summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGrzegorz Bizon <grzegorz@gitlab.com>2018-12-20 11:47:01 +0000
committerGrzegorz Bizon <grzegorz@gitlab.com>2018-12-20 11:47:01 +0000
commitc111e2657df22c811191135369d599923dc89f54 (patch)
tree2de468666124191dcf815cf4dd92ea21fa76ca16 /app
parentcad0661aadff50b4d2c2b4cc7b012809b945213c (diff)
parent37c934e089508e053e6ad4cf075b00cfaab53f3c (diff)
downloadgitlab-ce-c111e2657df22c811191135369d599923dc89f54.tar.gz
Merge branch 'master' into 'feature/option-to-make-variables-protected'
Conflicts: db/schema.rb
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/none-scheme-preview.pngbin0 -> 5971 bytes
-rw-r--r--app/assets/javascripts/api.js75
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js3
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js7
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js1
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js5
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue20
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js4
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue15
-rw-r--r--app/assets/javascripts/boards/models/issue.js6
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js3
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js10
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue96
-rw-r--r--app/assets/javascripts/clusters/constants.js1
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js6
-rw-r--r--app/assets/javascripts/diffs/components/app.vue31
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue16
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue12
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue23
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue3
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue21
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue9
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue31
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue20
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue23
-rw-r--r--app/assets/javascripts/diffs/components/no_changes.vue53
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue52
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue29
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue24
-rw-r--r--app/assets/javascripts/diffs/index.js4
-rw-r--r--app/assets/javascripts/diffs/store/actions.js67
-rw-r--r--app/assets/javascripts/diffs/store/getters.js34
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js74
-rw-r--r--app/assets/javascripts/diffs/store/utils.js24
-rw-r--r--app/assets/javascripts/dismissable_callout.js27
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue11
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue6
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js61
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js19
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js7
-rw-r--r--app/assets/javascripts/groups_select.js7
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue2
-rw-r--r--app/assets/javascripts/ide/constants.js1
-rw-r--r--app/assets/javascripts/ide/index.js10
-rw-r--r--app/assets/javascripts/ide/services/index.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/actions.js12
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js2
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/app.vue2
-rw-r--r--app/assets/javascripts/jobs/components/artifacts_block.vue32
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue21
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue35
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue3
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue6
-rw-r--r--app/assets/javascripts/jobs/components/stuck_block.vue10
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue69
-rw-r--r--app/assets/javascripts/labels_select.js33
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js6
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js5
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js13
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js2
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js42
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js38
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js28
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/milestone_select.js8
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js1
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue97
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue10
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue28
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js34
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js32
-rw-r--r--app/assets/javascripts/mr_notes/index.js2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue258
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue87
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue28
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue29
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue12
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue38
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue5
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue53
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue9
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue435
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue98
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue112
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js53
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js2
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js4
-rw-r--r--app/assets/javascripts/notes/stores/actions.js48
-rw-r--r--app/assets/javascripts/notes/stores/getters.js49
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js4
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js56
-rw-r--r--app/assets/javascripts/notifications_dropdown.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/index.js10
-rw-r--r--app/assets/javascripts/pages/profiles/show/emoji_menu.js1
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js2
-rw-r--r--app/assets/javascripts/pages/projects/project.js9
-rw-r--r--app/assets/javascripts/pages/projects/releases/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/serverless/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_avatar.js16
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js4
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js8
-rw-r--r--app/assets/javascripts/persistent_user_callout.js34
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue10
-rw-r--r--app/assets/javascripts/registry/components/app.vue12
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue61
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue56
-rw-r--r--app/assets/javascripts/registry/stores/actions.js36
-rw-r--r--app/assets/javascripts/registry/stores/index.js28
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js1
-rw-r--r--app/assets/javascripts/registry/stores/state.js26
-rw-r--r--app/assets/javascripts/releases/components/app.vue82
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue122
-rw-r--r--app/assets/javascripts/releases/index.js24
-rw-r--r--app/assets/javascripts/releases/store/actions.js37
-rw-r--r--app/assets/javascripts/releases/store/index.js14
-rw-r--r--app/assets/javascripts/releases/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/releases/store/mutations.js37
-rw-r--r--app/assets/javascripts/releases/store/state.js5
-rw-r--r--app/assets/javascripts/serverless/components/empty_state.vue40
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue40
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue123
-rw-r--r--app/assets/javascripts/serverless/event_hub.js3
-rw-r--r--app/assets/javascripts/serverless/serverless_bundle.js106
-rw-r--r--app/assets/javascripts/serverless/services/get_functions_service.js11
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js24
-rw-r--r--app/assets/javascripts/star.js15
-rw-r--r--app/assets/javascripts/terminal/index.js2
-rw-r--r--app/assets/javascripts/terminal/terminal.js57
-rw-r--r--app/assets/javascripts/user_popovers.js107
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue110
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue74
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue56
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue94
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue100
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue60
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue136
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue50
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue108
-rw-r--r--app/assets/stylesheets/application.scss6
-rw-r--r--app/assets/stylesheets/bootstrap.scss2
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss15
-rw-r--r--app/assets/stylesheets/components/popover.scss9
-rw-r--r--app/assets/stylesheets/csslab.scss1
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/avatar.scss4
-rw-r--r--app/assets/stylesheets/framework/buttons.scss10
-rw-r--r--app/assets/stylesheets/framework/callout.scss6
-rw-r--r--app/assets/stylesheets/framework/common.scss15
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss38
-rw-r--r--app/assets/stylesheets/framework/files.scss2
-rw-r--r--app/assets/stylesheets/framework/filters.scss16
-rw-r--r--app/assets/stylesheets/framework/flex_grid.scss52
-rw-r--r--app/assets/stylesheets/framework/forms.scss7
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss10
-rw-r--r--app/assets/stylesheets/framework/header.scss31
-rw-r--r--app/assets/stylesheets/framework/highlight.scss3
-rw-r--r--app/assets/stylesheets/framework/icons.scss12
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss23
-rw-r--r--app/assets/stylesheets/framework/mobile.scss9
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss4
-rw-r--r--app/assets/stylesheets/framework/selects.scss5
-rw-r--r--app/assets/stylesheets/framework/timeline.scss10
-rw-r--r--app/assets/stylesheets/framework/typography.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss18
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss9
-rw-r--r--app/assets/stylesheets/highlight/none.scss242
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_mixins.scss18
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss17
-rw-r--r--app/assets/stylesheets/pages/boards.scss2
-rw-r--r--app/assets/stylesheets/pages/builds.scss22
-rw-r--r--app/assets/stylesheets/pages/diff.scss1
-rw-r--r--app/assets/stylesheets/pages/issues.scss10
-rw-r--r--app/assets/stylesheets/pages/login.scss5
-rw-r--r--app/assets/stylesheets/pages/merge_conflicts.scss24
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss38
-rw-r--r--app/assets/stylesheets/pages/note_form.scss14
-rw-r--r--app/assets/stylesheets/pages/notes.scss67
-rw-r--r--app/assets/stylesheets/pages/profile.scss20
-rw-r--r--app/assets/stylesheets/pages/projects.scss394
-rw-r--r--app/assets/stylesheets/pages/search.scss2
-rw-r--r--app/assets/stylesheets/pages/tree.scss12
-rw-r--r--app/assets/stylesheets/pages/wiki.scss2
-rw-r--r--app/controllers/admin/health_check_controller.rb8
-rw-r--r--app/controllers/admin/impersonations_controller.rb13
-rw-r--r--app/controllers/admin/requests_profiles_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb5
-rw-r--r--app/controllers/application_controller.rb61
-rw-r--r--app/controllers/boards/issues_controller.rb16
-rw-r--r--app/controllers/chaos_controller.rb10
-rw-r--r--app/controllers/clusters/applications_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb28
-rw-r--r--app/controllers/concerns/invalid_utf8_error_handler.rb27
-rw-r--r--app/controllers/concerns/issuable_collections.rb38
-rw-r--r--app/controllers/concerns/lfs_request.rb6
-rw-r--r--app/controllers/concerns/notes_actions.rb10
-rw-r--r--app/controllers/concerns/preview_markdown.rb10
-rw-r--r--app/controllers/concerns/renders_commits.rb6
-rw-r--r--app/controllers/concerns/service_params.rb6
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb28
-rw-r--r--app/controllers/concerns/snippets_actions.rb2
-rw-r--r--app/controllers/concerns/uploads_actions.rb14
-rw-r--r--app/controllers/dashboard/projects_controller.rb3
-rw-r--r--app/controllers/dashboard/todos_controller.rb10
-rw-r--r--app/controllers/dashboard_controller.rb3
-rw-r--r--app/controllers/explore/projects_controller.rb2
-rw-r--r--app/controllers/graphql_controller.rb3
-rw-r--r--app/controllers/groups/clusters_controller.rb8
-rw-r--r--app/controllers/groups_controller.rb3
-rw-r--r--app/controllers/import/github_controller.rb2
-rw-r--r--app/controllers/metrics_controller.rb2
-rw-r--r--app/controllers/notification_settings_controller.rb10
-rw-r--r--app/controllers/oauth/applications_controller.rb2
-rw-r--r--app/controllers/profiles/accounts_controller.rb8
-rw-r--r--app/controllers/profiles/keys_controller.rb5
-rw-r--r--app/controllers/projects/artifacts_controller.rb9
-rw-r--r--app/controllers/projects/blob_controller.rb13
-rw-r--r--app/controllers/projects/branches_controller.rb2
-rw-r--r--app/controllers/projects/commits_controller.rb20
-rw-r--r--app/controllers/projects/compare_controller.rb6
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb6
-rw-r--r--app/controllers/projects/imports_controller.rb16
-rw-r--r--app/controllers/projects/issues_controller.rb20
-rw-r--r--app/controllers/projects/jobs_controller.rb20
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb7
-rw-r--r--app/controllers/projects/merge_requests_controller.rb12
-rw-r--r--app/controllers/projects/milestones_controller.rb21
-rw-r--r--app/controllers/projects/pipelines_controller.rb10
-rw-r--r--app/controllers/projects/protected_branches_controller.rb4
-rw-r--r--app/controllers/projects/protected_refs_controller.rb4
-rw-r--r--app/controllers/projects/protected_tags_controller.rb4
-rw-r--r--app/controllers/projects/releases_controller.rb33
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb37
-rw-r--r--app/controllers/projects/settings/repository_controller.rb14
-rw-r--r--app/controllers/projects/tags/releases_controller.rb42
-rw-r--r--app/controllers/projects/tags_controller.rb8
-rw-r--r--app/controllers/projects_controller.rb2
-rw-r--r--app/controllers/users_controller.rb5
-rw-r--r--app/finders/cluster_ancestors_finder.rb35
-rw-r--r--app/finders/concerns/finder_with_cross_project_access.rb30
-rw-r--r--app/finders/events_finder.rb45
-rw-r--r--app/finders/issuable_finder.rb42
-rw-r--r--app/finders/pipelines_finder.rb2
-rw-r--r--app/finders/projects/serverless/functions_finder.rb31
-rw-r--r--app/finders/remote_mirror_finder.rb15
-rw-r--r--app/helpers/appearances_helper.rb7
-rw-r--r--app/helpers/application_helper.rb7
-rw-r--r--app/helpers/auth_helper.rb4
-rw-r--r--app/helpers/blob_helper.rb4
-rw-r--r--app/helpers/button_helper.rb5
-rw-r--r--app/helpers/dropdowns_helper.rb9
-rw-r--r--app/helpers/emails_helper.rb25
-rw-r--r--app/helpers/events_helper.rb4
-rw-r--r--app/helpers/groups_helper.rb2
-rw-r--r--app/helpers/icons_helper.rb2
-rw-r--r--app/helpers/ide_helper.rb16
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/milestones_helper.rb13
-rw-r--r--app/helpers/nav_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb38
-rw-r--r--app/helpers/selects_helper.rb5
-rw-r--r--app/helpers/sentry_helper.rb11
-rw-r--r--app/helpers/sorting_helper.rb60
-rw-r--r--app/helpers/users_helper.rb6
-rw-r--r--app/helpers/version_check_helper.rb3
-rw-r--r--app/helpers/visibility_level_helper.rb2
-rw-r--r--app/helpers/workhorse_helper.rb9
-rw-r--r--app/mailers/emails/notes.rb2
-rw-r--r--app/mailers/emails/projects.rb15
-rw-r--r--app/mailers/emails/remote_mirrors.rb12
-rw-r--r--app/mailers/notify.rb9
-rw-r--r--app/mailers/previews/notify_preview.rb8
-rw-r--r--app/models/application_setting.rb2
-rw-r--r--app/models/broadcast_message.rb27
-rw-r--r--app/models/ci/bridge.rb34
-rw-r--r--app/models/ci/build.rb5
-rw-r--r--app/models/ci/build_trace_chunk.rb17
-rw-r--r--app/models/ci/pipeline.rb79
-rw-r--r--app/models/ci/pipeline_enums.rb13
-rw-r--r--app/models/ci/runner.rb15
-rw-r--r--app/models/clusters/applications/cert_manager.rb4
-rw-r--r--app/models/clusters/applications/jupyter.rb6
-rw-r--r--app/models/clusters/applications/knative.rb38
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb50
-rw-r--r--app/models/clusters/kubernetes_namespace.rb4
-rw-r--r--app/models/clusters/platforms/kubernetes.rb6
-rw-r--r--app/models/commit.rb8
-rw-r--r--app/models/commit_collection.rb2
-rw-r--r--app/models/concerns/avatarable.rb13
-rw-r--r--app/models/concerns/awardable.rb13
-rw-r--r--app/models/concerns/cache_markdown_field.rb2
-rw-r--r--app/models/concerns/chronic_duration_attribute.rb2
-rw-r--r--app/models/concerns/deployment_platform.rb13
-rw-r--r--app/models/concerns/discussion_on_diff.rb5
-rw-r--r--app/models/concerns/enum_with_nil.rb3
-rw-r--r--app/models/concerns/fast_destroy_all.rb5
-rw-r--r--app/models/concerns/issuable.rb28
-rw-r--r--app/models/concerns/noteable.rb4
-rw-r--r--app/models/concerns/redis_cacheable.rb6
-rw-r--r--app/models/concerns/shardable.rb18
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb2
-rw-r--r--app/models/concerns/token_authenticatable.rb25
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb37
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encrypted.rb103
-rw-r--r--app/models/concerns/with_uploads.rb31
-rw-r--r--app/models/diff_note.rb13
-rw-r--r--app/models/environment.rb5
-rw-r--r--app/models/environment_status.rb31
-rw-r--r--app/models/event.rb15
-rw-r--r--app/models/group.rb6
-rw-r--r--app/models/hooks/web_hook.rb4
-rw-r--r--app/models/issue.rb14
-rw-r--r--app/models/member.rb19
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/merge_request.rb112
-rw-r--r--app/models/namespace.rb7
-rw-r--r--app/models/note.rb16
-rw-r--r--app/models/notification_setting.rb2
-rw-r--r--app/models/pool_repository.rb112
-rw-r--r--app/models/project.rb316
-rw-r--r--app/models/project_import_data.rb8
-rw-r--r--app/models/project_import_state.rb29
-rw-r--r--app/models/project_repository.rb13
-rw-r--r--app/models/project_services/kubernetes_service.rb4
-rw-r--r--app/models/project_services/pipelines_email_service.rb4
-rw-r--r--app/models/project_services/prometheus_service.rb2
-rw-r--r--app/models/prometheus_metric.rb7
-rw-r--r--app/models/protected_branch.rb1
-rw-r--r--app/models/protected_tag.rb1
-rw-r--r--app/models/release.rb1
-rw-r--r--app/models/remote_mirror.rb10
-rw-r--r--app/models/repository.rb10
-rw-r--r--app/models/service.rb6
-rw-r--r--app/models/shard.rb4
-rw-r--r--app/models/storage/hashed_project.rb8
-rw-r--r--app/models/suggestion.rb55
-rw-r--r--app/models/upload.rb19
-rw-r--r--app/models/uploads/base.rb19
-rw-r--r--app/models/uploads/fog.rb43
-rw-r--r--app/models/uploads/local.rb56
-rw-r--r--app/models/user.rb1
-rw-r--r--app/models/wiki_page.rb6
-rw-r--r--app/policies/commit_policy.rb2
-rw-r--r--app/policies/group_policy.rb1
-rw-r--r--app/policies/note_policy.rb9
-rw-r--r--app/policies/suggestion_policy.rb11
-rw-r--r--app/presenters/clusters/cluster_presenter.rb50
-rw-r--r--app/presenters/group_clusterable_presenter.rb2
-rw-r--r--app/presenters/member_presenter.rb8
-rw-r--r--app/presenters/project_presenter.rb140
-rw-r--r--app/serializers/README.md4
-rw-r--r--app/serializers/cluster_application_entity.rb1
-rw-r--r--app/serializers/diff_file_base_entity.rb101
-rw-r--r--app/serializers/diff_file_entity.rb98
-rw-r--r--app/serializers/diff_line_entity.rb2
-rw-r--r--app/serializers/discussion_diff_file_entity.rb4
-rw-r--r--app/serializers/discussion_entity.rb15
-rw-r--r--app/serializers/environment_entity.rb16
-rw-r--r--app/serializers/issue_board_entity.rb50
-rw-r--r--app/serializers/issue_serializer.rb6
-rw-r--r--app/serializers/label_entity.rb4
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/serializers/note_entity.rb1
-rw-r--r--app/serializers/pipeline_entity.rb2
-rw-r--r--app/serializers/projects/serverless/service_entity.rb33
-rw-r--r--app/serializers/projects/serverless/service_serializer.rb9
-rw-r--r--app/serializers/suggestion_entity.rb17
-rw-r--r--app/serializers/trigger_variable_entity.rb3
-rw-r--r--app/services/access_token_validation_service.rb6
-rw-r--r--app/services/ci/archive_trace_service.rb35
-rw-r--r--app/services/ci/compare_reports_base_service.rb47
-rw-r--r--app/services/ci/compare_test_reports_service.rb36
-rw-r--r--app/services/ci/create_pipeline_service.rb15
-rw-r--r--app/services/clusters/applications/create_service.rb2
-rw-r--r--app/services/clusters/build_service.rb21
-rw-r--r--app/services/clusters/gcp/fetch_operation_service.rb13
-rw-r--r--app/services/clusters/gcp/finalize_creation_service.rb36
-rw-r--r--app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb2
-rw-r--r--app/services/clusters/gcp/kubernetes/create_or_update_service_account_service.rb (renamed from app/services/clusters/gcp/kubernetes/create_service_account_service.rb)11
-rw-r--r--app/services/clusters/refresh_service.rb40
-rw-r--r--app/services/create_release_service.rb9
-rw-r--r--app/services/deploy_keys/create_service.rb2
-rw-r--r--app/services/files/multi_service.rb11
-rw-r--r--app/services/groups/update_service.rb2
-rw-r--r--app/services/issuable/common_system_notes_service.rb21
-rw-r--r--app/services/issuable_base_service.rb6
-rw-r--r--app/services/labels/promote_service.rb8
-rw-r--r--app/services/merge_requests/base_service.rb18
-rw-r--r--app/services/merge_requests/create_service.rb7
-rw-r--r--app/services/merge_requests/refresh_service.rb23
-rw-r--r--app/services/merge_requests/update_service.rb9
-rw-r--r--app/services/notes/create_service.rb1
-rw-r--r--app/services/notes/update_service.rb11
-rw-r--r--app/services/notification_recipient_service.rb23
-rw-r--r--app/services/notification_service.rb40
-rw-r--r--app/services/preview_markdown_service.rb8
-rw-r--r--app/services/projects/auto_devops/disable_service.rb2
-rw-r--r--app/services/projects/cleanup_service.rb52
-rw-r--r--app/services/projects/create_service.rb14
-rw-r--r--app/services/projects/destroy_service.rb2
-rw-r--r--app/services/projects/fork_service.rb2
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb1
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb3
-rw-r--r--app/services/projects/transfer_service.rb5
-rw-r--r--app/services/suggestions/apply_service.rb54
-rw-r--r--app/services/suggestions/create_service.rb56
-rw-r--r--app/services/tags/create_service.rb2
-rw-r--r--app/services/test_hooks/project_service.rb2
-rw-r--r--app/services/users/update_service.rb10
-rw-r--r--app/validators/duration_validator.rb6
-rw-r--r--app/validators/url_validator.rb1
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml2
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/hooks/index.html.haml2
-rw-r--r--app/views/admin/runners/_sort_dropdown.html.haml6
-rw-r--r--app/views/admin/users/_head.html.haml2
-rw-r--r--app/views/admin/users/index.html.haml24
-rw-r--r--app/views/clusters/clusters/_buttons.html.haml7
-rw-r--r--app/views/clusters/clusters/_cluster.html.haml4
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml4
-rw-r--r--app/views/clusters/clusters/index.html.haml7
-rw-r--r--app/views/dashboard/activity.html.haml3
-rw-r--r--app/views/dashboard/groups/index.html.haml2
-rw-r--r--app/views/dashboard/issues.html.haml2
-rw-r--r--app/views/dashboard/merge_requests.html.haml2
-rw-r--r--app/views/dashboard/projects/index.html.haml2
-rw-r--r--app/views/dashboard/projects/starred.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml2
-rw-r--r--app/views/devise/mailer/email_changed.html.haml12
-rw-r--r--app/views/devise/mailer/email_changed.text.erb10
-rw-r--r--app/views/errors/access_denied.html.haml2
-rw-r--r--app/views/explore/groups/index.html.haml2
-rw-r--r--app/views/explore/projects/_filter.html.haml6
-rw-r--r--app/views/explore/projects/index.html.haml2
-rw-r--r--app/views/explore/projects/starred.html.haml2
-rw-r--r--app/views/explore/projects/trending.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml2
-rw-r--r--app/views/groups/edit.html.haml1
-rw-r--r--app/views/groups/group_members/_new_group_member.html.haml2
-rw-r--r--app/views/groups/labels/edit.html.haml4
-rw-r--r--app/views/groups/labels/new.html.haml5
-rw-r--r--app/views/groups/milestones/edit.html.haml5
-rw-r--r--app/views/groups/milestones/new.html.haml15
-rw-r--r--app/views/ide/_show.html.haml10
-rw-r--r--app/views/ide/index.html.haml18
-rw-r--r--app/views/import/bitbucket/status.html.haml5
-rw-r--r--app/views/import/bitbucket_server/status.html.haml5
-rw-r--r--app/views/import/fogbugz/status.html.haml5
-rw-r--r--app/views/import/gitlab/status.html.haml5
-rw-r--r--app/views/import/google_code/status.html.haml5
-rw-r--r--app/views/invites/show.html.haml17
-rw-r--r--app/views/issues/_issues_calendar.ics.ruby2
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/devise.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml2
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml35
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml17
-rw-r--r--app/views/notify/_note_email.html.haml23
-rw-r--r--app/views/notify/_note_email.text.erb13
-rw-r--r--app/views/notify/note_project_snippet_email.html.haml (renamed from app/views/notify/note_snippet_email.html.haml)0
-rw-r--r--app/views/notify/note_project_snippet_email.text.erb (renamed from app/views/notify/note_snippet_email.text.erb)0
-rw-r--r--app/views/notify/remote_mirror_update_failed_email.html.haml46
-rw-r--r--app/views/notify/remote_mirror_update_failed_email.text.erb7
-rw-r--r--app/views/notify/repository_cleanup_failure_email.text.erb3
-rw-r--r--app/views/notify/repository_cleanup_success_email.text.erb3
-rw-r--r--app/views/profiles/accounts/show.html.haml9
-rw-r--r--app/views/profiles/show.html.haml38
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml3
-rw-r--r--app/views/projects/_files.html.haml6
-rw-r--r--app/views/projects/_home_panel.html.haml130
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml4
-rw-r--r--app/views/projects/blob/_blob.html.haml2
-rw-r--r--app/views/projects/blob/preview.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml2
-rw-r--r--app/views/projects/buttons/_clone.html.haml31
-rw-r--r--app/views/projects/buttons/_download.html.haml2
-rw-r--r--app/views/projects/buttons/_fork.html.haml6
-rw-r--r--app/views/projects/buttons/_notifications.html.haml27
-rw-r--r--app/views/projects/buttons/_star.html.haml12
-rw-r--r--app/views/projects/cleanup/_show.html.haml29
-rw-r--r--app/views/projects/commit/_commit_box.html.haml6
-rw-r--r--app/views/projects/commits/_commit.html.haml94
-rw-r--r--app/views/projects/edit.html.haml2
-rw-r--r--app/views/projects/empty.html.haml122
-rw-r--r--app/views/projects/environments/index.html.haml1
-rw-r--r--app/views/projects/forks/index.html.haml2
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/imports/show.html.haml2
-rw-r--r--app/views/projects/labels/edit.html.haml2
-rw-r--r--app/views/projects/labels/index.html.haml2
-rw-r--r--app/views/projects/labels/new.html.haml3
-rw-r--r--app/views/projects/merge_requests/show.html.haml6
-rw-r--r--app/views/projects/milestones/edit.html.haml3
-rw-r--r--app/views/projects/milestones/new.html.haml3
-rw-r--r--app/views/projects/mirrors/_authentication_method.html.haml4
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml12
-rw-r--r--app/views/projects/mirrors/_mirror_repos_form.html.haml2
-rw-r--r--app/views/projects/new.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml32
-rw-r--r--app/views/projects/project_members/_new_project_group.html.haml2
-rw-r--r--app/views/projects/project_members/_new_project_member.html.haml2
-rw-r--r--app/views/projects/project_templates/_built_in_templates.html.haml6
-rw-r--r--app/views/projects/releases/index.html.haml5
-rw-r--r--app/views/projects/serverless/functions/index.html.haml15
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml1
-rw-r--r--app/views/projects/show.html.haml16
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/projects/tags/index.html.haml2
-rw-r--r--app/views/projects/tags/releases/edit.html.haml (renamed from app/views/projects/releases/edit.html.haml)0
-rw-r--r--app/views/projects/tree/_tree_content.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml8
-rw-r--r--app/views/projects/wikis/_sidebar_wiki_page.html.haml2
-rw-r--r--app/views/projects/wikis/edit.html.haml10
-rw-r--r--app/views/projects/wikis/history.html.haml4
-rw-r--r--app/views/projects/wikis/show.html.haml8
-rw-r--r--app/views/search/results/_blob.html.haml6
-rw-r--r--app/views/search/results/_wiki_blob.html.haml4
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml2
-rw-r--r--app/views/shared/_milestones_sort_dropdown.html.haml14
-rw-r--r--app/views/shared/_mobile_clone_panel.html.haml12
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml2
-rw-r--r--app/views/shared/_sort_dropdown.html.haml16
-rw-r--r--app/views/shared/groups/_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_filter.html.haml32
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_nav.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml10
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml21
-rw-r--r--app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml2
-rw-r--r--app/views/shared/issuable/nav_links/_all.html.haml2
-rw-r--r--app/views/shared/labels/_sort_dropdown.html.haml4
-rw-r--r--app/views/shared/members/_access_request_links.html.haml17
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_milestone.html.haml4
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/notifications/_button.html.haml6
-rw-r--r--app/views/shared/projects/_list.html.haml13
-rw-r--r--app/views/shared/projects/_project.html.haml141
-rw-r--r--app/views/shared/runners/_form.html.haml2
-rw-r--r--app/views/snippets/_actions.html.haml26
-rw-r--r--app/views/snippets/_snippets.html.haml2
-rw-r--r--app/views/snippets/_snippets_scope_menu.html.haml8
-rw-r--r--app/views/snippets/edit.html.haml5
-rw-r--r--app/views/snippets/index.html.haml6
-rw-r--r--app/views/snippets/new.html.haml2
-rw-r--r--app/views/snippets/notes/_actions.html.haml4
-rw-r--r--app/views/snippets/show.html.haml4
-rw-r--r--app/views/users/_overview.html.haml30
-rw-r--r--app/views/users/calendar_activities.html.haml2
-rw-r--r--app/views/users/show.html.haml12
-rw-r--r--app/workers/all_queues.yml10
-rw-r--r--app/workers/archive_trace_worker.rb2
-rw-r--r--app/workers/ci/archive_traces_cron_worker.rb14
-rw-r--r--app/workers/cluster_platform_configure_worker.rb12
-rw-r--r--app/workers/cluster_project_configure_worker.rb12
-rw-r--r--app/workers/concerns/gitlab/github_import/stage_methods.rb2
-rw-r--r--app/workers/concerns/object_pool_queue.rb12
-rw-r--r--app/workers/concerns/project_import_options.rb2
-rw-r--r--app/workers/concerns/project_start_import.rb6
-rw-r--r--app/workers/delete_stored_files_worker.rb22
-rw-r--r--app/workers/git_garbage_collect_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb11
-rw-r--r--app/workers/gitlab/github_import/refresh_import_jid_worker.rb14
-rw-r--r--app/workers/gitlab/github_import/stage/import_base_data_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb2
-rw-r--r--app/workers/new_note_worker.rb9
-rw-r--r--app/workers/object_pool/create_worker.rb44
-rw-r--r--app/workers/object_pool/destroy_worker.rb16
-rw-r--r--app/workers/object_pool/join_worker.rb20
-rw-r--r--app/workers/object_pool/schedule_join_worker.rb19
-rw-r--r--app/workers/pipeline_schedule_worker.rb32
-rw-r--r--app/workers/remote_mirror_notification_worker.rb15
-rw-r--r--app/workers/remove_old_web_hook_logs_worker.rb14
-rw-r--r--app/workers/repository_cleanup_worker.rb39
-rw-r--r--app/workers/repository_fork_worker.rb4
-rw-r--r--app/workers/repository_import_worker.rb4
-rw-r--r--app/workers/repository_update_remote_mirror_worker.rb3
-rw-r--r--app/workers/stuck_import_jobs_worker.rb2
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb6
-rw-r--r--app/workers/update_head_pipeline_for_merge_request_worker.rb6
622 files changed, 8998 insertions, 3421 deletions
diff --git a/app/assets/images/none-scheme-preview.png b/app/assets/images/none-scheme-preview.png
new file mode 100644
index 00000000000..2eb6bf96671
--- /dev/null
+++ b/app/assets/images/none-scheme-preview.png
Binary files differ
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 3f7a1ef1bfc..a1310d18c26 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -5,25 +5,31 @@ import axios from './lib/utils/axios_utils';
const Api = {
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
+ subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
projectLabelsPath: '/:namespace_path/:project_path/labels',
- mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
+ 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',
+ projectRunnersPath: '/api/:version/projects/:id/runners',
mergeRequestsPath: '/api/:version/merge_requests',
- mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
- mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
usersPath: '/api/:version/users.json',
- userStatusPath: '/api/:version/user/status',
+ userPath: '/api/:version/users/:id',
+ userStatusPath: '/api/:version/users/:id/status',
+ userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
+ applySuggestionPath: '/api/:version/suggestions/:id/apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
+ releasesPath: '/api/:version/project/:id/releases',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -99,36 +105,45 @@ const Api = {
},
// Return Merge Request for project
- mergeRequest(projectPath, mergeRequestId, params = {}) {
- const url = Api.buildUrl(Api.mergeRequestPath)
+ projectMergeRequest(projectPath, mergeRequestId, params = {}) {
+ const url = Api.buildUrl(Api.projectMergeRequestPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':mrid', mergeRequestId);
return axios.get(url, { params });
},
- mergeRequests(params = {}) {
- const url = Api.buildUrl(Api.mergeRequestsPath);
-
- return axios.get(url, { params });
- },
-
- mergeRequestChanges(projectPath, mergeRequestId) {
- const url = Api.buildUrl(Api.mergeRequestChangesPath)
+ projectMergeRequestChanges(projectPath, mergeRequestId) {
+ const url = Api.buildUrl(Api.projectMergeRequestChangesPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':mrid', mergeRequestId);
return axios.get(url);
},
- mergeRequestVersions(projectPath, mergeRequestId) {
- const url = Api.buildUrl(Api.mergeRequestVersionsPath)
+ projectMergeRequestVersions(projectPath, mergeRequestId) {
+ const url = Api.buildUrl(Api.projectMergeRequestVersionsPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':mrid', mergeRequestId);
return axios.get(url);
},
+ projectRunners(projectPath, config = {}) {
+ const url = Api.buildUrl(Api.projectRunnersPath).replace(
+ ':id',
+ encodeURIComponent(projectPath),
+ );
+
+ return axios.get(url, config);
+ },
+
+ mergeRequests(params = {}) {
+ const url = Api.buildUrl(Api.mergeRequestsPath);
+
+ return axios.get(url, { params });
+ },
+
newLabel(namespacePath, projectPath, data, callback) {
let url;
@@ -172,6 +187,12 @@ const Api = {
});
},
+ applySuggestion(id) {
+ const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id));
+
+ return axios.put(url);
+ },
+
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')
@@ -243,6 +264,20 @@ const Api = {
});
},
+ user(id, options) {
+ const url = Api.buildUrl(this.userPath).replace(':id', encodeURIComponent(id));
+ return axios.get(url, {
+ params: options,
+ });
+ },
+
+ userStatus(id, options) {
+ const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id));
+ return axios.get(url, {
+ params: options,
+ });
+ },
+
branches(id, query = '', options = {}) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
@@ -265,7 +300,7 @@ const Api = {
},
postUserStatus({ emoji, message }) {
- const url = Api.buildUrl(this.userStatusPath);
+ const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, {
emoji,
@@ -273,6 +308,12 @@ const Api = {
});
},
+ releases(id) {
+ const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
buildUrl(url) {
let urlRoot = '';
if (gon.relative_url_root != null) {
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index a2d4331b6d1..fc9286d15e6 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math';
import renderMermaid from './render_mermaid';
import highlightCurrentUser from './highlight_current_user';
+import initUserPopovers from '../../user_popovers';
// Render GitLab flavoured Markdown
//
@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get());
+ initUserPopovers(this.find('.gfm-project_member').get());
return this;
};
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 720f30e18e6..35380ca49fb 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -26,6 +26,9 @@ export default function renderMermaid($els) {
},
// mermaidAPI options
theme: 'neutral',
+ flowchart: {
+ htmlLabels: false,
+ },
});
$els.each((i, el) => {
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index c09d9ccddd6..d8056e48d4e 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -50,10 +50,11 @@ function hideOrShowHelpBlock(form) {
}
$(() => {
- const $form = $('form.js-requires-input');
- if ($form) {
+ $('form.js-requires-input').each((i, el) => {
+ const $form = $(el);
+
$form.requiresInput();
hideOrShowHelpBlock($form);
$('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
- }
+ });
});
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index fa9b2c9f755..bef1553703b 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -8,6 +8,7 @@ export default class ShortcutsNavigation extends Shortcuts {
Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
Mousetrap.bind('g v', () => findAndFollowLink('.shortcuts-project-activity'));
+ Mousetrap.bind('g r', () => findAndFollowLink('.shortcuts-project-releases'));
Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 9f547471170..b07f951346e 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -17,6 +17,11 @@ export default () => {
const currentAction = $('.js-file-title').data('currentAction');
const projectId = editBlobForm.data('project-id');
const commitButton = $('.js-commit-button');
+ const cancelLink = $('.btn.btn-cancel');
+
+ cancelLink.on('click', () => {
+ window.onbeforeunload = null;
+ });
commitButton.on('click', () => {
window.onbeforeunload = null;
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 15937b1091a..e038198e6f0 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -15,6 +15,16 @@ export default {
type: String,
required: true,
},
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'bottom',
+ },
},
computed: {
title() {
@@ -66,15 +76,13 @@ export default {
<template>
<span>
- <span ref="issueDueDate" class="board-card-info card-number">
- <icon
- :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }"
- name="calendar"
- /><time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
+ <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
+ <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" />
+ <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
</span>
- <gl-tooltip :target="() => $refs.issueDueDate" placement="bottom">
+ <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span> <br />
<span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span>
</gl-tooltip>
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index f7016561f93..10577da9305 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -37,7 +37,7 @@ export default function initNewListDropdown() {
});
},
renderRow(label) {
- const active = boardsStore.findList('title', label.title);
+ const active = boardsStore.findListByLabelId(label.id);
const $li = $('<li />');
const $a = $('<a />', {
class: active ? `is-active js-board-list-${active.id}` : '',
@@ -63,7 +63,7 @@ export default function initNewListDropdown() {
const label = options.selectedObj;
e.preventDefault();
- if (!boardsStore.findList('title', label.title)) {
+ if (!boardsStore.findListByLabelId(label.id)) {
boardsStore.new({
title: label.title,
position: boardsStore.state.lists.length - 2,
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 31651658fe6..d899b7fbd8c 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -92,20 +92,7 @@ export default {
{{ selectedProjectName }} <icon name="chevron-down" />
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
- <div class="dropdown-title">
- <span>Projects</span>
- <button
- aria-label="Close"
- type="button"
- class="dropdown-title-button dropdown-menu-close"
- >
- <icon
- name="merge-request-close-m"
- data-hidden="true"
- class="dropdown-menu-close-icon"
- />
- </button>
- </div>
+ <div class="dropdown-title">Projects</div>
<div class="dropdown-input">
<input class="dropdown-input-field" type="search" placeholder="Search projects" />
<icon name="search" class="dropdown-input-search" data-hidden="true" />
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 5e0f0b07247..dd92d3c8552 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -55,12 +55,12 @@ class ListIssue {
}
findLabel(findLabel) {
- return this.labels.filter(label => label.title === findLabel.title)[0];
+ return this.labels.find(label => label.id === findLabel.id);
}
removeLabel(removeLabel) {
if (removeLabel) {
- this.labels = this.labels.filter(label => removeLabel.title !== label.title);
+ this.labels = this.labels.filter(label => removeLabel.id !== label.id);
}
}
@@ -75,7 +75,7 @@ class ListIssue {
}
findAssignee(findAssignee) {
- return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ return this.assignees.find(assignee => assignee.id === findAssignee.id);
}
removeAssignee(removeAssignee) {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index cf88a973d33..802796208c2 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -166,6 +166,9 @@ const boardsStore = {
});
return filteredList[0];
},
+ findListByLabelId(id) {
+ return this.state.lists.find(list => list.type === 'label' && list.label.id === id);
+ },
updateFiltersUrl() {
window.history.pushState(null, null, `?${this.filter.path}`);
},
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index aff32d95db1..cf70a48f076 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -1,6 +1,6 @@
import Visibility from 'visibilityjs';
import Vue from 'vue';
-import PersistentUserCallout from '../persistent_user_callout';
+import initDismissableCallout from '~/dismissable_callout';
import { s__, sprintf } from '../locale';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
@@ -67,7 +67,7 @@ export default class Clusters {
this.showTokenButton = document.querySelector('.js-show-cluster-token');
this.tokenField = document.querySelector('.js-cluster-token');
- Clusters.initDismissableCallout();
+ initDismissableCallout('.js-cluster-security-warning');
initSettingsPanels();
setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area'));
this.initApplications(clusterType);
@@ -108,12 +108,6 @@ export default class Clusters {
});
}
- static initDismissableCallout() {
- const callout = document.querySelector('.js-cluster-security-warning');
-
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
- }
-
addListeners() {
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
eventHub.$on('installApplication', this.installApplication);
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index a37cb4def28..489615f1f78 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -84,6 +84,9 @@ export default {
ingressExternalIp() {
return this.applications.ingress.externalIp;
},
+ certManagerInstalled() {
+ return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED;
+ },
ingressDescription() {
const extraCostParagraph = sprintf(
_.escape(
@@ -130,9 +133,9 @@ export default {
return sprintf(
_.escape(
s__(
- `ClusterIntegration|cert-manager is a native Kubernetes certificate management controller that helps with issuing certificates.
- Installing cert-manager on your cluster will issue a certificate by %{letsEncrypt} and ensure that certificates
- are valid and up to date.`,
+ `ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates.
+ Installing Cert-Manager on your cluster will issue a certificate by %{letsEncrypt} and ensure that certificates
+ are valid and up-to-date.`,
),
),
{
@@ -259,6 +262,16 @@ export default {
</span>
</div>
<input v-else type="text" class="form-control js-ip-address" readonly value="?" />
+ <p class="form-text text-muted">
+ {{
+ 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>
</div>
<p v-if="!ingressExternalIp" class="settings-message js-no-ip-message">
@@ -272,17 +285,6 @@ export default {
{{ __('More information') }}
</a>
</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>
<div v-html="ingressDescription"></div>
</div>
@@ -295,11 +297,41 @@ export default {
:status-reason="applications.cert_manager.statusReason"
:request-status="applications.cert_manager.requestStatus"
:request-reason="applications.cert_manager.requestReason"
+ :install-application-request-params="{ email: applications.cert_manager.email }"
:disabled="!helmInstalled"
- class="hide-bottom-border rounded-bottom"
title-link="https://cert-manager.readthedocs.io/en/latest/#"
>
- <div slot="description" v-html="certManagerDescription"></div>
+ <template>
+ <div slot="description">
+ <p v-html="certManagerDescription"></p>
+ <div class="form-group">
+ <label for="cert-manager-issuer-email">
+ {{ s__('ClusterIntegration|Issuer Email') }}
+ </label>
+ <div class="input-group">
+ <input
+ v-model="applications.cert_manager.email"
+ :readonly="certManagerInstalled"
+ type="text"
+ class="form-control js-email"
+ />
+ </div>
+ <p class="form-text text-muted">
+ {{
+ s__(`ClusterIntegration|Issuers represent a certificate authority.
+ 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>
+ </p>
+ </div>
+ </div>
+ </template>
</application-row>
<application-row
v-if="isProjectCluster"
@@ -382,20 +414,22 @@ export default {
/>
</span>
</div>
+
+ <p v-if="ingressInstalled" class="form-text text-muted">
+ {{
+ s__(`ClusterIntegration|Replace this with your own hostname if you want.
+ If you do so, point hostname to Ingress IP Address from above.`)
+ }}
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
+ {{ __('More information') }}
+ </a>
+ </p>
</div>
- <p v-if="ingressInstalled">
- {{
- s__(`ClusterIntegration|Replace this with your own hostname if you want.
- If you do so, point hostname to Ingress IP Address from above.`)
- }}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
- </p>
</template>
</div>
</application-row>
<application-row
+ v-if="isProjectCluster"
id="knative"
:logo-url="knativeLogo"
:title="applications.knative.title"
@@ -405,17 +439,15 @@ export default {
:request-reason="applications.knative.requestReason"
:install-application-request-params="{ hostname: applications.knative.hostname }"
:disabled="!helmInstalled"
- class="hide-bottom-border rounded-bottom"
title-link="https://github.com/knative/docs"
>
<div slot="description">
<p>
{{
- s__(`ClusterIntegration|Knative (pronounced kay-nay-tiv) 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.`)
+ 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.`)
}}
</p>
@@ -433,7 +465,7 @@ export default {
/>
</div>
</template>
- <template v-else>
+ <template v-else-if="helmInstalled">
<div class="form-group">
<label for="knative-domainname">
{{ s__('ClusterIntegration|Knative Domain Name:') }}
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 15cf4a56138..e31afadf186 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -24,3 +24,4 @@ export const REQUEST_FAILURE = 'request-failure';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';
+export const CERT_MANAGER = 'cert_manager';
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 2d69da8eaec..c750daab112 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,5 +1,5 @@
import { s__ } from '../../locale';
-import { INGRESS, JUPYTER, KNATIVE } from '../constants';
+import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER } from '../constants';
export default class ClusterStore {
constructor() {
@@ -30,6 +30,7 @@ export default class ClusterStore {
statusReason: null,
requestStatus: null,
requestReason: null,
+ email: null,
},
runner: {
title: s__('ClusterIntegration|GitLab Runner'),
@@ -103,6 +104,9 @@ export default class ClusterStore {
if (appId === INGRESS) {
this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
+ } else if (appId === CERT_MANAGER) {
+ this.state.applications.cert_manager.email =
+ this.state.applications.cert_manager.email || serverAppEntry.email;
} else if (appId === JUPYTER) {
this.state.applications.jupyter.hostname =
serverAppEntry.hostname ||
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 22da38ce7a5..d4c1b07093d 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -42,6 +42,16 @@ export default {
type: Object,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ changesEmptyStateIllustration: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -63,7 +73,7 @@ export default {
plainDiffPath: state => state.diffs.plainDiffPath,
emailPatchPath: state => state.diffs.emailPatchPath,
}),
- ...mapState('diffs', ['showTreeList', 'isLoading']),
+ ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']),
...mapGetters('diffs', ['isParallelView']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
targetBranch() {
@@ -79,6 +89,13 @@ export default {
showCompareVersions() {
return this.mergeRequestDiffs && this.mergeRequestDiff;
},
+ renderDiffFiles() {
+ return (
+ this.diffFiles.length > 0 ||
+ (this.startVersion &&
+ this.startVersion.version_index === this.mergeRequestDiff.version_index)
+ );
+ },
},
watch: {
diffViewType() {
@@ -102,6 +119,12 @@ export default {
if (this.shouldShow) {
this.fetchData();
}
+
+ const id = window && window.location && window.location.hash;
+
+ if (id) {
+ this.setHighlightedRow(id.slice(1));
+ }
},
created() {
this.adjustView();
@@ -114,6 +137,7 @@ export default {
'fetchDiffFiles',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
+ 'setHighlightedRow',
]),
fetchData() {
this.fetchDiffFiles()
@@ -184,15 +208,16 @@ export default {
<div v-show="showTreeList" class="diff-tree-list"><tree-list /></div>
<div class="diff-files-holder">
<commit-widget v-if="commit" :commit="commit" />
- <template v-if="diffFiles.length > 0">
+ <template v-if="renderDiffFiles">
<diff-file
v-for="file in diffFiles"
:key="file.newPath"
:file="file"
+ :help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
/>
</template>
- <no-changes v-else />
+ <no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index e405d8b20ae..42d09e44768 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
+import EmptyFileViewer from '~/vue_shared/components/diff_viewer/viewers/empty_file.vue';
import InlineDiffView from './inline_diff_view.vue';
import ParallelDiffView from './parallel_diff_view.vue';
import NoteForm from '../../notes/components/note_form.vue';
@@ -17,12 +18,18 @@ export default {
NoteForm,
DiffDiscussions,
ImageDiffOverlay,
+ EmptyFileViewer,
},
props: {
diffFile: {
type: Object,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapState({
@@ -70,15 +77,18 @@ export default {
<div class="diff-content">
<div class="diff-viewer">
<template v-if="isTextFile">
+ <empty-file-viewer v-if="diffFile.empty" />
<inline-diff-view
- v-if="isInlineView"
+ v-else-if="isInlineView"
:diff-file="diffFile"
:diff-lines="diffFile.highlighted_diff_lines || []"
+ :help-page-path="helpPagePath"
/>
<parallel-diff-view
- v-if="isParallelView"
+ v-else-if="isParallelView"
:diff-file="diffFile"
:diff-lines="diffFile.parallel_diff_lines || []"
+ :help-page-path="helpPagePath"
/>
</template>
<diff-viewer
@@ -90,6 +100,8 @@ export default {
:old-sha="diffFile.diff_refs.base_sha"
:file-hash="diffFile.file_hash"
:project-path="projectPath"
+ :a-mode="diffFile.a_mode"
+ :b-mode="diffFile.b_mode"
>
<image-diff-overlay
slot="image-overlay"
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index bee29b04e92..b2021cd6061 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -13,6 +13,11 @@ export default {
type: Array,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
shouldCollapseDiscussions: {
type: Boolean,
required: false,
@@ -23,6 +28,11 @@ export default {
required: false,
default: false,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
methods: {
...mapActions(['toggleDiscussion']),
@@ -72,6 +82,8 @@ export default {
:render-diff-file="false"
:always-expanded="true"
:discussions-by-diff-order="true"
+ :line="line"
+ :help-page-path="helpPagePath"
@noteDeleted="deleteNoteHandler"
>
<span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index f7e3655ea40..449f7007077 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -4,6 +4,7 @@ import _ from 'underscore';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui';
+import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
@@ -22,6 +23,11 @@ export default {
type: Boolean,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -52,7 +58,9 @@ export default {
(!this.file.highlighted_diff_lines &&
!this.isLoadingCollapsedDiff &&
!this.file.too_large &&
- this.file.text)
+ this.file.text &&
+ !this.file.renamed_file &&
+ !this.file.mode_changed)
);
},
showLoadingIcon() {
@@ -73,6 +81,9 @@ export default {
}
},
},
+ created() {
+ eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
+ },
methods: {
...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']),
handleToggle() {
@@ -143,9 +154,8 @@ export default {
<a
:href="file.fork_path"
class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success"
+ >Fork</a
>
- Fork
- </a>
<button
class="js-cancel-fork-suggestion-button btn btn-grouped"
type="button"
@@ -159,13 +169,14 @@ export default {
v-if="!isCollapsed && file.renderIt"
:class="{ hidden: isCollapsed || file.too_large }"
:diff-file="file"
+ :help-page-path="helpPagePath"
/>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
<div v-else-if="showExpandMessage" 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>
+ <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
+ __('Click to expand it.')
+ }}</a>
</div>
<div v-if="file.too_large" class="nothing-here-block diff-collapsed js-too-large-diff">
{{ __('This source diff could not be displayed because it is too large.') }}
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index b969017a2bb..0c0a0faa59d 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -56,9 +56,12 @@ export default {
return `${noteData.author.name}: ${note}`;
},
toggleDiscussions() {
+ const forceExpanded = this.discussions.some(discussion => !discussion.expanded);
+
this.discussions.forEach(discussion => {
this.toggleDiscussion({
discussionId: discussion.id,
+ forceExpanded,
});
});
},
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 c02561b7599..c0613d80d37 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -72,6 +72,13 @@ export default {
diffFiles: state => state.diffs.diffFiles,
}),
...mapGetters(['isLoggedIn']),
+ lineCode() {
+ return (
+ this.line.line_code ||
+ (this.line.left && this.line.line.left.line_code) ||
+ (this.line.right && this.line.right.line_code)
+ );
+ },
lineHref() {
return `#${this.line.line_code || ''}`;
},
@@ -97,9 +104,9 @@ export default {
},
},
methods: {
- ...mapActions('diffs', ['loadMoreLines', 'showCommentForm']),
+ ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
handleCommentButton() {
- this.showCommentForm({ lineCode: this.line.line_code });
+ this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
},
handleLoadMoreLines() {
if (this.isRequesting) {
@@ -160,7 +167,7 @@ export default {
>
<template v-else>
<button
- v-if="shouldShowCommentButton"
+ v-show="shouldShowCommentButton"
type="button"
class="add-diff-note js-add-diff-note-button qa-diff-comment"
title="Add a comment to this line"
@@ -168,7 +175,13 @@ export default {
>
<icon :size="12" name="comment" />
</button>
- <a v-if="lineNumber" :data-linenumber="lineNumber" :href="lineHref"> </a>
+ <a
+ v-if="lineNumber"
+ :data-linenumber="lineNumber"
+ :href="lineHref"
+ @click="setHighlightedRow(lineCode);"
+ >
+ </a>
<diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" />
</template>
</div>
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 c7cef74fe40..e7569ba7b84 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -73,6 +73,7 @@ export default {
this.cancelCommentForm({
lineCode: this.line.line_code,
+ fileHash: this.diffFileHash,
});
this.$nextTick(() => {
this.resetAutoSave();
@@ -93,6 +94,7 @@ export default {
ref="noteForm"
:is-editing="true"
:line-code="line.line_code"
+ :line="line"
save-button-title="Comment"
class="diff-comment-form"
@cancelForm="handleCancelCommentForm"
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index f4eb956adcb..d174b13e133 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -1,5 +1,5 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapGetters, mapActions } from 'vuex';
import DiffLineGutterContent from './diff_line_gutter_content.vue';
import {
MATCH_LINE_TYPE,
@@ -30,6 +30,11 @@ export default {
type: String,
required: true,
},
+ isHighlighted: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
diffViewType: {
type: String,
required: false,
@@ -85,6 +90,7 @@ export default {
const { type } = this.line;
return {
+ hll: this.isHighlighted,
[type]: type,
[LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
[LINE_HOVER_CLASS_NAME]:
@@ -99,6 +105,7 @@ export default {
return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
},
},
+ methods: mapActions('diffs', ['setHighlightedRow']),
};
</script>
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 91b87fb042c..814ee0b7c02 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -1,5 +1,4 @@
<script>
-import { mapState } from 'vuex';
import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue';
@@ -17,29 +16,41 @@ export default {
type: String,
required: true,
},
- lineIndex: {
- type: Number,
- required: true,
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
},
},
computed: {
- ...mapState({
- diffLineCommentForms: state => state.diffs.diffLineCommentForms,
- }),
className() {
return this.line.discussions.length ? '' : 'js-temp-notes-holder';
},
+ shouldRender() {
+ if (this.line.hasForm) return true;
+
+ if (!this.line.discussions || !this.line.discussions.length) {
+ return false;
+ }
+
+ return this.line.discussions.every(discussion => discussion.expanded);
+ },
},
};
</script>
<template>
- <tr :class="className" class="notes_holder">
+ <tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes_content" colspan="3">
<div class="content">
- <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" />
+ <diff-discussions
+ v-if="line.discussions.length"
+ :line="line"
+ :discussions="line.discussions"
+ :help-page-path="helpPagePath"
+ />
<diff-line-note-form
- v-if="diffLineCommentForms[line.line_code]"
+ v-if="line.hasForm"
:diff-file-hash="diffFileHash"
:line="line"
:note-target-line="line"
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 8d53fbded73..c764cbeb8e0 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -1,5 +1,5 @@
<script>
-import { mapGetters, mapActions } from 'vuex';
+import { mapGetters, mapActions, mapState } from 'vuex';
import DiffTableCell from './diff_table_cell.vue';
import {
NEW_LINE_TYPE,
@@ -40,6 +40,11 @@ export default {
};
},
computed: {
+ ...mapState({
+ isHighlighted(state) {
+ return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
+ },
+ }),
...mapGetters('diffs', ['isInlineView']),
isContextLine() {
return this.line.type === CONTEXT_LINE_TYPE;
@@ -91,6 +96,7 @@ export default {
:is-bottom="isBottom"
:is-hover="isHover"
:show-comment-button="true"
+ :is-highlighted="isHighlighted"
class="diff-line-num old_line"
/>
<diff-table-cell
@@ -100,8 +106,18 @@ export default {
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isHover"
+ :is-highlighted="isHighlighted"
class="diff-line-num new_line qa-new-diff-line"
/>
- <td :class="line.type" class="line_content" v-html="line.rich_text"></td>
+ <td
+ :class="[
+ line.type,
+ {
+ hll: isHighlighted,
+ },
+ ]"
+ class="line_content"
+ v-html="line.rich_text"
+ ></td>
</tr>
</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index fafc1649ce7..e781397214d 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -1,5 +1,5 @@
<script>
-import { mapGetters, mapState } from 'vuex';
+import { mapGetters } from 'vuex';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
@@ -17,25 +17,25 @@ export default {
type: Array,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
- ...mapGetters('diffs', ['commitId', 'shouldRenderInlineCommentRow']),
- ...mapState({
- diffLineCommentForms: state => state.diffs.diffLineCommentForms,
- }),
+ ...mapGetters('diffs', ['commitId']),
diffLinesLength() {
return this.diffLines.length;
},
- userColorScheme() {
- return window.gon.user_color_scheme;
- },
},
+ userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
<table
- :class="userColorScheme"
+ :class="$options.userColorScheme"
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"
>
@@ -49,11 +49,10 @@ export default {
:is-bottom="index + 1 === diffLinesLength"
/>
<inline-diff-comment-row
- v-if="shouldRenderInlineCommentRow(line)"
- :key="index"
+ :key="`icr-${line.line_code || index}`"
:diff-file-hash="diffFile.file_hash"
:line="line"
- :line-index="index"
+ :help-page-path="helpPagePath"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue
index 25ec157ed25..47e9627a957 100644
--- a/app/assets/javascripts/diffs/components/no_changes.vue
+++ b/app/assets/javascripts/diffs/components/no_changes.vue
@@ -1,34 +1,51 @@
<script>
-import { mapState } from 'vuex';
-import emptyImage from '~/../../views/shared/icons/_mr_widget_empty_state.svg';
+import { mapGetters } from 'vuex';
+import _ from 'underscore';
+import { GlButton } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
export default {
- data() {
- return {
- emptyImage,
- };
+ components: {
+ GlButton,
+ },
+ props: {
+ changesEmptyStateIllustration: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState({
- sourceBranch: state => state.notes.noteableData.source_branch,
- targetBranch: state => state.notes.noteableData.target_branch,
- newBlobPath: state => state.notes.noteableData.new_blob_path,
- }),
+ ...mapGetters(['getNoteableData']),
+ emptyStateText() {
+ return sprintf(
+ __(
+ 'No changes between %{ref_start}%{source_branch}%{ref_end} and %{ref_start}%{target_branch}%{ref_end}',
+ ),
+ {
+ ref_start: '<span class="ref-name">',
+ ref_end: '</span>',
+ source_branch: _.escape(this.getNoteableData.source_branch),
+ target_branch: _.escape(this.getNoteableData.target_branch),
+ },
+ false,
+ );
+ },
},
};
</script>
<template>
- <div class="row empty-state nothing-here-block">
- <div class="col-xs-12">
- <div class="svg-content"><span v-html="emptyImage"></span></div>
+ <div class="row empty-state">
+ <div class="col-12">
+ <div class="svg-content svg-250"><img :src="changesEmptyStateIllustration" /></div>
</div>
- <div class="col-xs-12">
+ <div class="col-12">
<div class="text-content text-center">
- No changes between <span class="ref-name">{{ sourceBranch }}</span> and
- <span class="ref-name">{{ targetBranch }}</span>
+ <span v-html="emptyStateText"></span>
<div class="text-center">
- <a :href="newBlobPath" class="btn btn-success"> {{ __('Create commit') }} </a>
+ <gl-button :href="getNoteableData.new_blob_path" variant="success">{{
+ __('Create commit')
+ }}</gl-button>
</div>
</div>
</div>
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 c6b50983277..a65cf025cde 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -1,5 +1,4 @@
<script>
-import { mapState } from 'vuex';
import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue';
@@ -21,24 +20,20 @@ export default {
type: Number,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
- ...mapState({
- diffLineCommentForms: state => state.diffs.diffLineCommentForms,
- }),
- leftLineCode() {
- return this.line.left && this.line.left.line_code;
- },
- rightLineCode() {
- return this.line.right && this.line.right.line_code;
- },
hasExpandedDiscussionOnLeft() {
- return this.line.left && this.line.left.discussions
+ return this.line.left && this.line.left.discussions.length
? this.line.left.discussions.every(discussion => discussion.expanded)
: false;
},
hasExpandedDiscussionOnRight() {
- return this.line.right && this.line.right.discussions
+ return this.line.right && this.line.right.discussions.length
? this.line.right.discussions.every(discussion => discussion.expanded)
: false;
},
@@ -57,9 +52,10 @@ export default {
);
},
showRightSideCommentForm() {
- return (
- this.line.right && this.line.right.type && this.diffLineCommentForms[this.rightLineCode]
- );
+ return this.line.right && this.line.right.type && this.line.right.hasForm;
+ },
+ showLeftSideCommentForm() {
+ return this.line.left && this.line.left.hasForm;
},
className() {
return (this.left && this.line.left.discussions.length > 0) ||
@@ -67,21 +63,41 @@ export default {
? ''
: 'js-temp-notes-holder';
},
+ shouldRender() {
+ const { line } = this;
+ const hasDiscussion =
+ (line.left && line.left.discussions && line.left.discussions.length) ||
+ (line.right && line.right.discussions && line.right.discussions.length);
+
+ if (
+ hasDiscussion &&
+ (this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight)
+ ) {
+ return true;
+ }
+
+ const hasCommentFormOnLeft = line.left && line.left.hasForm;
+ const hasCommentFormOnRight = line.right && line.right.hasForm;
+
+ return hasCommentFormOnLeft || hasCommentFormOnRight;
+ },
},
};
</script>
<template>
- <tr :class="className" class="notes_holder">
+ <tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes_content parallel old" colspan="2">
<div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions
v-if="line.left.discussions.length"
:discussions="line.left.discussions"
+ :line="line.left"
+ :help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
- v-if="diffLineCommentForms[leftLineCode]"
+ v-if="showLeftSideCommentForm"
:diff-file-hash="diffFileHash"
:line="line.left"
:note-target-line="line.left"
@@ -93,6 +109,8 @@ export default {
<diff-discussions
v-if="line.right.discussions.length"
:discussions="line.right.discussions"
+ :line="line.right"
+ :help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
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 248dfd9815e..caf0df8a4e3 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import $ from 'jquery';
import DiffTableCell from './diff_table_cell.vue';
import {
@@ -43,6 +43,15 @@ export default {
};
},
computed: {
+ ...mapState({
+ isHighlighted(state) {
+ const lineCode =
+ (this.line.left && this.line.left.line_code) ||
+ (this.line.right && this.line.right.line_code);
+
+ return lineCode ? lineCode === state.diffs.highlightedRow : false;
+ },
+ }),
isContextLine() {
return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE;
},
@@ -57,7 +66,14 @@ export default {
return OLD_NO_NEW_LINE_TYPE;
}
- return this.line.left ? this.line.left.type : EMPTY_CELL_TYPE;
+ const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE;
+
+ return [
+ lineTypeClass,
+ {
+ hll: this.isHighlighted,
+ },
+ ];
},
},
created() {
@@ -114,6 +130,7 @@ export default {
:line-type="oldLineType"
:is-bottom="isBottom"
:is-hover="isLeftHover"
+ :is-highlighted="isHighlighted"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
line-position="left"
@@ -139,6 +156,7 @@ export default {
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isRightHover"
+ :is-highlighted="isHighlighted"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
line-position="right"
@@ -146,7 +164,12 @@ export default {
/>
<td
:id="line.right.line_code"
- :class="line.right.type"
+ :class="[
+ line.right.type,
+ {
+ hll: isHighlighted,
+ },
+ ]"
class="line_content parallel right-side"
@mousedown.native="handleParallelLineMouseDown"
v-html="line.right.rich_text"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index 771b8a80352..1bf693380db 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState, mapGetters } from 'vuex';
+import { mapGetters } from 'vuex';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
@@ -17,25 +17,25 @@ export default {
type: Array,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
- ...mapGetters('diffs', ['commitId', 'shouldRenderParallelCommentRow']),
- ...mapState({
- diffLineCommentForms: state => state.diffs.diffLineCommentForms,
- }),
+ ...mapGetters('diffs', ['commitId']),
diffLinesLength() {
return this.diffLines.length;
},
- userColorScheme() {
- return window.gon.user_color_scheme;
- },
},
+ userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
<div
- :class="userColorScheme"
+ :class="$options.userColorScheme"
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file"
>
@@ -43,18 +43,18 @@ export default {
<tbody>
<template v-for="(line, index) in diffLines">
<parallel-diff-table-row
- :key="index"
+ :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
- v-if="shouldRenderParallelCommentRow(line)"
- :key="`dcr-${index}`"
+ :key="`dcr-${line.line_code || index}`"
:line="line"
:diff-file-hash="diffFile.file_hash"
:line-index="index"
+ :help-page-path="helpPagePath"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 06ef4207d85..b130cedc24c 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -16,7 +16,9 @@ export default function initDiffsApp(store) {
return {
endpoint: dataset.endpoint,
projectPath: dataset.projectPath,
+ helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
+ changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
};
},
computed: {
@@ -30,7 +32,9 @@ export default function initDiffsApp(store) {
endpoint: this.endpoint,
currentUser: this.currentUser,
projectPath: this.projectPath,
+ helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
+ changesEmptyStateIllustration: this.changesEmptyStateIllustration,
},
});
},
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index a3de058b20e..00a4bb6d3a3 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -3,8 +3,9 @@ import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import createFlash from '~/flash';
import { s__ } from '~/locale';
-import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils';
+import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
+import eventHub from '../../notes/event_hub';
import { getDiffPositionByLineCode, getNoteFormData } from './utils';
import * as types from './mutation_types';
import {
@@ -33,6 +34,10 @@ export const fetchDiffFiles = ({ state, commit }) => {
.then(handleLocationHash);
};
+export const setHighlightedRow = ({ commit }, lineCode) => {
+ commit(types.SET_HIGHLIGHTED_ROW, lineCode);
+};
+
// This is adding line discussions to the actual lines in the diff tree
// once for parallel and once for inline mode
export const assignDiscussionsToDiff = (
@@ -49,6 +54,10 @@ export const assignDiscussionsToDiff = (
diffPositionByLineCode,
});
});
+
+ Vue.nextTick(() => {
+ eventHub.$emit('scrollToDiscussion');
+ });
};
export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
@@ -56,6 +65,27 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
+export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
+ const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
+
+ if (discussion) {
+ const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash);
+
+ if (file) {
+ if (!file.renderIt) {
+ commit(types.RENDER_FILE, file);
+ }
+
+ if (file.collapsed) {
+ eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
+ scrollToElement(document.getElementById(file.file_hash));
+ } else {
+ eventHub.$emit('scrollToDiscussion');
+ }
+ }
+ }
+};
+
export const startRenderDiffsQueue = ({ state, commit }) => {
const checkItem = () =>
new Promise(resolve => {
@@ -99,12 +129,12 @@ export const setParallelDiffViewType = ({ commit }) => {
historyPushState(url);
};
-export const showCommentForm = ({ commit }, params) => {
- commit(types.ADD_COMMENT_FORM_LINE, params);
+export const showCommentForm = ({ commit }, { lineCode, fileHash }) => {
+ commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: true });
};
-export const cancelCommentForm = ({ commit }, params) => {
- commit(types.REMOVE_COMMENT_FORM_LINE, params);
+export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => {
+ commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: false });
};
export const loadMoreLines = ({ commit }, options) => {
@@ -127,7 +157,7 @@ export const loadMoreLines = ({ commit }, options) => {
export const scrollToLineIfNeededInline = (_, line) => {
const hash = getLocationHash();
- if (hash && line.lineCode === hash) {
+ if (hash && line.line_code === hash) {
handleLocationHash();
}
};
@@ -137,19 +167,25 @@ export const scrollToLineIfNeededParallel = (_, line) => {
if (
hash &&
- ((line.left && line.left.lineCode === hash) || (line.right && line.right.lineCode === hash))
+ ((line.left && line.left.line_code === hash) || (line.right && line.right.line_code === hash))
) {
handleLocationHash();
}
};
-export const loadCollapsedDiff = ({ commit }, file) =>
- axios.get(file.loadCollapsedDiffUrl).then(res => {
- commit(types.ADD_COLLAPSED_DIFFS, {
- file,
- data: res.data,
+export const loadCollapsedDiff = ({ commit, getters }, file) =>
+ axios
+ .get(file.load_collapsed_diff_url, {
+ params: {
+ commit_id: getters.commitId,
+ },
+ })
+ .then(res => {
+ commit(types.ADD_COLLAPSED_DIFFS, {
+ file,
+ data: res.data,
+ });
});
- });
export const expandAllFiles = ({ commit }) => {
commit(types.EXPAND_ALL_FILES);
@@ -182,8 +218,9 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
});
};
-export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => {
+export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({
+ commit: state.commit,
note,
...formData,
});
@@ -191,8 +228,8 @@ export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => {
return dispatch('saveNote', postData, { root: true })
.then(result => dispatch('updateDiscussion', result.discussion, { root: true }))
.then(discussion => dispatch('assignDiscussionsToDiff', [discussion]))
+ .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true }))
.then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash))
- .then(() => dispatch('startTaskList', null, { root: true }))
.catch(() => createFlash(s__('MergeRequests|Saving the comment failed')));
};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 6a87b712b48..fdf1efbb10e 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -70,40 +70,6 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
discussion => discussion.diff_discussion && discussion.diff_file.file_hash === diff.file_hash,
) || [];
-export const shouldRenderParallelCommentRow = state => line => {
- const hasDiscussion =
- (line.left && line.left.discussions && line.left.discussions.length) ||
- (line.right && line.right.discussions && line.right.discussions.length);
-
- const hasExpandedDiscussionOnLeft =
- line.left && line.left.discussions && line.left.discussions.length
- ? line.left.discussions.every(discussion => discussion.expanded)
- : false;
- const hasExpandedDiscussionOnRight =
- line.right && line.right.discussions && line.right.discussions.length
- ? line.right.discussions.every(discussion => discussion.expanded)
- : false;
-
- if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) {
- return true;
- }
-
- const hasCommentFormOnLeft = line.left && state.diffLineCommentForms[line.left.line_code];
- const hasCommentFormOnRight = line.right && state.diffLineCommentForms[line.right.line_code];
-
- return hasCommentFormOnLeft || hasCommentFormOnRight;
-};
-
-export const shouldRenderInlineCommentRow = state => line => {
- if (state.diffLineCommentForms[line.line_code]) return true;
-
- if (!line.discussions || line.discussions.length === 0) {
- return false;
- }
-
- return line.discussions.every(discussion => discussion.expanded);
-};
-
// prevent babel-plugin-rewire from generating an invalid default during karma∂ tests
export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.file_hash === fileHash);
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 2f59a3822f4..98e57d52d77 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -18,7 +18,6 @@ export default () => ({
diffFiles: [],
mergeRequestDiffs: [],
mergeRequestDiff: null,
- diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
tree: [],
treeEntries: {},
@@ -27,4 +26,5 @@ export default () => ({
currentDiffFileId: '',
projectPath: '',
commentForms: [],
+ highlightedRow: null,
});
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index e011031e72c..0338cde3658 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -3,8 +3,7 @@ export const SET_LOADING = 'SET_LOADING';
export const SET_DIFF_DATA = 'SET_DIFF_DATA';
export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS';
-export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE';
-export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE';
+export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM';
export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES';
export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS';
export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES';
@@ -18,3 +17,4 @@ export const UPDATE_CURRENT_DIFF_FILE_ID = 'UPDATE_CURRENT_DIFF_FILE_ID';
export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM';
export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM';
export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM';
+export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 2133cfe4825..ed4203cf5e0 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -1,4 +1,3 @@
-import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { sortTree } from '~/ide/stores/utils';
import {
@@ -49,12 +48,30 @@ export default {
Object.assign(state, { diffViewType });
},
- [types.ADD_COMMENT_FORM_LINE](state, { lineCode }) {
- Vue.set(state.diffLineCommentForms, lineCode, true);
- },
+ [types.TOGGLE_LINE_HAS_FORM](state, { lineCode, fileHash, hasForm }) {
+ const diffFile = state.diffFiles.find(f => f.file_hash === fileHash);
+
+ if (!diffFile) return;
+
+ if (diffFile.highlighted_diff_lines) {
+ diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm;
+ }
+
+ if (diffFile.parallel_diff_lines) {
+ const line = diffFile.parallel_diff_lines.find(l => {
+ const { left, right } = l;
- [types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }) {
- Vue.delete(state.diffLineCommentForms, lineCode);
+ return (left && left.line_code === lineCode) || (right && right.line_code === lineCode);
+ });
+
+ if (line.left && line.left.line_code === lineCode) {
+ line.left.hasForm = hasForm;
+ }
+
+ if (line.right && line.right.line_code === lineCode) {
+ line.right.hasForm = hasForm;
+ }
+ }
},
[types.ADD_CONTEXT_LINES](state, options) {
@@ -68,6 +85,7 @@ export default {
...line,
line_code: line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`,
discussions: line.discussions || [],
+ hasForm: false,
}));
addContextLines({
@@ -105,22 +123,23 @@ export default {
diffPosition: diffPositionByLineCode[line.line_code],
latestDiff,
});
+ const mapDiscussions = (line, extraCheck = () => true) => ({
+ ...line,
+ discussions: extraCheck()
+ ? line.discussions
+ .filter(() => !line.discussions.some(({ id }) => discussion.id === id))
+ .concat(lineCheck(line) ? discussion : line.discussions)
+ : [],
+ });
state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) {
const file = { ...diffFile };
if (file.highlighted_diff_lines) {
- file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => {
- if (lineCheck(line)) {
- return {
- ...line,
- discussions: line.discussions.concat(discussion),
- };
- }
-
- return line;
- });
+ file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
+ lineCheck(line) ? mapDiscussions(line) : line,
+ );
}
if (file.parallel_diff_lines) {
@@ -130,14 +149,8 @@ export default {
if (left || right) {
return {
- left: {
- ...line.left,
- discussions: left ? line.left.discussions.concat(discussion) : [],
- },
- right: {
- ...line.right,
- discussions: right && !left ? line.right.discussions.concat(discussion) : [],
- },
+ left: line.left ? mapDiscussions(line.left) : null,
+ right: line.right ? mapDiscussions(line.right, () => !left) : null,
};
}
@@ -146,7 +159,7 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
- file.discussions = file.discussions.concat(discussion);
+ file.discussions = (file.discussions || []).concat(discussion);
}
return file;
@@ -156,7 +169,7 @@ export default {
});
},
- [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode, id }) {
+ [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) {
if (selectedFile.parallel_diff_lines) {
@@ -169,7 +182,7 @@ export default {
const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
Object.assign(targetLine[side], {
- discussions: [],
+ discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
});
}
}
@@ -181,14 +194,14 @@ export default {
if (targetInlineLine) {
Object.assign(targetInlineLine, {
- discussions: [],
+ discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
});
}
}
if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter(
- discussion => discussion.id !== id,
+ discussion => discussion.notes.length,
);
}
}
@@ -223,4 +236,7 @@ export default {
[types.CLOSE_DIFF_FILE_COMMENT_FORM](state, fileHash) {
state.commentForms = state.commentForms.filter(form => form.fileHash !== fileHash);
},
+ [types.SET_HIGHLIGHTED_ROW](state, lineCode) {
+ state.highlightedRow = lineCode;
+ },
};
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index d9d3c0f2ca2..2fe20551642 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -27,6 +27,7 @@ export const getReversePosition = linePosition => {
export function getFormData(params) {
const {
+ commit,
note,
noteableType,
noteableData,
@@ -66,7 +67,7 @@ export function getFormData(params) {
position,
noteable_type: noteableType,
noteable_id: noteableData.id,
- commit_id: '',
+ commit_id: commit && commit.id,
type:
diffFile.diff_refs.start_sha && diffFile.diff_refs.head_sha
? DIFF_NOTE_TYPE
@@ -195,6 +196,15 @@ export function trimFirstCharOfLineContent(line = {}) {
return parsedLine;
}
+function getLineCode({ left, right }, index) {
+ if (left && left.line_code) {
+ return left.line_code;
+ } else if (right && right.line_code) {
+ return right.line_code;
+ }
+ return index;
+}
+
// This prepares and optimizes the incoming diff data from the server
// by setting up incremental rendering and removing unneeded data
export function prepareDiffData(diffData) {
@@ -207,11 +217,15 @@ export function prepareDiffData(diffData) {
const linesLength = file.parallel_diff_lines.length;
for (let u = 0; u < linesLength; u += 1) {
const line = file.parallel_diff_lines[u];
+
+ line.line_code = getLineCode(line, u);
if (line.left) {
line.left = trimFirstCharOfLineContent(line.left);
+ line.left.hasForm = false;
}
if (line.right) {
line.right = trimFirstCharOfLineContent(line.right);
+ line.right.hasForm = false;
}
}
}
@@ -220,7 +234,7 @@ export function prepareDiffData(diffData) {
const linesLength = file.highlighted_diff_lines.length;
for (let u = 0; u < linesLength; u += 1) {
const line = file.highlighted_diff_lines[u];
- Object.assign(line, { ...trimFirstCharOfLineContent(line) });
+ Object.assign(line, { ...trimFirstCharOfLineContent(line), hasForm: false });
}
showingLines += file.parallel_diff_lines.length;
}
@@ -322,5 +336,9 @@ export const generateTreeList = files =>
export const getDiffMode = diffFile => {
const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]);
- return diffModes[diffModeKey] || diffModes.replaced;
+ return (
+ diffModes[diffModeKey] ||
+ (diffFile.mode_changed && diffModes.mode_changed) ||
+ diffModes.replaced
+ );
};
diff --git a/app/assets/javascripts/dismissable_callout.js b/app/assets/javascripts/dismissable_callout.js
new file mode 100644
index 00000000000..5185b019376
--- /dev/null
+++ b/app/assets/javascripts/dismissable_callout.js
@@ -0,0 +1,27 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import Flash from '~/flash';
+
+export default function initDismissableCallout(alertSelector) {
+ const alertEl = document.querySelector(alertSelector);
+ if (!alertEl) {
+ return;
+ }
+
+ const closeButtonEl = alertEl.getElementsByClassName('close')[0];
+ const { dismissEndpoint, featureId } = closeButtonEl.dataset;
+
+ closeButtonEl.addEventListener('click', () => {
+ axios
+ .post(dismissEndpoint, {
+ feature_name: featureId,
+ })
+ .then(() => {
+ $(alertEl).alert('close');
+ })
+ .catch(() => {
+ Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.'));
+ });
+ });
+}
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index cd2f46fd07a..f44806d82a6 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -14,6 +14,7 @@ import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { CLUSTER_TYPE } from '~/clusters/constants';
/**
* Environment Item Component
@@ -85,6 +86,15 @@ export default {
},
/**
+ * Hide group cluster features which are not currently implemented.
+ *
+ * @returns {Boolean}
+ */
+ disableGroupClusterFeatures() {
+ return this.model && this.model.cluster_type === CLUSTER_TYPE.GROUP;
+ },
+
+ /**
* Returns whether the environment can be stopped.
*
* @returns {Boolean}
@@ -547,6 +557,7 @@ export default {
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
+ :disabled="disableGroupClusterFeatures"
/>
<rollback-component
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 83727caad16..6d74d136a94 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -19,6 +19,11 @@ export default {
required: false,
default: '',
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
title() {
@@ -33,6 +38,7 @@ export default {
:title="title"
:aria-label="title"
:href="terminalPath"
+ :class="{ disabled: disabled }"
class="btn terminal-button d-none d-sm-none d-md-block"
>
<icon name="terminal" />
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 5164d87c5fa..533e90e2222 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -70,7 +70,7 @@ export default {
<template v-if="shouldRenderFolderContent(model)">
<div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`">
- <gl-loading-icon :size="2" />
+ <gl-loading-icon :size="2" class="prepend-top-16" />
</div>
<template v-else>
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index 1b79a3320c6..8d92af2cf7e 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -54,67 +54,6 @@ export default class DropdownUtils {
return updatedItem;
}
- static mergeDuplicateLabels(dataMap, newLabel) {
- const updatedMap = dataMap;
- const key = newLabel.title;
-
- const hasKeyProperty = Object.prototype.hasOwnProperty.call(updatedMap, key);
-
- if (!hasKeyProperty) {
- updatedMap[key] = newLabel;
- } else {
- const existing = updatedMap[key];
-
- if (!existing.multipleColors) {
- existing.multipleColors = [existing.color];
- }
-
- existing.multipleColors.push(newLabel.color);
- }
-
- return updatedMap;
- }
-
- static duplicateLabelColor(labelColors) {
- const colors = labelColors;
- const spacing = 100 / colors.length;
-
- // Reduce the colors to 4
- colors.length = Math.min(colors.length, 4);
-
- const color = colors
- .map((c, i) => {
- const percentFirst = Math.floor(spacing * i);
- const percentSecond = Math.floor(spacing * (i + 1));
- return `${c} ${percentFirst}%, ${c} ${percentSecond}%`;
- })
- .join(', ');
-
- return `linear-gradient(${color})`;
- }
-
- static duplicateLabelPreprocessing(data) {
- const results = [];
- const dataMap = {};
-
- data.forEach(DropdownUtils.mergeDuplicateLabels.bind(null, dataMap));
-
- Object.keys(dataMap).forEach(key => {
- const label = dataMap[key];
-
- if (label.multipleColors) {
- label.color = DropdownUtils.duplicateLabelColor(label.multipleColors);
- label.text_color = '#000000';
- }
-
- results.push(label);
- });
-
- results.preprocessed = true;
-
- return results;
- }
-
static filterHint(config, item) {
const { input, allowedKeys } = config;
const updatedItem = item;
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 89dcff74d0e..fba31f16d65 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -79,11 +79,7 @@ export default class FilteredSearchVisualTokens {
static setTokenStyle(tokenContainer, backgroundColor, textColor) {
const token = tokenContainer;
- // Labels with linear gradient should not override default background color
- if (backgroundColor.indexOf('linear-gradient') === -1) {
- token.style.backgroundColor = backgroundColor;
- }
-
+ token.style.backgroundColor = backgroundColor;
token.style.color = textColor;
if (textColor === '#FFFFFF') {
@@ -94,18 +90,6 @@ export default class FilteredSearchVisualTokens {
return token;
}
- static preprocessLabel(labelsEndpoint, labels) {
- let processed = labels;
-
- if (!labels.preprocessed) {
- processed = DropdownUtils.duplicateLabelPreprocessing(labels);
- AjaxCache.override(labelsEndpoint, processed);
- processed.preprocessed = true;
- }
-
- return processed;
- }
-
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const { baseEndpoint } = filteredSearchInput.dataset;
@@ -115,7 +99,6 @@ export default class FilteredSearchVisualTokens {
);
return AjaxCache.retrieve(labelsEndpoint)
- .then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
.then(labels => {
const matchingLabel = (labels || []).find(
label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
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 bb0ecb8efe7..b494b7e2de0 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
@@ -88,11 +88,16 @@ export const conditions = [
value: 'started',
},
{
- url: 'label_name[]=No+Label',
+ url: 'label_name[]=None',
tokenKey: 'label',
value: 'none',
},
{
+ url: 'label_name[]=Any',
+ tokenKey: 'any',
+ value: 'any',
+ },
+ {
url: 'my_reaction_emoji=None',
tokenKey: 'my-reaction',
value: 'none',
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index b4a3037c1b7..2049760fe29 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -10,13 +10,18 @@ export default function groupsSelect() {
const $select = $(this);
const allAvailable = $select.data('allAvailable');
const skipGroups = $select.data('skipGroups') || [];
+ const parentGroupID = $select.data('parentId');
+ const groupsPath = parentGroupID
+ ? Api.subgroupsPath.replace(':id', parentGroupID)
+ : Api.groupsPath;
+
$select.select2({
placeholder: 'Search for a group',
allowClear: $select.hasClass('allowClear'),
multiple: $select.hasClass('multiselect'),
minimumInputLength: 0,
ajax: {
- url: Api.buildUrl(Api.groupsPath),
+ url: Api.buildUrl(groupsPath),
dataType: 'json',
quietMillis: 250,
transport(params) {
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index e318367a5ec..7a57ccf2dd3 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -105,7 +105,7 @@ export default {
:key="tabView.name"
class="h-100"
>
- <component :is="tabView.name" />
+ <component :is="tabView.component || tabView.name" />
</div>
</resizable-panel>
<nav class="ide-activity-bar">
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 3b201f006aa..09245ed0296 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -26,6 +26,7 @@ export const diffModes = {
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
+ mode_changed: 'mode_changed',
};
export const rightSidebarViews = {
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index fbf944499d5..6351948f750 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import { mapActions } from 'vuex';
+import _ from 'underscore';
import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue';
import store from './stores';
@@ -13,19 +14,19 @@ Vue.use(Translate);
*
* @param {Element} el - The element that will contain the IDE.
* @param {Object} options - Extra options for the IDE (Used by EE).
- * @param {(e:Element) => Object} options.extraInitialData -
- * Function that returns extra properties to seed initial data.
* @param {Component} options.rootComponent -
* Component that overrides the root component.
+ * @param {(store:Vuex.Store, el:Element) => Vuex.Store} options.extendStore -
+ * Function that receives the default store and returns an extended one.
*/
export function initIde(el, options = {}) {
if (!el) return null;
- const { extraInitialData = () => ({}), rootComponent = ide } = options;
+ const { rootComponent = ide, extendStore = _.identity } = options;
return new Vue({
el,
- store,
+ store: extendStore(store, el),
router,
created() {
this.setEmptyStateSvgs({
@@ -41,7 +42,6 @@ export function initIde(el, options = {}) {
});
this.setInitialData({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
- ...extraInitialData(el),
});
},
methods: {
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index f0193d8e8ea..13449592e62 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -41,13 +41,13 @@ export default {
return Api.project(`${namespace}/${project}`);
},
getProjectMergeRequestData(projectId, mergeRequestId, params = {}) {
- return Api.mergeRequest(projectId, mergeRequestId, params);
+ return Api.projectMergeRequest(projectId, mergeRequestId, params);
},
getProjectMergeRequestChanges(projectId, mergeRequestId) {
- return Api.mergeRequestChanges(projectId, mergeRequestId);
+ return Api.projectMergeRequestChanges(projectId, mergeRequestId);
},
getProjectMergeRequestVersions(projectId, mergeRequestId) {
- return Api.mergeRequestVersions(projectId, mergeRequestId);
+ return Api.projectMergeRequestVersions(projectId, mergeRequestId);
},
getBranchData(projectId, currentBranchId) {
return Api.branchSingle(projectId, currentBranchId);
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
index 4565c11a83f..8b5f7558654 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
@@ -23,13 +23,19 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }
export const receiveMergeRequestsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data);
-export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => {
+export const fetchMergeRequests = (
+ { dispatch, state: { state }, rootState: { currentProjectId } },
+ { type, search = '' },
+) => {
dispatch('requestMergeRequests');
dispatch('resetMergeRequests');
- const scope = type ? scopes[type] : 'all';
+ const scope = type && scopes[type];
+ const request = scope
+ ? Api.mergeRequests({ scope, state, search })
+ : Api.projectMergeRequest(currentProjectId, '', { state, search });
- return Api.mergeRequests({ scope, state, search })
+ return request
.then(({ data }) => dispatch('receiveMergeRequestsSuccess', data))
.catch(() => dispatch('receiveMergeRequestsError', { type, search }));
};
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index eddaeda9578..000157efad0 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -12,7 +12,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
}
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
- const buttonEl = createImageBadge(noteId, coordinate, ['badge']);
+ const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']);
buttonEl.innerText = badgeText;
containerEl.appendChild(buttonEl);
diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue
index eea0701312b..575c860851c 100644
--- a/app/assets/javascripts/issuable_suggestions/components/app.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/app.vue
@@ -27,7 +27,7 @@ export default {
apollo: {
issues: {
query,
- debounce: 250,
+ debounce: 1000,
skip() {
return this.isSearchEmpty;
},
diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue
index 309b7427b9e..0bce860df91 100644
--- a/app/assets/javascripts/jobs/components/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/artifacts_block.vue
@@ -28,27 +28,29 @@ export default {
</script>
<template>
<div class="block">
- <div class="title">{{ s__('Job|Job artifacts') }}</div>
+ <div class="title font-weight-bold">{{ s__('Job|Job artifacts') }}</div>
- <p v-if="isExpired" class="js-artifacts-removed build-detail-row">
- {{ s__('Job|The artifacts were removed') }}
+ <p
+ v-if="isExpired || willExpire"
+ :class="{
+ 'js-artifacts-removed': isExpired,
+ 'js-artifacts-will-be-removed': willExpire,
+ }"
+ class="build-detail-row"
+ >
+ <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span>
+ <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span>
+ <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
</p>
- <p v-else-if="willExpire" class="js-artifacts-will-be-removed build-detail-row">
- {{ s__('Job|The artifacts will be removed in') }}
- </p>
-
- <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
-
- <div class="btn-group d-flex" role="group">
+ <div class="btn-group d-flex prepend-top-10" role="group">
<gl-link
v-if="artifact.keep_path"
:href="artifact.keep_path"
class="js-keep-artifacts btn btn-sm btn-default"
data-method="post"
+ >{{ s__('Job|Keep') }}</gl-link
>
- {{ s__('Job|Keep') }}
- </gl-link>
<gl-link
v-if="artifact.download_path"
@@ -56,17 +58,15 @@ export default {
class="js-download-artifacts btn btn-sm btn-default"
download
rel="nofollow"
+ >{{ s__('Job|Download') }}</gl-link
>
- {{ s__('Job|Download') }}
- </gl-link>
<gl-link
v-if="artifact.browse_path"
:href="artifact.browse_path"
class="js-browse-artifacts btn btn-sm btn-default"
+ >{{ s__('Job|Browse') }}</gl-link
>
- {{ s__('Job|Browse') }}
- </gl-link>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 3b9c61bd48c..e0f55518eef 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -31,12 +31,12 @@ export default {
block: !isLastBlock,
}"
>
- <p>
- {{ __('Commit') }}
+ <p class="append-bottom-5">
+ <span class="font-weight-bold">{{ __('Commit') }}</span>
- <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">{{
- commit.short_id
- }}</gl-link>
+ <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ {{ commit.short_id }}
+ </gl-link>
<clipboard-button
:text="commit.short_id"
@@ -44,11 +44,14 @@ export default {
css-class="btn btn-clipboard btn-transparent"
/>
- <gl-link v-if="mergeRequest" :href="mergeRequest.path" class="js-link-commit link-commit"
- >!{{ mergeRequest.iid }}</gl-link
- >
+ <span v-if="mergeRequest">
+ {{ __('in') }}
+ <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
+ >!{{ mergeRequest.iid }}</gl-link
+ >
+ </span>
</p>
- <p class="build-light-text append-bottom-0">{{ commit.title }}</p>
+ <p class="append-bottom-0">{{ commit.title }}</p>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index 2d09cf5760f..f7fbb9503a0 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -128,7 +128,7 @@ export default {
};
</script>
<template>
- <div class="prepend-top-default js-environment-container">
+ <div class="prepend-top-default append-bottom-default js-environment-container">
<div class="environment-information">
<ci-icon :status="iconStatus" />
<p class="inline append-bottom-0" v-html="environment"></p>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 934ecd0e3ec..ad3e7dabc79 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -110,22 +110,20 @@ export default {
<aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix">
<div class="sidebar-container">
<div class="blocks-container">
- <div class="block">
- <strong class="inline prepend-top-8"> {{ job.name }} </strong>
+ <div class="block d-flex align-items-center">
+ <h4 class="flex-grow-1 prepend-top-8 m-0">{{ job.name }}</h4>
<gl-link
v-if="job.retry_path"
:class="retryButtonClass"
:href="job.retry_path"
data-method="post"
rel="nofollow"
+ >{{ __('Retry') }}</gl-link
>
- {{ __('Retry') }}
- </gl-link>
<gl-link
v-if="job.terminal_path"
:href="job.terminal_path"
- class="js-terminal-link pull-right btn btn-primary
- btn-inverted visible-md-block visible-lg-block"
+ class="js-terminal-link pull-right btn btn-primary btn-inverted visible-md-block visible-lg-block"
target="_blank"
>
{{ __('Debug') }} <icon name="external-link" />
@@ -133,8 +131,7 @@ export default {
<gl-button
:aria-label="__('Toggle Sidebar')"
type="button"
- class="btn btn-blank gutter-toggle
- float-right d-block d-md-none js-sidebar-build-toggle"
+ class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle"
@click="toggleSidebar"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i>
@@ -145,25 +142,18 @@ export default {
v-if="job.new_issue_path"
:href="job.new_issue_path"
class="js-new-issue btn btn-success btn-inverted"
+ >{{ __('New issue') }}</gl-link
>
- {{ __('New issue') }}
- </gl-link>
<gl-link
v-if="job.retry_path"
:href="job.retry_path"
class="js-retry-job btn btn-inverted-secondary"
data-method="post"
rel="nofollow"
+ >{{ __('Retry') }}</gl-link
>
- {{ __('Retry') }}
- </gl-link>
</div>
<div :class="{ block: renderBlock }">
- <p v-if="job.merge_request" class="build-detail-row js-job-mr">
- <span class="build-light-text"> {{ __('Merge Request:') }} </span>
- <gl-link :href="job.merge_request.path"> !{{ job.merge_request.iid }} </gl-link>
- </p>
-
<detail-row
v-if="job.duration"
:value="duration"
@@ -198,10 +188,10 @@ export default {
title="Coverage"
/>
<p v-if="job.tags.length" class="build-detail-row js-job-tags">
- <span class="build-light-text"> {{ __('Tags:') }} </span>
- <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary">
- {{ tag }}
- </span>
+ <span class="font-weight-bold">{{ __('Tags:') }}</span>
+ <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{
+ tag
+ }}</span>
</p>
<div v-if="job.cancel_path" class="btn-group prepend-top-5" role="group">
@@ -210,9 +200,8 @@ export default {
class="js-cancel-job btn btn-sm btn-default"
data-method="post"
rel="nofollow"
+ >{{ __('Cancel') }}</gl-link
>
- {{ __('Cancel') }}
- </gl-link>
</div>
</div>
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
index 77be295e802..b826007ec2c 100644
--- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
@@ -34,8 +34,7 @@ export default {
</script>
<template>
<p class="build-detail-row">
- <span v-if="hasTitle" class="build-light-text"> {{ title }}: </span> {{ value }}
-
+ <span v-if="hasTitle" class="font-weight-bold">{{ title }}:</span> {{ value }}
<span v-if="hasHelpURL" class="help-button float-right">
<gl-link :href="helpUrl" target="_blank" rel="noopener noreferrer nofollow">
<i class="fa fa-question-circle" aria-hidden="true"></i>
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 90482500bbf..7f79e92067f 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -38,11 +38,11 @@ export default {
<div class="block-last dropdown">
<ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
- {{ __('Pipeline') }}
- <a :href="pipeline.path" class="js-pipeline-path link-commit"> #{{ pipeline.id }} </a>
+ <span class="font-weight-bold">{{ __('Pipeline') }}</span>
+ <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a>
<template v-if="hasRef">
{{ __('from') }}
- <a :href="pipeline.ref.path" class="link-commit ref-name"> {{ pipeline.ref.name }} </a>
+ <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
</template>
<button
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue
index 7b077d5e621..ec52d272168 100644
--- a/app/assets/javascripts/jobs/components/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/stuck_block.vue
@@ -28,20 +28,22 @@ export default {
<div class="bs-callout bs-callout-warning">
<p v-if="tags.length" class="js-stuck-with-tags append-bottom-0">
{{
- s__(`This job is stuck, because you don't have
+ s__(`This job is stuck because you don't have
any active runners online with any of these tags assigned to them:`)
}}
- <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary"> {{ tag }} </span>
+ <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4">
+ {{ tag }}
+ </span>
</p>
<p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners append-bottom-0">
{{
- s__(`Job|This job is stuck, because the project
+ s__(`Job|This job is stuck because the project
doesn't have any runners online assigned to it.`)
}}
</p>
<p v-else class="js-stuck-no-active-runner append-bottom-0">
{{
- s__(`This job is stuck, because you don't
+ s__(`This job is stuck because you don't
have any active runners that can run this job.`)
}}
</p>
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index 4a9b2903eec..997737b3e23 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -1,6 +1,9 @@
<script>
+import { __ } from '~/locale';
import { GlButton } from '@gitlab/ui';
+const HIDDEN_VALUE = '••••••';
+
export default {
components: {
GlButton,
@@ -13,17 +16,26 @@ export default {
},
data() {
return {
- areVariablesVisible: false,
+ showVariableValues: false,
};
},
computed: {
hasVariables() {
return this.trigger.variables && this.trigger.variables.length > 0;
},
+ getToggleButtonText() {
+ return this.showVariableValues ? __('Hide values') : __('Reveal values');
+ },
+ hasValues() {
+ return this.trigger.variables.some(v => v.value);
+ },
},
methods: {
- revealVariables() {
- this.areVariablesVisible = true;
+ toggleValues() {
+ this.showVariableValues = !this.showVariableValues;
+ },
+ getDisplayValue(value) {
+ return this.showVariableValues ? value : HIDDEN_VALUE;
},
},
};
@@ -31,33 +43,36 @@ export default {
<template>
<div class="build-widget block">
- <h4 class="title">{{ __('Trigger') }}</h4>
-
- <p v-if="trigger.short_token" class="js-short-token">
- <span class="build-light-text"> {{ __('Token') }} </span> {{ trigger.short_token }}
+ <p
+ v-if="trigger.short_token"
+ class="js-short-token"
+ :class="{ 'append-bottom-5': hasVariables, 'append-bottom-0': !hasVariables }"
+ >
+ <span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }}
</p>
- <p v-if="hasVariables">
- <gl-button
- v-if="!areVariablesVisible"
- type="button"
- class="btn btn-default group js-reveal-variables"
- @click="revealVariables"
- >
- {{ __('Reveal Variables') }}
- </gl-button>
- </p>
+ <template v-if="hasVariables">
+ <p class="trigger-variables-btn-container">
+ <span class="font-weight-bold">{{ __('Trigger variables:') }}</span>
- <dl v-if="areVariablesVisible" class="js-build-variables trigger-build-variables">
- <template v-for="variable in trigger.variables">
- <dt :key="`${variable.key}-variable`" class="js-build-variable trigger-build-variable">
- {{ variable.key }}
- </dt>
+ <gl-button
+ v-if="hasValues"
+ class="btn-sm group js-reveal-variables trigger-variables-btn"
+ @click="toggleValues"
+ >{{ getToggleButtonText }}</gl-button
+ >
+ </p>
- <dd :key="`${variable.key}-value`" class="js-build-value trigger-build-value">
- {{ variable.value }}
- </dd>
- </template>
- </dl>
+ <table class="js-build-variables trigger-build-variables">
+ <tr v-for="(variable, index) in trigger.variables" :key="`${variable.key}-${index}`">
+ <td class="js-build-variable trigger-build-variable trigger-variables-table-cell">
+ {{ variable.key }}
+ </td>
+ <td class="js-build-value trigger-build-value trigger-variables-table-cell">
+ {{ getDisplayValue(variable.value) }}
+ </td>
+ </tr>
+ </table>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index c0a76814102..f7a611fbca0 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -7,7 +7,6 @@ import _ from 'underscore';
import { sprintf, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
-import DropdownUtils from './filtered_search/dropdown_utils';
import CreateLabelDropdown from './create_label';
import flash from './flash';
import ModalStore from './boards/stores/modal_store';
@@ -171,23 +170,7 @@ export default class LabelsSelect {
axios
.get(labelUrl)
.then(res => {
- let data = _.chain(res.data)
- .groupBy(function(label) {
- return label.title;
- })
- .map(function(label) {
- var color;
- color = _.map(label, function(dup) {
- return dup.color;
- });
- return {
- id: label[0].id,
- title: label[0].title,
- color: color,
- duplicate: color.length > 1,
- };
- })
- .value();
+ let { data } = res;
if ($dropdown.hasClass('js-extra-options')) {
var extraData = [];
if (showNo) {
@@ -272,15 +255,9 @@ export default class LabelsSelect {
selectedClass.push('dropdown-clear-active');
}
}
- if (label.duplicate) {
- color = DropdownUtils.duplicateLabelColor(label.color);
- } else {
- if (label.color != null) {
- [color] = label.color;
- }
- }
- if (color) {
- colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>";
+ if (label.color) {
+ colorEl =
+ "<span class='dropdown-label-box' style='background: " + label.color + "'></span>";
} else {
colorEl = '';
}
@@ -435,7 +412,7 @@ export default class LabelsSelect {
new ListLabel({
id: label.id,
title: label.title,
- color: label.color[0],
+ color: label.color,
textColor: '#fff',
}),
);
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 040d0bc659e..9e22cdc04e9 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -192,8 +192,12 @@ export const contentTop = () => {
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 diffFileTitleBar =
+ (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0;
- return perfBar + mrTabsHeight + headerHeight + diffFilesChanged;
+ return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar;
};
export const scrollToElement = element => {
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 6f42382246d..7933c234384 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -7,3 +7,8 @@ export const addClassIfElementExists = (element, className) => {
};
export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isInMRPage();
+
+export const canScrollUp = ({ scrollTop }, margin = 0) => scrollTop > margin;
+
+export const canScrollDown = ({ scrollTop, offsetHeight, scrollHeight }, margin = 0) =>
+ scrollTop + offsetHeight < scrollHeight - margin;
diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js
new file mode 100644
index 00000000000..b41ffb44971
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/file_upload.js
@@ -0,0 +1,13 @@
+export default (buttonSelector, fileSelector) => {
+ const btn = document.querySelector(buttonSelector);
+ const fileInput = document.querySelector(fileSelector);
+ const form = btn.closest('form');
+
+ btn.addEventListener('click', () => {
+ fileInput.click();
+ });
+
+ fileInput.addEventListener('change', () => {
+ form.querySelector('.js-filename').textContent = fileInput.value.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index e4852c85378..14c02218990 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -16,7 +16,9 @@ const httpStatusCodes = {
IM_USED: 226,
MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400,
+ FORBIDDEN: 403,
NOT_FOUND: 404,
+ UNPROCESSABLE_ENTITY: 422,
};
export const successCodes = [
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 3618c6af7e2..c095a017866 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -39,7 +39,14 @@ function blockTagText(text, textArea, blockTag, selected) {
}
}
-function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) {
+function moveCursor({
+ textArea,
+ tag,
+ cursorOffset,
+ positionBetweenTags,
+ removedLastNewLine,
+ select,
+}) {
var pos;
if (!textArea.setSelectionRange) {
return;
@@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se
pos -= 1;
}
+ if (cursorOffset) {
+ pos -= cursorOffset;
+ }
+
return textArea.setSelectionRange(pos, pos);
}
}
-export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) {
+export function insertMarkdownText({
+ textArea,
+ text,
+ tag,
+ cursorOffset,
+ blockTag,
+ selected,
+ wrap,
+ select,
+}) {
var textToInsert,
selectedSplit,
startChar,
@@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr
return moveCursor({
textArea,
tag: tag.replace(textPlaceholder, selected),
+ cursorOffset,
positionBetweenTags: wrap && selected.length === 0,
removedLastNewLine,
select,
});
}
-function updateText({ textArea, tag, blockTag, wrap, select }) {
+function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
- selected = selectedText(text, textArea);
+ selected = selectedText(text, textArea) || tagContent;
$textArea.focus();
- return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select });
+ return insertMarkdownText({
+ textArea,
+ text,
+ tag,
+ cursorOffset,
+ blockTag,
+ selected,
+ wrap,
+ select,
+ });
}
export function addMarkdownListeners(form) {
@@ -178,9 +208,11 @@ export function addMarkdownListeners(form) {
return updateText({
textArea: $this.closest('.md-area').find('textarea'),
tag: $this.data('mdTag'),
+ cursorOffset: $this.data('mdCursorOffset'),
blockTag: $this.data('mdBlock'),
wrap: !$this.data('mdPrepend'),
select: $this.data('mdSelect'),
+ tagContent: $this.data('mdTagContent').toString(),
});
});
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index a282c2df441..9850f7ce782 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -17,27 +17,29 @@ export function getParameterValues(sParam) {
// @param {Object} params - url keys and value to merge
// @param {String} url
export function mergeUrlParams(params, url) {
- let newUrl = Object.keys(params).reduce((acc, paramName) => {
- const paramValue = encodeURIComponent(params[paramName]);
- const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`);
-
- if (paramValue === null) {
- return acc.replace(pattern, '');
- } else if (url.search(pattern) !== -1) {
- return acc.replace(pattern, `$1${paramValue}$2`);
- }
-
- return `${acc}${acc.indexOf('?') > 0 ? '&' : '?'}${paramName}=${paramValue}`;
- }, decodeURIComponent(url));
+ const re = /^([^?#]*)(\?[^#]*)?(.*)/;
+ const merged = {};
+ const urlparts = url.match(re);
+
+ if (urlparts[2]) {
+ urlparts[2]
+ .substr(1)
+ .split('&')
+ .forEach(part => {
+ if (part.length) {
+ const kv = part.split('=');
+ merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('='));
+ }
+ });
+ }
- // Remove a trailing ampersand
- const lastChar = newUrl[newUrl.length - 1];
+ Object.assign(merged, params);
- if (lastChar === '&') {
- newUrl = newUrl.slice(0, -1);
- }
+ const query = Object.keys(merged)
+ .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`)
+ .join('&');
- return newUrl;
+ return `${urlparts[1]}?${query}${urlparts[3]}`;
}
export function removeParamQueryString(url, param) {
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
index c0d45e017b4..9f980fd4899 100644
--- a/app/assets/javascripts/lib/utils/users_cache.js
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -22,6 +22,34 @@ class UsersCache extends Cache {
});
// missing catch is intentional, error handling depends on use case
}
+
+ retrieveById(userId) {
+ if (this.hasData(userId) && this.get(userId).username) {
+ return Promise.resolve(this.get(userId));
+ }
+
+ return Api.user(userId).then(({ data }) => {
+ this.internalStorage[userId] = data;
+ return data;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
+
+ retrieveStatusById(userId) {
+ if (this.hasData(userId) && this.get(userId).status) {
+ return Promise.resolve(this.get(userId).status);
+ }
+
+ return Api.userStatus(userId).then(({ data }) => {
+ if (!this.hasData(userId)) {
+ this.internalStorage[userId] = {};
+ }
+ this.internalStorage[userId].status = data;
+
+ return data;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
}
export default new UsersCache();
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index a88b575ad99..c866e8d180a 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
+import initUserPopovers from './user_popovers';
// expose jQuery as global (TODO: remove these)
window.jQuery = jQuery;
@@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle();
initLogoAnimation();
initUsagePingConsent();
+ initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index d32f39881dd..75c18a9b6a0 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -155,7 +155,7 @@ export default class MilestoneSelect {
const { $el, e } = clickEvent;
let selected = clickEvent.selectedObj;
- let data, boardsStore;
+ let data, modalStoreFilter;
if (!selected) return;
if (options.handleClick) {
@@ -179,11 +179,11 @@ export default class MilestoneSelect {
}
if ($dropdown.closest('.add-issues-modal').length) {
- boardsStore = ModalStore.store.filter;
+ modalStoreFilter = ModalStore.store.filter;
}
- if (boardsStore) {
- boardsStore[$dropdown.data('fieldName')] = selected.name;
+ if (modalStoreFilter) {
+ modalStoreFilter[$dropdown.data('fieldName')] = selected.name;
e.preventDefault();
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
return Issuable.filterResults($dropdown.closest('form'));
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index 0d8f31d6bfc..196b84621b6 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -30,6 +30,7 @@ export default class MirrorRepos {
this.$password.on('input.updateUrl', () => this.debouncedUpdateUrl());
this.initMirrorSSH();
+ this.updateProtectedBranches();
}
initMirrorSSH() {
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
new file mode 100644
index 00000000000..12224e36ba2
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui';
+import dateFormat from 'dateformat';
+
+export default {
+ components: {
+ GlAreaChart,
+ },
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator(data) {
+ return (
+ 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
+ );
+ },
+ },
+ },
+ computed: {
+ chartData() {
+ return this.graphData.queries.reduce((accumulator, query) => {
+ const xLabel = `${query.unit}`;
+ accumulator[xLabel] = {};
+ query.result.forEach(res =>
+ res.values.forEach(v => {
+ accumulator[xLabel][v.time.toISOString()] = v.value;
+ }),
+ );
+ return accumulator;
+ }, {});
+ },
+ chartOptions() {
+ return {
+ xAxis: {
+ name: 'Time',
+ type: 'time',
+ axisLabel: {
+ formatter: date => dateFormat(date, 'h:MMtt'),
+ },
+ nameTextStyle: {
+ padding: [18, 0, 0, 0],
+ },
+ },
+ yAxis: {
+ name: this.graphData.y_label,
+ axisLabel: {
+ formatter: value => value.toFixed(3),
+ },
+ nameTextStyle: {
+ padding: [0, 0, 36, 0],
+ },
+ },
+ legend: {
+ formatter: this.xAxisLabel,
+ },
+ };
+ },
+ xAxisLabel() {
+ return this.graphData.queries.map(query => query.label).join(', ');
+ },
+ },
+ methods: {
+ formatTooltipText(params) {
+ const [date, value] = params;
+ return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
+ },
+ onCreated(chart) {
+ this.$emit('created', chart);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-graph">
+ <div class="prometheus-graph-header">
+ <h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div class="prometheus-graph-widgets"><slot></slot></div>
+ </div>
+ <gl-area-chart
+ :data="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ @created="onCreated"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 218c508a608..2d9c5050c9b 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -4,6 +4,7 @@ import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import MonitoringService from '../services/monitoring_service';
+import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
@@ -12,6 +13,7 @@ import eventHub from '../event_hub';
export default {
components: {
+ MonitorAreaChart,
Graph,
GraphGroup,
EmptyState,
@@ -102,6 +104,9 @@ export default {
};
},
computed: {
+ graphComponent() {
+ return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
+ },
forceRedraw() {
return this.elWidth;
},
@@ -207,7 +212,8 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
- <graph
+ <component
+ :is="graphComponent"
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
@@ -220,7 +226,7 @@ export default {
>
<!-- EE content -->
{{ null }}
- </graph>
+ </component>
</graph-group>
</div>
<empty-state
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
index 815063237fc..64a1df80a8e 100644
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ b/app/assets/javascripts/monitoring/components/graph.vue
@@ -105,6 +105,9 @@ export default {
deploymentFlagData() {
return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag);
},
+ shouldRenderData() {
+ return this.graphData.queries.filter(s => s.result.length > 0).length > 0;
+ },
},
watch: {
hoverData() {
@@ -120,17 +123,17 @@ export default {
},
draw() {
const breakpointSize = bp.getBreakpointSize();
- const query = this.graphData.queries[0];
const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width;
+
this.margin = measurements.large.margin;
+
if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') {
this.graphHeight = 300;
this.margin = measurements.small.margin;
this.measurements = measurements.small;
}
- this.unitOfDisplay = query.unit || '';
+
this.yAxisLabel = this.graphData.y_label || 'Values';
- this.legendTitle = query.label || 'Average';
this.graphWidth = svgWidth - this.margin.left - this.margin.right;
this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
this.baseGraphHeight = this.graphHeight - 50;
@@ -139,8 +142,15 @@ export default {
// pixel offsets inside the svg and outside are not 1:1
this.realPixelRatio = svgWidth / this.baseGraphWidth;
- this.renderAxesPaths();
- this.formatDeployments();
+ // set the legends on the axes
+ const [query] = this.graphData.queries;
+ this.legendTitle = query ? query.label : 'Average';
+ this.unitOfDisplay = query ? query.unit : '';
+
+ if (this.shouldRenderData) {
+ this.renderAxesPaths();
+ this.formatDeployments();
+ }
},
handleMouseOverGraph(e) {
let point = this.$refs.graphData.createSVGPoint();
@@ -266,7 +276,7 @@ export default {
:y-axis-label="yAxisLabel"
:unit-of-display="unitOfDisplay"
/>
- <svg ref="graphData" :viewBox="innerViewBox" class="graph-data">
+ <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data">
<slot name="additionalSvgContent" :graphDrawData="graphDrawData" />
<graph-path
v-for="(path, index) in timeSeries"
@@ -293,8 +303,14 @@ export default {
@mousemove="handleMouseOverGraph($event);"
/>
</svg>
+ <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display">
+ <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle">
+ {{ s__('Metrics|No data to display') }}
+ </text>
+ </svg>
</svg>
<graph-flag
+ v-if="shouldRenderData"
:real-pixel-ratio="realPixelRatio"
:current-x-coordinate="currentXCoordinate"
:current-data="currentData"
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 176f7d9eef2..8692c873a41 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -7,10 +7,29 @@ function sortMetrics(metrics) {
.value();
}
+function checkQueryEmptyData(query) {
+ return {
+ ...query,
+ result: query.result.filter(timeSeries => {
+ const newTimeSeries = timeSeries;
+ const hasValue = series =>
+ !Number.isNaN(series.value) && (series.value !== null || series.value !== 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 => ({
- ...metric,
- queries: metric.queries.map(query => ({
+ return metrics.map(metric => {
+ const queries = metric.queries.map(query => ({
...query,
result: query.result.map(result => ({
...result,
@@ -19,8 +38,13 @@ function normalizeMetrics(metrics) {
value: Number(value),
})),
})),
- })),
- }));
+ }));
+
+ return {
+ ...metric,
+ queries: removeTimeSeriesNoData(queries),
+ };
+ });
}
export default class MonitoringStore {
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
index bb24a1acdb3..50ba14dfb2e 100644
--- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js
+++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
@@ -92,7 +92,11 @@ function queryTimeSeries(query, graphDrawData, lineStyle) {
if (seriesCustomizationData) {
metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
[lineColor, areaColor] = pickColor(seriesCustomizationData.color);
- shouldRenderLegend = false;
+ if (timeSeriesParsed.length > 0) {
+ shouldRenderLegend = false;
+ } else {
+ shouldRenderLegend = true;
+ }
} else {
metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
[lineColor, areaColor] = pickColor();
@@ -101,19 +105,6 @@ function queryTimeSeries(query, graphDrawData, lineStyle) {
}
}
- if (!shouldRenderLegend) {
- if (!timeSeriesParsed[0].tracksLegend) {
- timeSeriesParsed[0].tracksLegend = [];
- }
- timeSeriesParsed[0].tracksLegend.push({
- max: maximumValue,
- average: accum / timeSeries.values.length,
- lineStyle,
- lineColor,
- metricTag,
- });
- }
-
const values = datesWithoutGaps.map(time => ({
time,
value: findByDate(timeSeries.values, time),
@@ -135,6 +126,19 @@ function queryTimeSeries(query, graphDrawData, lineStyle) {
shouldRenderLegend,
renderCanary,
});
+
+ if (!shouldRenderLegend) {
+ if (!timeSeriesParsed[0].tracksLegend) {
+ timeSeriesParsed[0].tracksLegend = [];
+ }
+ timeSeriesParsed[0].tracksLegend.push({
+ max: maximumValue,
+ average: accum / timeSeries.values.length,
+ lineStyle,
+ lineColor,
+ metricTag,
+ });
+ }
});
return timeSeriesParsed;
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index 1c98683c597..e4d72eb8318 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -33,6 +33,7 @@ export default function initMrNotes() {
noteableData,
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
+ helpPagePath: notesDataset.helpPagePath,
};
},
computed: {
@@ -71,6 +72,7 @@ export default function initMrNotes() {
notesData: this.notesData,
userData: this.currentUserData,
shouldShow: this.activeTab === 'show',
+ helpPagePath: this.helpPagePath,
},
});
},
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 884ccca7bde..ce56beb1e6b 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -4,6 +4,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import _ from 'underscore';
import Autosize from 'autosize';
import { __, sprintf } from '~/locale';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
import Autosave from '../../autosave';
import {
@@ -30,6 +31,7 @@ export default {
markdownField,
userAvatarLink,
loadingButton,
+ TimelineEntryItem,
},
mixins: [issuableStateMixin],
props: {
@@ -245,15 +247,19 @@ Please check your network connection and try again.`;
} else {
this.reopenIssue()
.then(() => this.enableButton())
- .catch(() => {
+ .catch(({ data }) => {
this.enableButton();
this.toggleStateButtonLoading(false);
- Flash(
- sprintf(
- __('Something went wrong while reopening the %{issuable}. Please try again later'),
- { issuable: this.noteableDisplayName },
- ),
+ let errorMessage = sprintf(
+ __('Something went wrong while reopening the %{issuable}. Please try again later'),
+ { issuable: this.noteableDisplayName },
);
+
+ if (data) {
+ errorMessage = Object.values(data).join('\n');
+ }
+
+ Flash(errorMessage);
});
}
},
@@ -309,137 +315,135 @@ Please check your network connection and try again.`;
<div>
<note-signed-out-widget v-if="!isLoggedIn" />
<discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" />
- <div v-else-if="canCreateNote" class="notes notes-form timeline">
- <div class="timeline-entry note-form">
- <div class="timeline-entry-inner">
- <div class="flash-container error-alert timeline-content"></div>
- <div class="timeline-icon d-none d-sm-none d-md-block">
- <user-avatar-link
- v-if="author"
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :img-size="40"
- />
- </div>
- <div class="timeline-content timeline-content-form">
- <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
- <div class="error-alert"></div>
+ <ul v-else-if="canCreateNote" class="notes notes-form timeline">
+ <timeline-entry-item class="note-form">
+ <div class="flash-container error-alert timeline-content"></div>
+ <div class="timeline-icon d-none d-sm-none d-md-block">
+ <user-avatar-link
+ v-if="author"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content timeline-content-form">
+ <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
+ <div class="error-alert"></div>
- <issue-warning
- v-if="hasWarning(getNoteableData)"
- :is-locked="isLocked(getNoteableData)"
- :is-confidential="isConfidential(getNoteableData)"
- />
+ <issue-warning
+ v-if="hasWarning(getNoteableData)"
+ :is-locked="isLocked(getNoteableData)"
+ :is-confidential="isConfidential(getNoteableData)"
+ />
- <markdown-field
- ref="markdownField"
- :markdown-preview-path="markdownPreviewPath"
- :markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
- :markdown-version="markdownVersion"
- :add-spacing-classes="false"
- >
- <textarea
- id="note-body"
- ref="textarea"
- slot="textarea"
- v-model="note"
- :disabled="isSubmitting"
- name="note[note]"
- class="note-textarea js-vue-comment-form js-note-text
+ <markdown-field
+ ref="markdownField"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :markdown-version="markdownVersion"
+ :add-spacing-classes="false"
+ >
+ <textarea
+ id="note-body"
+ ref="textarea"
+ slot="textarea"
+ v-model="note"
+ :disabled="isSubmitting"
+ name="note[note]"
+ class="note-textarea js-vue-comment-form js-note-text
js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
- data-supports-quick-actions="true"
- aria-label="Description"
- placeholder="Write a comment or drag your files here…"
- @keydown.up="editCurrentUserLastNote();"
- @keydown.meta.enter="handleSave();"
- @keydown.ctrl.enter="handleSave();"
- >
- </textarea>
- </markdown-field>
- <div class="note-form-actions">
- <div
- class="float-left btn-group
+ data-supports-quick-actions="true"
+ aria-label="Description"
+ placeholder="Write a comment or drag your files here…"
+ @keydown.up="editCurrentUserLastNote();"
+ @keydown.meta.enter="handleSave();"
+ @keydown.ctrl.enter="handleSave();"
+ >
+ </textarea>
+ </markdown-field>
+ <div class="note-form-actions">
+ <div
+ class="float-left btn-group
append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
- >
- <button
- :disabled="isSubmitButtonDisabled"
- class="btn btn-create comment-btn js-comment-button js-comment-submit-button
+ >
+ <button
+ :disabled="isSubmitButtonDisabled"
+ class="btn btn-create comment-btn js-comment-button js-comment-submit-button
qa-comment-button"
- type="submit"
- @click.prevent="handleSave();"
- >
- {{ __(commentButtonTitle) }}
- </button>
- <button
- :disabled="isSubmitButtonDisabled"
- name="button"
- type="button"
- class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
- data-display="static"
- data-toggle="dropdown"
- aria-label="Open comment type dropdown"
- >
- <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i>
- </button>
-
- <ul class="note-type-dropdown dropdown-open-top dropdown-menu">
- <li :class="{ 'droplab-item-selected': noteType === 'comment' }">
- <button
- type="button"
- class="btn btn-transparent"
- @click.prevent="setNoteType('comment');"
- >
- <i aria-hidden="true" class="fa fa-check icon"> </i>
- <div class="description">
- <strong>Comment</strong>
- <p>Add a general comment to this {{ noteableDisplayName }}.</p>
- </div>
- </button>
- </li>
- <li class="divider droplab-item-ignore"></li>
- <li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
- <button
- type="button"
- class="btn btn-transparent qa-discussion-option"
- @click.prevent="setNoteType('discussion');"
- >
- <i aria-hidden="true" class="fa fa-check icon"> </i>
- <div class="description">
- <strong>Start discussion</strong>
- <p>{{ startDiscussionDescription }}</p>
- </div>
- </button>
- </li>
- </ul>
- </div>
-
- <loading-button
- v-if="canUpdateIssue"
- :loading="isToggleStateButtonLoading"
- :container-class="[
- actionButtonClassNames,
- 'btn btn-comment btn-comment-and-close js-action-button',
- ]"
- :disabled="isToggleStateButtonLoading || isSubmitting"
- :label="issueActionButtonTitle"
- @click="handleSave(true);"
- />
-
+ type="submit"
+ @click.prevent="handleSave();"
+ >
+ {{ __(commentButtonTitle) }}
+ </button>
<button
- v-if="note.length"
+ :disabled="isSubmitButtonDisabled"
+ name="button"
type="button"
- class="btn btn-cancel js-note-discard"
- @click="discard"
+ class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
+ data-display="static"
+ data-toggle="dropdown"
+ aria-label="Open comment type dropdown"
>
- Discard draft
+ <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i>
</button>
+
+ <ul class="note-type-dropdown dropdown-open-top dropdown-menu">
+ <li :class="{ 'droplab-item-selected': noteType === 'comment' }">
+ <button
+ type="button"
+ class="btn btn-transparent"
+ @click.prevent="setNoteType('comment');"
+ >
+ <i aria-hidden="true" class="fa fa-check icon"> </i>
+ <div class="description">
+ <strong>Comment</strong>
+ <p>Add a general comment to this {{ noteableDisplayName }}.</p>
+ </div>
+ </button>
+ </li>
+ <li class="divider droplab-item-ignore"></li>
+ <li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
+ <button
+ type="button"
+ class="btn btn-transparent qa-discussion-option"
+ @click.prevent="setNoteType('discussion');"
+ >
+ <i aria-hidden="true" class="fa fa-check icon"> </i>
+ <div class="description">
+ <strong>Start discussion</strong>
+ <p>{{ startDiscussionDescription }}</p>
+ </div>
+ </button>
+ </li>
+ </ul>
</div>
- </form>
- </div>
+
+ <loading-button
+ v-if="canUpdateIssue"
+ :loading="isToggleStateButtonLoading"
+ :container-class="[
+ actionButtonClassNames,
+ 'btn btn-comment btn-comment-and-close js-action-button',
+ ]"
+ :disabled="isToggleStateButtonLoading || isSubmitting"
+ :label="issueActionButtonTitle"
+ @click="handleSave(true);"
+ />
+
+ <button
+ v-if="note.length"
+ type="button"
+ class="btn btn-cancel js-note-discard"
+ @click="discard"
+ >
+ Discard draft
+ </button>
+ </div>
+ </form>
</div>
- </div>
- </div>
+ </timeline-entry-item>
+ </ul>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index 8e8bd150647..af821df0fd2 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -4,7 +4,9 @@ import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { GlSkeletonLoading } from '@gitlab/ui';
-import { trimFirstCharOfLineContent, getDiffMode } from '~/diffs/store/utils';
+import { getDiffMode } from '~/diffs/store/utils';
+
+const FIRST_CHAR_REGEX = /^(\+|-| )/;
export default {
components: {
@@ -26,46 +28,16 @@ export default {
},
computed: {
...mapState({
- noteableData: state => state.notes.noteableData,
projectPath: state => state.diffs.projectPath,
}),
diffMode() {
- return getDiffMode(this.diffFile);
+ return getDiffMode(this.discussion.diff_file);
},
hasTruncatedDiffLines() {
return (
this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0
);
},
- isDiscussionsExpanded() {
- return true; // TODO: @fatihacet - Fix this.
- },
- isCollapsed() {
- return this.diffFile.collapsed || false;
- },
- isImageDiff() {
- return !this.diffFile.text;
- },
- diffFileClass() {
- const { text } = this.diffFile;
- return text ? 'text-file' : 'js-image-file';
- },
- diffFile() {
- return this.discussion.diff_file;
- },
- imageDiffHtml() {
- return this.discussion.image_diff_html;
- },
- userColorScheme() {
- return window.gon.user_color_scheme;
- },
- normalizedDiffLines() {
- if (this.discussion.truncated_diff_lines) {
- return this.discussion.truncated_diff_lines.map(line => trimFirstCharOfLineContent(line));
- }
-
- return [];
- },
},
mounted() {
if (!this.hasTruncatedDiffLines) {
@@ -74,9 +46,6 @@ export default {
},
methods: {
...mapActions(['fetchDiscussionDiffLines']),
- rowTag(html) {
- return html.outerHTML ? 'tr' : 'template';
- },
fetchDiff() {
this.error = false;
this.fetchDiscussionDiffLines(this.discussion)
@@ -85,31 +54,45 @@ export default {
this.error = true;
});
},
+ trimChar(line) {
+ return line.replace(FIRST_CHAR_REGEX, '');
+ },
},
+ userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
<template>
- <div ref="fileHolder" :class="diffFileClass" class="diff-file file-holder">
+ <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder">
<diff-file-header
:discussion-path="discussion.discussion_path"
- :diff-file="diffFile"
+ :diff-file="discussion.diff_file"
:can-current-user-fork="false"
- :discussions-expanded="isDiscussionsExpanded"
- :expanded="!isCollapsed"
+ :expanded="!discussion.diff_file.collapsed"
/>
- <div v-if="diffFile.text" :class="userColorScheme" class="diff-content code">
+ <div
+ v-if="discussion.diff_file.text"
+ :class="$options.userColorSchemeClass"
+ class="diff-content code"
+ >
<table>
- <tr v-for="line in normalizedDiffLines" :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="line_content" v-html="line.rich_text"></td>
- </tr>
+ <template v-if="hasTruncatedDiffLines">
+ <tr
+ v-for="line in discussion.truncated_diff_lines"
+ v-once
+ :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="line_content" v-html="trimChar(line.rich_text)"></td>
+ </tr>
+ </template>
<tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder">
<td class="old_line diff-line-num"></td>
<td class="new_line diff-line-num"></td>
<td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block">
- Unable to load the diff
+ {{ error }} Unable to load the diff
<button
class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button"
@click="fetchDiff"
@@ -131,17 +114,17 @@ export default {
<div v-else>
<diff-viewer
:diff-mode="diffMode"
- :new-path="diffFile.new_path"
- :new-sha="diffFile.diff_refs.head_sha"
- :old-path="diffFile.old_path"
- :old-sha="diffFile.diff_refs.base_sha"
- :file-hash="diffFile.file_hash"
+ :new-path="discussion.diff_file.new_path"
+ :new-sha="discussion.diff_file.diff_refs.head_sha"
+ :old-path="discussion.diff_file.old_path"
+ :old-sha="discussion.diff_file.diff_refs.base_sha"
+ :file-hash="discussion.diff_file.file_hash"
:project-path="projectPath"
>
<image-diff-overlay
slot="image-overlay"
:discussions="discussion"
- :file-hash="diffFile.file_hash"
+ :file-hash="discussion.diff_file.file_hash"
:show-comment-icon="true"
:should-toggle-discussion="false"
badge-class="image-comment-badge"
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index ee79ecbf9b3..c7cfc0f0f3b 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,13 +1,12 @@
<script>
import { mapActions, mapGetters } from 'vuex';
+import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-import { pluralize } from '../../lib/utils/text_utility';
import discussionNavigation from '../mixins/discussion_navigation';
-import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
Icon,
@@ -17,9 +16,9 @@ export default {
...mapGetters([
'getUserData',
'getNoteableData',
- 'discussionCount',
+ 'resolvableDiscussionsCount',
'firstUnresolvedDiscussionId',
- 'resolvedDiscussionCount',
+ 'unresolvedDiscussionsCount',
]),
isLoggedIn() {
return this.getUserData.id;
@@ -27,15 +26,15 @@ export default {
hasNextButton() {
return this.isLoggedIn && !this.allResolved;
},
- countText() {
- return pluralize('discussion', this.discussionCount);
- },
allResolved() {
- return this.resolvedDiscussionCount === this.discussionCount;
+ return this.unresolvedDiscussionsCount === 0;
},
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
+ resolvedDiscussionsCount() {
+ return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount;
+ },
},
methods: {
...mapActions(['expandDiscussion']),
@@ -50,7 +49,7 @@ export default {
</script>
<template>
- <div v-if="discussionCount > 0" class="line-resolve-all-container prepend-top-8">
+ <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container prepend-top-8">
<div>
<div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all">
<span
@@ -61,15 +60,15 @@ export default {
<icon name="check-circle" />
</span>
<span class="line-resolve-text">
- {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
+ {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }}
+ {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }}
</span>
</div>
<div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group">
<a
- v-tooltip
+ v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
:title="s__('Resolve all discussions in new issue')"
- data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
>
<icon name="issue-new" />
@@ -77,9 +76,8 @@ export default {
</div>
<div v-if="isLoggedIn && !allResolved" class="btn-group" role="group">
<button
- v-tooltip
+ v-gl-tooltip
title="Jump to first unresolved discussion"
- data-container="body"
class="btn btn-default discussion-next-btn"
@click="jumpToFirstUnresolvedDiscussion"
>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 9a5817890c9..d99694b06e9 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,8 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'NoteActions',
@@ -11,7 +10,7 @@ export default {
GlLoadingIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
authorId: {
@@ -119,10 +118,10 @@ export default {
<template>
<div class="note-actions">
- <span v-if="accessLevel" class="note-role user-access-role"> {{ accessLevel }} </span>
+ <span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span>
<div v-if="canResolve" class="note-actions-item">
<button
- v-tooltip
+ v-gl-tooltip
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveButtonTitle"
:aria-label="resolveButtonTitle"
@@ -138,12 +137,10 @@ export default {
</div>
<div v-if="canAwardEmoji" class="note-actions-item">
<a
- v-tooltip
+ v-gl-tooltip.bottom
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button js-add-award js-note-emoji"
data-position="right"
- data-placement="bottom"
- data-container="body"
href="#"
title="Add reaction"
>
@@ -158,12 +155,10 @@ export default {
</div>
<div v-if="canEdit" class="note-actions-item">
<button
- v-tooltip
+ v-gl-tooltip.bottom
type="button"
title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent"
- data-container="body"
- data-placement="bottom"
@click="onEdit"
>
<icon name="pencil" css-classes="link-highlight" />
@@ -171,12 +166,10 @@ export default {
</div>
<div v-if="showDeleteAction" class="note-actions-item">
<button
- v-tooltip
+ v-gl-tooltip.bottom
type="button"
title="Delete comment"
class="note-action-button js-note-delete btn btn-transparent"
- data-container="body"
- data-placement="bottom"
@click="onDelete"
>
<icon name="remove" class="link-highlight" />
@@ -184,19 +177,17 @@ export default {
</div>
<div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item">
<button
- v-tooltip
+ v-gl-tooltip.bottom
type="button"
title="More actions"
class="note-action-button more-actions-toggle btn btn-transparent"
data-toggle="dropdown"
- data-container="body"
- data-placement="bottom"
>
<icon css-classes="icon" name="ellipsis_v" />
</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 GitLab') }}</a>
</li>
<li v-if="noteUrl">
<button
@@ -213,7 +204,7 @@ export default {
type="button"
@click.prevent="onDelete"
>
- <span class="text-danger"> {{ __('Delete comment') }} </span>
+ <span class="text-danger">{{ __('Delete comment') }}</span>
</button>
</li>
</ul>
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 4aba2e65edb..3d60eb02db8 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,16 +1,16 @@
<script>
import { mapActions, mapGetters } from 'vuex';
+import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import { glEmojiTag } from '../../emoji';
-import tooltip from '../../vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
awards: {
@@ -167,21 +167,19 @@ export default {
<button
v-for="(awardList, awardName, index) in groupedAwards"
:key="index"
- v-tooltip
+ v-gl-tooltip.bottom="{ boundary: 'viewport' }"
:class="getAwardClassBindings(awardList)"
:title="awardTitle(awardList)"
class="btn award-control"
- data-boundary="viewport"
- data-placement="bottom"
type="button"
@click="handleAward(awardName);"
>
<span v-html="getAwardHTML(awardName)"></span>
- <span class="award-control-text js-counter"> {{ awardList.length }} </span>
+ <span class="award-control-text js-counter">{{ awardList.length }}</span>
</button>
<div v-if="canAwardEmoji" class="award-menu-holder">
<button
- v-tooltip
+ v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award"
title="Add reaction"
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index c0bee600181..bcf5d334da4 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,10 +1,12 @@
<script>
+import { mapActions } from 'vuex';
import $ from 'jquery';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
import noteForm from './note_form.vue';
import autosave from '../mixins/autosave';
+import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
@@ -12,6 +14,7 @@ export default {
noteAwardsList,
noteAttachment,
noteForm,
+ Suggestions,
},
mixins: [autosave],
props: {
@@ -19,6 +22,11 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
canEdit: {
type: Boolean,
required: true,
@@ -28,11 +36,22 @@ export default {
required: false,
default: false,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
noteBody() {
return this.note.note;
},
+ hasSuggestion() {
+ return this.note.suggestions && this.note.suggestions.length;
+ },
+ lineType() {
+ return this.line ? this.line.type : null;
+ },
},
mounted() {
this.renderGFM();
@@ -53,6 +72,7 @@ export default {
}
},
methods: {
+ ...mapActions(['submitSuggestion']),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
@@ -62,19 +82,35 @@ export default {
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
+ applySuggestion({ suggestionId, flashContainer, callback }) {
+ const { discussion_id: discussionId, id: noteId } = this.note;
+
+ this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
+ },
},
};
</script>
<template>
<div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body">
- <div class="note-text md" v-html="note.note_html"></div>
+ <suggestions
+ v-if="hasSuggestion && !isEditing"
+ :suggestions="note.suggestions"
+ :note-html="note.note_html"
+ :line-type="lineType"
+ :help-page-path="helpPagePath"
+ @apply="applySuggestion"
+ />
+ <div v-else class="note-text md" v-html="note.note_html"></div>
<note-form
v-if="isEditing"
ref="noteForm"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
+ :line="line"
+ :note="note"
+ :help-page-path="helpPagePath"
:markdown-version="note.cached_markdown_version"
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 3d3dbbd7fe1..15ce49d7c31 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -39,7 +39,10 @@ export default {
<div :class="className">
{{ actionText }}
<template v-if="editedBy">
- by <a :href="editedBy.path" class="js-vue-author author-link"> {{ editedBy.name }} </a>
+ by
+ <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link">
+ {{ editedBy.name }}
+ </a>
</template>
{{ actionDetailText }}
<time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index ad58267b533..9b7f3d3588d 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,4 +1,5 @@
<script>
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
@@ -48,13 +49,34 @@ export default {
required: false,
default: '',
},
+ resolveDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ note: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
- isResolving: false,
+ isResolving: this.resolveDiscussion,
+ isUnresolving: !this.resolveDiscussion,
resolveAsThread: true,
};
},
@@ -73,7 +95,8 @@ export default {
return '#';
},
markdownPreviewPath() {
- return this.getNoteableDataByProp('preview_note_path');
+ const notable = this.getNoteableDataByProp('preview_note_path');
+ return mergeUrlParams({ preview_suggestions: true }, notable);
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
@@ -87,6 +110,18 @@ export default {
isDisabled() {
return !this.updatedNoteBody.length || this.isSubmitting;
},
+ discussionNote() {
+ const discussionNote = this.discussion.id
+ ? this.getDiscussionLastNote(this.discussion)
+ : this.note;
+ return discussionNote || {};
+ },
+ canSuggest() {
+ return (
+ this.getNoteableData.can_receive_suggestion &&
+ (this.line && this.line.can_receive_suggestion)
+ );
+ },
},
watch: {
noteBody() {
@@ -149,7 +184,7 @@ export default {
<div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form">
<div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger">
This comment has changed since you started editing, please review the
- <a :href="noteHash" target="_blank" rel="noopener noreferrer"> updated comment </a> to ensure
+ <a :href="noteHash" target="_blank" rel="noopener noreferrer">updated comment</a> to ensure
information is not lost.
</div>
<div class="flash-container timeline-content"></div>
@@ -165,7 +200,11 @@ export default {
:markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
+ :line="line"
+ :note="discussionNote"
+ :can-suggest="canSuggest"
:add-spacing-classes="false"
+ :help-page-path="helpPagePath"
>
<textarea
id="note_note"
@@ -174,22 +213,20 @@ export default {
v-model="updatedNoteBody"
: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"
+ class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input"
aria-label="Description"
placeholder="Write a comment or drag your files here…"
@keydown.meta.enter="handleUpdate();"
@keydown.ctrl.enter="handleUpdate();"
@keydown.up="editMyLastNote();"
@keydown.esc="cancelHandler(true);"
- >
- </textarea>
+ ></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 "
+ class="js-vue-issue-save btn btn-success js-comment-button"
@click="handleUpdate();"
>
{{ saveButtonTitle }}
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 8b7450783c9..7b39901024d 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -73,7 +73,14 @@ export default {
{{ __('Toggle discussion') }}
</button>
</div>
- <a v-if="hasAuthor" :href="author.path">
+ <a
+ v-if="hasAuthor"
+ v-once
+ :href="author.path"
+ class="js-user-link"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ >
<span class="note-header-author-name">{{ 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>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 29740ddf6ae..d9dd08a7a6b 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,9 +1,12 @@
<script>
+import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
+import { GlTooltipDirective } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility';
-import { s__ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import systemNote from '~/vue_shared/components/notes/system_note.vue';
import icon from '~/vue_shared/components/icon.vue';
+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';
@@ -20,14 +23,12 @@ import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import discussionNavigation from '../mixins/discussion_navigation';
-import tooltip from '../../vue_shared/directives/tooltip';
export default {
name: 'NoteableDiscussion',
components: {
icon,
noteableNote,
- diffWithNote,
userAvatarLink,
noteHeader,
noteSignedOutWidget,
@@ -37,9 +38,10 @@ export default {
placeholderNote,
placeholderSystemNote,
systemNote,
+ TimelineEntryItem,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
mixins: [autosave, noteable, resolvable, discussionNavigation],
props: {
@@ -47,6 +49,11 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
renderDiffFile: {
type: Boolean,
required: false,
@@ -62,45 +69,32 @@ export default {
required: false,
default: false,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
+ const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
+
return {
isReplying: false,
isResolving: false,
resolveAsThread: true,
- isRepliesToggledByUser: false,
+ isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved),
};
},
computed: {
...mapGetters([
'getNoteableData',
- 'discussionCount',
- 'resolvedDiscussionCount',
- 'allDiscussions',
- 'unresolvedDiscussionsIdsByDiff',
- 'unresolvedDiscussionsIdsByDate',
- 'unresolvedDiscussions',
- 'unresolvedDiscussionsIdsOrdered',
'nextUnresolvedDiscussionId',
- 'isLastUnresolvedDiscussion',
+ 'unresolvedDiscussionsCount',
+ 'hasUnresolvedDiscussions',
+ 'showJumpToNextDiscussion',
]),
- transformedDiscussion() {
- return {
- ...this.discussion.notes[0],
- truncated_diff_lines: this.discussion.truncated_diff_lines || [],
- truncated_diff_lines_path: this.discussion.truncated_diff_lines_path,
- diff_file: this.discussion.diff_file,
- diff_discussion: this.discussion.diff_discussion,
- active: this.discussion.active,
- discussion_path: this.discussion.discussion_path,
- resolved: this.discussion.resolved,
- resolved_by: this.discussion.resolved_by,
- resolved_by_push: this.discussion.resolved_by_push,
- resolved_at: this.discussion.resolved_at,
- };
- },
author() {
- return this.transformedDiscussion.author;
+ return this.initialDiscussion.author;
},
canReply() {
return this.getNoteableData.current_user.can_create_note;
@@ -136,29 +130,19 @@ export default {
return null;
},
resolvedText() {
- return this.transformedDiscussion.resolved_by_push ? 'Automatically resolved' : 'Resolved';
- },
- hasMultipleUnresolvedDiscussions() {
- return this.unresolvedDiscussions.length > 1;
+ return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
- showJumpToNextDiscussion() {
- return (
- this.hasMultipleUnresolvedDiscussions &&
- !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder)
+ shouldShowJumpToNextDiscussion() {
+ return this.showJumpToNextDiscussion(
+ this.discussion.id,
+ this.discussionsByDiffOrder ? 'diff' : 'discussion',
);
},
shouldRenderDiffs() {
- return (
- this.transformedDiscussion.diff_discussion &&
- this.transformedDiscussion.diff_file &&
- this.renderDiffFile
- );
+ return this.discussion.diff_discussion && this.renderDiffFile;
},
shouldGroupReplies() {
- return !this.shouldRenderDiffs && !this.transformedDiscussion.diff_discussion;
- },
- shouldRenderHeader() {
- return this.shouldRenderDiffs;
+ return !this.shouldRenderDiffs && !this.discussion.diff_discussion;
},
wrapperComponent() {
return this.shouldRenderDiffs ? diffWithNote : 'div';
@@ -170,9 +154,6 @@ export default {
return {};
},
- wrapperClass() {
- return this.isDiffDiscussion ? '' : 'card discussion-wrapper';
- },
componentClassName() {
if (this.shouldRenderDiffs) {
if (!this.lastUpdatedAt && !this.discussion.resolved) {
@@ -183,28 +164,60 @@ export default {
return '';
},
shouldShowDiscussions() {
- const isExpanded = this.discussion.expanded;
- const { resolved } = this.transformedDiscussion;
- const isResolvedNonDiffDiscussion = !this.transformedDiscussion.diff_discussion && resolved;
+ const { expanded, resolved } = this.discussion;
+ const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved;
- return isExpanded || this.alwaysExpanded || isResolvedNonDiffDiscussion;
+ return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion;
},
- isRepliesCollapsed() {
- const { discussion, isRepliesToggledByUser } = this;
- const { resolved, notes } = discussion;
- const hasReplies = notes.length > 1;
+ actionText() {
+ const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
+ const linkEnd = '</a>';
+
+ let { commit_id: commitId } = this.discussion;
+ if (commitId) {
+ commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`;
+ }
- return (
- (!discussion.diff_discussion && resolved && hasReplies && !isRepliesToggledByUser) || false
+ let text = s__('MergeRequests|started a discussion');
+
+ if (this.discussion.for_commit) {
+ text = s__(
+ 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}',
+ );
+ } else if (this.discussion.diff_discussion) {
+ if (this.discussion.active) {
+ text = s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}');
+ } else {
+ text = s__(
+ 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}',
+ );
+ }
+ }
+
+ return sprintf(
+ text,
+ {
+ commitId,
+ linkStart,
+ linkEnd,
+ },
+ false,
);
},
+ diffLine() {
+ if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) {
+ return this.discussion.truncated_diff_lines.slice(-1)[0];
+ }
+
+ return this.line;
+ },
},
watch: {
isReplying() {
if (this.isReplying) {
this.$nextTick(() => {
// Pass an extra key to separate reply and note edit forms
- this.initAutoSave(this.transformedDiscussion, ['Reply']);
+ this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']);
});
} else {
this.disposeAutoSave();
@@ -242,7 +255,7 @@ export default {
this.toggleDiscussion({ discussionId: this.discussion.id });
},
toggleReplies() {
- this.isRepliesToggledByUser = !this.isRepliesToggledByUser;
+ this.isRepliesCollapsed = !this.isRepliesCollapsed;
},
showReplyForm() {
this.isReplying = true;
@@ -311,181 +324,171 @@ Please check your network connection and try again.`;
</script>
<template>
- <li class="note note-discussion timeline-entry" :class="componentClassName">
- <div class="timeline-entry-inner">
- <div class="timeline-content">
- <div
- :data-discussion-id="transformedDiscussion.discussion_id"
- class="discussion js-discussion-container"
- >
- <div v-if="shouldRenderHeader" class="discussion-header note-wrapper">
- <div class="timeline-icon">
- <user-avatar-link
- v-if="author"
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :img-size="40"
- />
- </div>
- <note-header
- :author="author"
- :created-at="transformedDiscussion.created_at"
- :note-id="transformedDiscussion.id"
- :include-toggle="true"
- :expanded="discussion.expanded"
- @toggleHandler="toggleDiscussionHandler"
- >
- <template v-if="transformedDiscussion.diff_discussion">
- started a discussion on
- <a :href="transformedDiscussion.discussion_path">
- <template v-if="transformedDiscussion.active">
- the diff
- </template>
- <template v-else>
- an old version of the diff
- </template>
- </a>
- </template>
- <template v-else-if="discussion.for_commit">
- started a discussion on commit
- <a :href="discussion.discussion_path"> {{ truncateSha(discussion.commit_id) }} </a>
- </template>
- <template v-else>
- started a discussion
- </template>
- </note-header>
- <note-edited-text
- v-if="transformedDiscussion.resolved"
- :edited-at="transformedDiscussion.resolved_at"
- :edited-by="transformedDiscussion.resolved_by"
- :action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline"
- />
- <note-edited-text
- v-else-if="lastUpdatedAt"
- :edited-at="lastUpdatedAt"
- :edited-by="lastUpdatedBy"
- action-text="Last updated"
- class-name="discussion-headline-light js-discussion-headline"
+ <timeline-entry-item class="note note-discussion" :class="componentClassName">
+ <div class="timeline-content">
+ <div :data-discussion-id="discussion.id" class="discussion js-discussion-container">
+ <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
+ <div v-once class="timeline-icon">
+ <user-avatar-link
+ v-if="author"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
/>
</div>
- <div v-if="shouldShowDiscussions" class="discussion-body">
- <component :is="wrapperComponent" v-bind="wrapperComponentProps" :class="wrapperClass">
- <div class="discussion-notes">
- <ul class="notes">
- <template v-if="shouldGroupReplies">
- <component
- :is="componentName(initialDiscussion)"
- :note="componentData(initialDiscussion)"
- @handleDeleteNote="deleteNoteHandler"
- >
- <slot slot="avatar-badge" name="avatar-badge"> </slot>
- </component>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="isRepliesCollapsed"
- :replies="replies"
- @toggle="toggleReplies"
+ <note-header
+ :author="author"
+ :created-at="initialDiscussion.created_at"
+ :note-id="initialDiscussion.id"
+ :include-toggle="true"
+ :expanded="discussion.expanded"
+ @toggleHandler="toggleDiscussionHandler"
+ >
+ <span v-html="actionText"></span>
+ </note-header>
+ <note-edited-text
+ v-if="discussion.resolved"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ <note-edited-text
+ v-else-if="lastUpdatedAt"
+ :edited-at="lastUpdatedAt"
+ :edited-by="lastUpdatedBy"
+ action-text="Last updated"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ </div>
+ <div v-if="shouldShowDiscussions" 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"
+ :help-page-path="helpPagePath"
+ @handleDeleteNote="deleteNoteHandler"
+ >
+ <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"
/>
- <template v-if="!isRepliesCollapsed">
- <component
- :is="componentName(note)"
- v-for="note in replies"
- :key="note.id"
- :note="componentData(note)"
- @handleDeleteNote="deleteNoteHandler"
- />
- </template>
- </template>
- <template v-else>
+ <slot slot="avatar-badge" name="avatar-badge"></slot>
+ </component>
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="isRepliesCollapsed"
+ :replies="replies"
+ @toggle="toggleReplies"
+ />
+ <template v-if="!isRepliesCollapsed">
<component
:is="componentName(note)"
- v-for="(note, index) in discussion.notes"
+ v-for="note in replies"
:key="note.id"
:note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="line"
@handleDeleteNote="deleteNoteHandler"
- >
- <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"> </slot>
- </component>
+ />
</template>
- </ul>
- <div
- v-if="!isRepliesCollapsed"
- :class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder"
- >
- <template v-if="!isReplying && canReply">
- <div class="discussion-with-resolve-btn">
+ </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">
+ <button
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply"
+ title="Add a reply"
+ @click="showReplyForm"
+ >
+ Reply...
+ </button>
+ <div v-if="discussion.resolvable">
<button
type="button"
- class="js-vue-discussion-reply btn btn-text-field mr-sm-2 qa-discussion-reply"
- title="Add a reply"
- @click="showReplyForm"
+ class="btn btn-default ml-sm-2"
+ @click="resolveHandler();"
>
- Reply...
+ <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i>
+ {{ resolveButtonTitle }}
</button>
- <div v-if="discussion.resolvable">
+ </div>
+ <div
+ v-if="discussion.resolvable"
+ class="btn-group discussion-actions ml-sm-2"
+ role="group"
+ >
+ <div v-if="!discussionResolved" class="btn-group" role="group">
+ <a
+ v-gl-tooltip
+ :href="discussion.resolve_with_issue_path"
+ :title="s__('MergeRequests|Resolve this discussion in a new issue')"
+ class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
+ >
+ <icon name="issue-new" />
+ </a>
+ </div>
+ <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group">
<button
- type="button"
- class="btn btn-default mr-sm-2"
- @click="resolveHandler();"
+ v-gl-tooltip
+ class="btn btn-default discussion-next-btn"
+ title="Jump to next unresolved discussion"
+ @click="jumpToNextDiscussion"
>
- <i
- v-if="isResolving"
- aria-hidden="true"
- class="fa fa-spinner fa-spin"
- ></i>
- {{ resolveButtonTitle }}
+ <icon name="comment-next" />
</button>
</div>
- <div
- v-if="discussion.resolvable"
- class="btn-group discussion-actions ml-sm-2"
- role="group"
- >
- <div v-if="!discussionResolved" class="btn-group" role="group">
- <a
- v-tooltip
- :href="discussion.resolve_with_issue_path"
- :title="s__('MergeRequests|Resolve this discussion in a new issue')"
- class="new-issue-for-discussion btn
- btn-default discussion-create-issue-btn"
- data-container="body"
- >
- <icon name="issue-new" />
- </a>
- </div>
- <div v-if="showJumpToNextDiscussion" class="btn-group" role="group">
- <button
- v-tooltip
- class="btn btn-default discussion-next-btn"
- title="Jump to next unresolved discussion"
- data-container="body"
- @click="jumpToNextDiscussion"
- >
- <icon name="comment-next" />
- </button>
- </div>
- </div>
</div>
- </template>
- <note-form
- v-if="isReplying"
- ref="noteForm"
- :discussion="discussion"
- :is-editing="false"
- save-button-title="Comment"
- @handleFormUpdate="saveReply"
- @cancelForm="cancelReplyForm"
- />
- <note-signed-out-widget v-if="!canReply" />
- </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>
- </component>
- </div>
+ </div>
+ </component>
</div>
</div>
</div>
- </li>
+ </timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index c2e49f8b23f..4c02588127e 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -2,6 +2,7 @@
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { escape } from 'underscore';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteHeader from './note_header.vue';
@@ -18,6 +19,7 @@ export default {
noteHeader,
noteActions,
noteBody,
+ TimelineEntryItem,
},
mixins: [noteable, resolvable],
props: {
@@ -25,6 +27,16 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -169,61 +181,65 @@ export default {
</script>
<template>
- <li
+ <timeline-entry-item
:id="noteAnchorId"
:class="classNameBindings"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
- class="note timeline-entry note-wrapper"
+ class="note note-wrapper"
>
- <div class="timeline-entry-inner">
- <div class="timeline-icon">
- <user-avatar-link
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :img-size="40"
- >
- <slot slot="avatar-badge" name="avatar-badge"> </slot>
- </user-avatar-link>
+ <div v-once class="timeline-icon">
+ <user-avatar-link
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ >
+ <slot slot="avatar-badge" name="avatar-badge"></slot>
+ </user-avatar-link>
+ </div>
+ <div class="timeline-content">
+ <div class="note-header">
+ <note-header
+ v-once
+ :author="author"
+ :created-at="note.created_at"
+ :note-id="note.id"
+ action-text="commented"
+ />
+ <note-actions
+ :author-id="author.id"
+ :note-id="note.id"
+ :note-url="note.noteable_note_url"
+ :access-level="note.human_access"
+ :can-edit="note.current_user.can_edit"
+ :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"
+ :report-abuse-path="note.report_abuse_path"
+ :resolvable="note.resolvable"
+ :is-resolved="note.resolved"
+ :is-resolving="isResolving"
+ :resolved-by="note.resolved_by"
+ @handleEdit="editHandler"
+ @handleDelete="deleteHandler"
+ @handleResolve="resolveHandler"
+ />
</div>
- <div class="timeline-content">
- <div class="note-header">
- <note-header
- :author="author"
- :created-at="note.created_at"
- :note-id="note.id"
- action-text="commented"
- />
- <note-actions
- :author-id="author.id"
- :note-id="note.id"
- :note-url="note.noteable_note_url"
- :access-level="note.human_access"
- :can-edit="note.current_user.can_edit"
- :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"
- :report-abuse-path="note.report_abuse_path"
- :resolvable="note.resolvable"
- :is-resolved="note.resolved"
- :is-resolving="isResolving"
- :resolved-by="note.resolved_by"
- @handleEdit="editHandler"
- @handleDelete="deleteHandler"
- @handleResolve="resolveHandler"
- />
- </div>
+ <div class="timeline-discussion-body">
+ <slot name="discussion-resolved-text"></slot>
<note-body
ref="noteBody"
:note="note"
+ :line="line"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
+ :help-page-path="helpPagePath"
@handleFormUpdate="formUpdateHandler"
@cancelForm="formCancelHandler"
/>
</div>
</div>
- </li>
+ </timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 79ece036e69..f3fcfdfda05 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note.
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
+import initUserPopovers from '../../user_popovers';
export default {
name: 'NotesApp',
@@ -22,6 +23,7 @@ export default {
commentForm,
placeholderNote,
placeholderSystemNote,
+ skeletonLoadingContainer,
},
props: {
noteableData: {
@@ -47,6 +49,11 @@ export default {
required: false,
default: 0,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -59,7 +66,6 @@ export default {
'isNotesFetched',
'discussions',
'getNotesDataByProp',
- 'discussionCount',
'isLoading',
'commentsDisabled',
]),
@@ -101,47 +107,33 @@ export default {
if (parentElement && parentElement.classList.contains('js-vue-notes-event')) {
parentElement.addEventListener('toggleAward', event => {
const { awardName, noteId } = event.detail;
- this.actionToggleAward({ awardName, noteId });
+ this.toggleAward({ awardName, noteId });
});
}
},
updated() {
- this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')));
+ this.$nextTick(() => {
+ highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ });
},
methods: {
- ...mapActions({
- setLoadingState: 'setLoadingState',
- fetchDiscussions: 'fetchDiscussions',
- poll: 'poll',
- actionToggleAward: 'toggleAward',
- scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
- setNotesData: 'setNotesData',
- setNoteableData: 'setNoteableData',
- setUserData: 'setUserData',
- setLastFetchedAt: 'setLastFetchedAt',
- setTargetNoteHash: 'setTargetNoteHash',
- toggleDiscussion: 'toggleDiscussion',
- setNotesFetchedState: 'setNotesFetchedState',
- startTaskList: 'startTaskList',
- }),
- getComponentName(discussion) {
- if (discussion.isSkeletonNote) {
- return skeletonLoadingContainer;
- }
- if (discussion.isPlaceholderNote) {
- if (discussion.placeholderType === constants.SYSTEM_NOTE) {
- return placeholderSystemNote;
- }
- return placeholderNote;
- } else if (discussion.individual_note) {
- return discussion.notes[0].system ? systemNote : noteableNote;
- }
-
- return noteableDiscussion;
- },
- getComponentData(discussion) {
- return discussion.individual_note ? { note: discussion.notes[0] } : { discussion };
- },
+ ...mapActions([
+ 'setLoadingState',
+ 'fetchDiscussions',
+ 'poll',
+ 'toggleAward',
+ 'scrollToNoteIfNeeded',
+ 'setNotesData',
+ 'setNoteableData',
+ 'setUserData',
+ 'setLastFetchedAt',
+ 'setTargetNoteHash',
+ 'toggleDiscussion',
+ 'setNotesFetchedState',
+ 'expandDiscussion',
+ 'startTaskList',
+ ]),
fetchNotes() {
if (this.isFetching) return null;
@@ -181,31 +173,47 @@ export default {
const noteId = hash && hash.replace(/^note_/, '');
if (noteId) {
- this.discussions.forEach(discussion => {
- if (discussion.notes) {
- discussion.notes.forEach(note => {
- if (`${note.id}` === `${noteId}`) {
- // FIXME: this modifies the store state without using a mutation/action
- Object.assign(discussion, { expanded: true });
- }
- });
- }
- });
+ const discussion = this.discussions.find(d => d.notes.some(({ id }) => id === noteId));
+
+ if (discussion) {
+ this.expandDiscussion({ discussionId: discussion.id });
+ }
}
},
},
+ systemNote: constants.SYSTEM_NOTE,
};
</script>
<template>
<div v-show="shouldShow" id="notes">
<ul id="notes-list" class="notes main-notes-list timeline">
- <component
- :is="getComponentName(discussion)"
- v-for="discussion in allDiscussions"
- :key="discussion.id"
- v-bind="getComponentData(discussion)"
- />
+ <template v-for="discussion in allDiscussions">
+ <skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" />
+ <template v-else-if="discussion.isPlaceholderNote">
+ <placeholder-system-note
+ v-if="discussion.placeholderType === $options.systemNote"
+ :key="discussion.id"
+ :note="discussion.notes[0]"
+ />
+ <placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" />
+ </template>
+ <template v-else-if="discussion.individual_note">
+ <system-note
+ v-if="discussion.notes[0].system"
+ :key="discussion.id"
+ :note="discussion.notes[0]"
+ />
+ <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" />
+ </template>
+ <noteable-discussion
+ v-else
+ :key="discussion.id"
+ :discussion="discussion"
+ :render-diff-file="true"
+ :help-page-path="helpPagePath"
+ />
+ </template>
</ul>
<comment-form
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index f7c4deee1f8..3d89d907777 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,29 +1,56 @@
import { scrollToElement } from '~/lib/utils/common_utils';
+import eventHub from '../../notes/event_hub';
export default {
methods: {
- jumpToDiscussion(id) {
- if (id) {
- const activeTab = window.mrTabs.currentAction;
- const selector =
- activeTab === 'diffs'
- ? `ul.notes[data-discussion-id="${id}"]`
- : `div.discussion[data-discussion-id="${id}"]`;
- const el = document.querySelector(selector);
+ diffsJump(id) {
+ const selector = `ul.notes[data-discussion-id="${id}"]`;
- if (activeTab === 'commits' || activeTab === 'pipelines') {
- window.mrTabs.activateTab('show');
- }
+ eventHub.$once('scrollToDiscussion', () => {
+ const el = document.querySelector(selector);
if (el) {
- this.expandDiscussion({ discussionId: id });
-
scrollToElement(el);
+
return true;
}
+
+ return false;
+ });
+
+ this.expandDiscussion({ discussionId: id });
+ },
+ discussionJump(id) {
+ const selector = `div.discussion[data-discussion-id="${id}"]`;
+
+ const el = document.querySelector(selector);
+
+ this.expandDiscussion({ discussionId: id });
+
+ if (el) {
+ scrollToElement(el);
+
+ return true;
}
return false;
},
+ jumpToDiscussion(id) {
+ if (id) {
+ const activeTab = window.mrTabs.currentAction;
+
+ if (activeTab === 'diffs') {
+ this.diffsJump(id);
+ } else if (activeTab === 'commits' || activeTab === 'pipelines') {
+ window.mrTabs.eventHub.$once('MergeRequestTabChange', () => {
+ setTimeout(() => this.discussionJump(id), 0);
+ });
+
+ window.mrTabs.tabShown('show');
+ } else {
+ this.discussionJump(id);
+ }
+ }
+ },
},
};
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index cd8394e0619..8edf3d088bb 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -36,7 +36,7 @@ export default {
const discussion = this.resolveAsThread;
const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`;
- this.toggleResolveNote({ endpoint, isResolved, discussion })
+ return this.toggleResolveNote({ endpoint, isResolved, discussion })
.then(() => {
this.isResolving = false;
})
diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
index 47a6f07cce2..237e70c0a4c 100644
--- a/app/assets/javascripts/notes/services/notes_service.js
+++ b/app/assets/javascripts/notes/services/notes_service.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import Api from '~/api';
import VueResource from 'vue-resource';
import * as constants from '../constants';
@@ -44,4 +45,7 @@ export default {
toggleIssueState(endpoint, data) {
return Vue.http.put(endpoint, data);
},
+ applySuggestion(id) {
+ return Api.applySuggestion(id);
+ },
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 5b2f0540020..65f85314fa0 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -11,13 +11,19 @@ import * as constants from '../constants';
import service from '../services/notes_service';
import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
-import { isInViewport, scrollToElement } from '../../lib/utils/common_utils';
+import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
import { __ } from '~/locale';
let eTagPoll;
-export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data);
+export const expandDiscussion = ({ commit, dispatch }, data) => {
+ if (data.discussionId) {
+ dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true });
+ }
+
+ commit(types.EXPAND_DISCUSSION, data);
+};
export const collapseDiscussion = ({ commit }, data) => commit(types.COLLAPSE_DISCUSSION, data);
@@ -39,12 +45,13 @@ export const setNotesFetchedState = ({ commit }, state) =>
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
-export const fetchDiscussions = ({ commit }, { path, filter }) =>
+export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) =>
service
.fetchDiscussions(path, filter)
.then(res => res.json())
.then(discussions => {
commit(types.SET_INITIAL_DISCUSSIONS, discussions);
+ dispatch('updateResolvableDiscussonsCounts');
});
export const updateDiscussion = ({ commit, state }, discussion) => {
@@ -53,11 +60,18 @@ export const updateDiscussion = ({ commit, state }, discussion) => {
return utils.findNoteObjectById(state.discussions, discussion.id);
};
-export const deleteNote = ({ commit, dispatch }, note) =>
+export const deleteNote = ({ commit, dispatch, state }, note) =>
service.deleteNote(note.path).then(() => {
+ const discussion = state.discussions.find(({ id }) => id === note.discussion_id);
+
commit(types.DELETE_NOTE, note);
dispatch('updateMergeRequestWidget');
+ dispatch('updateResolvableDiscussonsCounts');
+
+ if (isInMRPage()) {
+ dispatch('diffs/removeDiscussionsFromDiff', discussion);
+ }
});
export const updateNote = ({ commit, dispatch }, { endpoint, note }) =>
@@ -89,6 +103,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
+ dispatch('updateResolvableDiscussonsCounts');
}
return res;
});
@@ -104,6 +119,8 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
commit(mutationType, res);
+ dispatch('updateResolvableDiscussonsCounts');
+
dispatch('updateMergeRequestWidget');
});
@@ -385,5 +402,28 @@ export const startTaskList = ({ dispatch }) =>
}),
);
+export const updateResolvableDiscussonsCounts = ({ commit }) =>
+ commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
+
+export const submitSuggestion = (
+ { commit },
+ { discussionId, noteId, suggestionId, flashContainer, callback },
+) => {
+ service
+ .applySuggestion(suggestionId)
+ .then(() => {
+ commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId });
+ callback();
+ })
+ .catch(() => {
+ Flash(
+ __('Something went wrong while applying the suggestion. Please try again.'),
+ 'alert',
+ flashContainer,
+ );
+ callback();
+ });
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 980d79605d7..0ffc0cb2593 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -53,30 +53,26 @@ export const getCurrentUserLastNote = state =>
export const getDiscussionLastNote = state => discussion =>
reverseNotes(discussion.notes).find(el => isLastNote(el, state));
-export const discussionCount = state => {
- const filteredDiscussions = state.discussions.filter(n => !n.individual_note && n.resolvable);
+export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCount;
+export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount;
+export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions;
- return filteredDiscussions.length;
-};
-
-export const unresolvedDiscussions = (state, getters) => {
- const resolvedMap = getters.resolvedDiscussionsById;
-
- return state.discussions.filter(n => !n.individual_note && !resolvedMap[n.id]);
-};
+export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => {
+ const orderedDiffs =
+ mode !== 'discussion'
+ ? getters.unresolvedDiscussionsIdsByDiff
+ : getters.unresolvedDiscussionsIdsByDate;
-export const allDiscussions = (state, getters) => {
- const resolved = getters.resolvedDiscussionsById;
- const unresolved = getters.unresolvedDiscussions;
+ const indexOf = orderedDiffs.indexOf(discussionId);
- return Object.values(resolved).concat(unresolved);
+ return indexOf !== -1 && indexOf < orderedDiffs.length - 1;
};
export const isDiscussionResolved = (state, getters) => discussionId =>
getters.resolvedDiscussionsById[discussionId] !== undefined;
-export const allResolvableDiscussions = (state, getters) =>
- getters.allDiscussions.filter(d => !d.individual_note && d.resolvable);
+export const allResolvableDiscussions = state =>
+ state.discussions.filter(d => !d.individual_note && d.resolvable);
export const resolvedDiscussionsById = state => {
const map = {};
@@ -119,7 +115,7 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) =>
// line numbers.
export const unresolvedDiscussionsIdsByDiff = (state, getters) =>
getters.allResolvableDiscussions
- .filter(d => !d.resolved)
+ .filter(d => !d.resolved && d.active)
.sort((a, b) => {
if (!a.diff_file || !b.diff_file) {
return 0;
@@ -147,15 +143,12 @@ export const resolvedDiscussionCount = (state, getters) => {
return Object.keys(resolvedMap).length;
};
-export const discussionTabCounter = state => {
- let all = [];
-
- state.discussions.forEach(discussion => {
- all = all.concat(discussion.notes.filter(note => !note.system && !note.placeholder));
- });
-
- return all.length;
-};
+export const discussionTabCounter = state =>
+ state.discussions.reduce(
+ (acc, discussion) =>
+ acc + discussion.notes.filter(note => !note.system && !note.placeholder).length,
+ 0,
+ );
// Returns the list of discussion IDs ordered according to given parameter
// @param {Boolean} diffOrder - is ordered by diff?
@@ -182,8 +175,10 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif
export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => {
const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
const currentIndex = idsOrdered.indexOf(discussionId);
+ const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2);
- return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0];
+ // Get the first ID if there is none after the currentIndex
+ return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0];
};
// @param {Boolean} diffOrder - is ordered by diff?
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 8aea269ea7d..887e6d22b06 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -20,8 +20,12 @@ export default () => ({
userData: {},
noteableData: {
current_user: {},
+ preview_note_path: 'path/to/preview',
},
commentsDisabled: false,
+ resolvableDiscussionsCount: 0,
+ unresolvedDiscussionsCount: 0,
+ hasUnresolvedDiscussions: false,
},
actions,
getters,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index dfbf3b7b34b..df943c155f4 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -16,11 +16,13 @@ export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES';
export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
+export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
+export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS';
// Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index f6054e0be87..8992454be2e 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -22,8 +22,10 @@ export default {
if (isDiscussion && isInMRPage()) {
noteData.resolvable = note.resolvable;
noteData.resolved = false;
+ noteData.active = true;
noteData.resolve_path = note.resolve_path;
noteData.resolve_with_issue_path = note.resolve_with_issue_path;
+ noteData.diff_discussion = false;
}
state.discussions.push(noteData);
@@ -97,33 +99,36 @@ export default {
},
[types.SET_INITIAL_DISCUSSIONS](state, discussionsData) {
- const discussions = [];
+ const discussions = discussionsData.reduce((acc, d) => {
+ const discussion = { ...d };
+ const diffData = {};
- discussionsData.forEach(discussion => {
if (discussion.diff_file) {
- Object.assign(discussion, {
- file_hash: discussion.diff_file.file_hash,
- truncated_diff_lines: discussion.truncated_diff_lines || [],
- });
+ diffData.file_hash = discussion.diff_file.file_hash;
+ diffData.truncated_diff_lines = discussion.truncated_diff_lines || [];
}
// To support legacy notes, should be very rare case.
if (discussion.individual_note && discussion.notes.length > 1) {
discussion.notes.forEach(n => {
- discussions.push({
+ acc.push({
...discussion,
+ ...diffData,
notes: [n], // override notes array to only have one item to mimick individual_note
});
});
} else {
const oldNote = utils.findNoteObjectById(state.discussions, discussion.id);
- discussions.push({
+ acc.push({
...discussion,
+ ...diffData,
expanded: oldNote ? oldNote.expanded : discussion.expanded,
});
}
- });
+
+ return acc;
+ }, []);
Object.assign(state, { discussions });
},
@@ -174,9 +179,11 @@ export default {
}
},
- [types.TOGGLE_DISCUSSION](state, { discussionId }) {
+ [types.TOGGLE_DISCUSSION](state, { discussionId, forceExpanded = null }) {
const discussion = utils.findNoteObjectById(state.discussions, discussionId);
- Object.assign(discussion, { expanded: !discussion.expanded });
+ Object.assign(discussion, {
+ expanded: forceExpanded === null ? !discussion.expanded : forceExpanded,
+ });
},
[types.UPDATE_NOTE](state, note) {
@@ -190,12 +197,25 @@ export default {
}
},
+ [types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) {
+ const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
+ const comment = utils.findNoteObjectById(noteObj.notes, noteId);
+
+ comment.suggestions = comment.suggestions.map(suggestion => ({
+ ...suggestion,
+ applied: suggestion.applied || suggestion.id === suggestionId,
+ appliable: false,
+ }));
+ },
+
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
note.expanded = true; // override expand flag to prevent collapse
if (note.diff_file) {
- Object.assign(note, { file_hash: note.diff_file.file_hash });
+ Object.assign(note, {
+ file_hash: note.diff_file.file_hash,
+ });
}
Object.assign(selectedDiscussion, { ...note });
},
@@ -229,4 +249,16 @@ export default {
[types.DISABLE_COMMENTS](state, value) {
state.commentsDisabled = value;
},
+ [types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS](state) {
+ state.resolvableDiscussionsCount = state.discussions.filter(
+ discussion => !discussion.individual_note && discussion.resolvable,
+ ).length;
+ state.unresolvedDiscussionsCount = state.discussions.filter(
+ discussion =>
+ !discussion.individual_note &&
+ discussion.resolvable &&
+ discussion.notes.some(note => note.resolvable && !note.resolved),
+ ).length;
+ state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1;
+ },
};
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index c4c8cf86cb0..e7fa05faa8a 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -12,6 +12,10 @@ export default function notificationsDropdown() {
const form = $(this).parents('.notification-form:first');
form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner');
+ if (form.hasClass('no-label')) {
+ form.find('.js-notification-loading').toggleClass('hidden');
+ form.find('.js-notifications-icon').toggleClass('hidden');
+ }
form.find('#notification_setting_level').val(notificationLevel);
form.submit();
});
diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js
index 0c585e162cb..8f98be79640 100644
--- a/app/assets/javascripts/pages/dashboard/projects/index.js
+++ b/app/assets/javascripts/pages/dashboard/projects/index.js
@@ -1,3 +1,7 @@
import ProjectsList from '~/projects_list';
+import Star from '../../../star';
-document.addEventListener('DOMContentLoaded', () => new ProjectsList());
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectsList(); // eslint-disable-line no-new
+ new Star('.project-row'); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js
index 21efc4f6d00..845a5f7042c 100644
--- a/app/assets/javascripts/pages/groups/clusters/index/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index/index.js
@@ -1,7 +1,5 @@
-import PersistentUserCallout from '~/persistent_user_callout';
+import initDismissableCallout from '~/dismissable_callout';
document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.gcp-signup-offer');
-
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
+ initDismissableCallout('.gcp-signup-offer');
});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 32b55575f95..01ef445c901 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import { GROUP_BADGE } from '~/badges/constants';
+import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => {
@@ -17,5 +18,8 @@ document.addEventListener('DOMContentLoaded', () => {
);
mountBadgeSettings(GROUP_BADGE);
+ // Initialize Subgroups selector
+ groupsSelect();
+
projectSelect();
});
diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js
index 00e2d7fc998..bf80d8b8193 100644
--- a/app/assets/javascripts/pages/groups/index.js
+++ b/app/assets/javascripts/pages/groups/index.js
@@ -1,12 +1,6 @@
-import PersistentUserCallout from '~/persistent_user_callout';
+import initDismissableCallout from '~/dismissable_callout';
import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
-function initCallout() {
- const callout = document.querySelector('.gcp-signup-offer');
-
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
-}
-
document.addEventListener('DOMContentLoaded', () => {
const { page } = document.body.dataset;
const newClusterViews = [
@@ -16,7 +10,7 @@ document.addEventListener('DOMContentLoaded', () => {
];
if (newClusterViews.indexOf(page) > -1) {
- initCallout();
+ initDismissableCallout('.gcp-signup-offer');
initGkeDropdowns();
}
});
diff --git a/app/assets/javascripts/pages/profiles/show/emoji_menu.js b/app/assets/javascripts/pages/profiles/show/emoji_menu.js
index 094837b40e0..286c1f1e929 100644
--- a/app/assets/javascripts/pages/profiles/show/emoji_menu.js
+++ b/app/assets/javascripts/pages/profiles/show/emoji_menu.js
@@ -1,3 +1,4 @@
+import '~/commons/bootstrap';
import { AwardsHandler } from '~/awards_handler';
class EmojiMenu extends AwardsHandler {
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 21efc4f6d00..845a5f7042c 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -1,7 +1,5 @@
-import PersistentUserCallout from '~/persistent_user_callout';
+import initDismissableCallout from '~/dismissable_callout';
document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.gcp-signup-offer');
-
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
+ initDismissableCallout('.gcp-signup-offer');
});
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index f5b1cf85e68..899d5925956 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -3,8 +3,8 @@ 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 initProjectLoadingSpinner from '../shared/save_project_loader';
-import projectAvatar from '../shared/project_avatar';
import initProjectPermissionsSettings from '../shared/permissions';
document.addEventListener('DOMContentLoaded', () => {
@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
setupProjectEdit();
// Initialize expandable settings panels
initSettingsPanels();
- projectAvatar();
+ fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input');
initProjectPermissionsSettings();
initConfirmDangerModal();
mountBadgeSettings(PROJECT_BADGE);
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index b0345b4e50d..5659e13981a 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,5 +1,5 @@
+import initDismissableCallout from '~/dismissable_callout';
import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
-import PersistentUserCallout from '../../persistent_user_callout';
import Project from './project';
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
@@ -12,9 +12,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
-
+ initDismissableCallout('.gcp-signup-offer');
initGkeDropdowns();
}
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 02a56685a35..f99023ad8e7 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -17,7 +17,7 @@ export default () => {
new MilestoneSelect();
new IssuableTemplateSelectors();
- if (gon.features.issueSuggestions && gon.features.graphql) {
+ if (gon.features.graphql) {
initSuggestions();
}
};
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index a6bee49a6b1..b288989b252 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -13,6 +13,9 @@ export default class Project {
const $cloneOptions = $('ul.clone-options-dropdown');
const $projectCloneField = $('#project_clone');
const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label');
+ const mobileCloneField = document.querySelector(
+ '.js-mobile-git-clone .js-clone-dropdown-label',
+ );
const selectedCloneOption = $cloneBtnLabel.text().trim();
if (selectedCloneOption.length > 0) {
@@ -36,7 +39,11 @@ export default class Project {
$label.text(activeText);
});
- $projectCloneField.val(url);
+ if (mobileCloneField) {
+ mobileCloneField.dataset.clipboardText = url;
+ } else {
+ $projectCloneField.val(url);
+ }
$('.js-git-empty .js-clone').text(url);
});
// Ref switcher
diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js
new file mode 100644
index 00000000000..c183fbb9610
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/releases/index/index.js
@@ -0,0 +1,3 @@
+import initReleases from '~/releases';
+
+document.addEventListener('DOMContentLoaded', initReleases);
diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js
new file mode 100644
index 00000000000..7b08620773c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/serverless/index.js
@@ -0,0 +1,5 @@
+import ServerlessBundle from '~/serverless/serverless_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ServerlessBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js
index a52861c9efa..3e02893f24c 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/form.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/form.js
@@ -7,6 +7,7 @@ import initDeployKeys from '~/deploy_keys';
import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list';
import DueDateSelectors from '~/due_date_select';
+import fileUpload from '~/lib/utils/file_upload';
export default () => {
new ProtectedTagCreate();
@@ -16,4 +17,5 @@ export default () => {
new ProtectedBranchCreate();
new ProtectedBranchEditList();
new DueDateSelectors();
+ fileUpload('.js-choose-file', '.js-object-map-input');
};
diff --git a/app/assets/javascripts/pages/projects/shared/project_avatar.js b/app/assets/javascripts/pages/projects/shared/project_avatar.js
deleted file mode 100644
index 1e69ecb481d..00000000000
--- a/app/assets/javascripts/pages/projects/shared/project_avatar.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import $ from 'jquery';
-
-export default function projectAvatar() {
- $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() {
- const form = $(this).closest('form');
- return form.find('.js-project-avatar-input').click();
- });
-
- $('.js-project-avatar-input').bind('change', function onClickAvatarInput() {
- const form = $(this).closest('form');
- const filename = $(this)
- .val()
- .replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape
- return form.find('.js-avatar-filename').text(filename);
- });
-}
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
index d3e8dbf4000..9b58d42b47d 100644
--- a/app/assets/javascripts/pages/projects/wikis/wikis.js
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -1,5 +1,4 @@
import bp from '../../../breakpoints';
-import { slugify } from '../../../lib/utils/text_utility';
import { parseQueryStringIntoObject } from '../../../lib/utils/common_utils';
import { mergeUrlParams, redirectTo } from '../../../lib/utils/url_utility';
@@ -26,7 +25,8 @@ export default class Wikis {
if (!this.newWikiForm) return;
const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
- const slug = slugify(slugInput.value);
+
+ const slug = slugInput.value;
if (slug.length > 0) {
const wikisPath = slugInput.getAttribute('data-wikis-path');
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index aa537d4a43e..1c3fd58ca74 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -151,8 +151,10 @@ export default class UserTabs {
loadTab(action, endpoint) {
this.toggleLoading(true);
+ const params = action === 'projects' ? { skip_namespace: true } : {};
+
return axios
- .get(endpoint)
+ .get(endpoint, { params })
.then(({ data }) => {
const tabSelector = `div#${action}`;
this.$parentEl.find(tabSelector).html(data.html);
@@ -188,7 +190,7 @@ export default class UserTabs {
requestParams: { limit: 10 },
});
UserTabs.renderMostRecentBlocks('#js-overview .projects-block', {
- requestParams: { limit: 10, skip_pagination: true },
+ requestParams: { limit: 10, skip_pagination: true, skip_namespace: true, compact_mode: true },
});
this.loaded.overview = true;
@@ -206,6 +208,8 @@ export default class UserTabs {
loadActivityCalendar() {
const $calendarWrap = this.$parentEl.find('.tab-pane.active .user-calendar');
+ if (!$calendarWrap.length) return;
+
const calendarPath = $calendarWrap.data('calendarPath');
AjaxCache.retrieve(calendarPath)
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
deleted file mode 100644
index 1e34e74a152..00000000000
--- a/app/assets/javascripts/persistent_user_callout.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
-import Flash from './flash';
-
-export default class PersistentUserCallout {
- constructor(container) {
- const { dismissEndpoint, featureId } = container.dataset;
- this.container = container;
- this.dismissEndpoint = dismissEndpoint;
- this.featureId = featureId;
-
- this.init();
- }
-
- init() {
- const closeButton = this.container.querySelector('.js-close');
- closeButton.addEventListener('click', event => this.dismiss(event));
- }
-
- dismiss(event) {
- event.preventDefault();
-
- axios
- .post(this.dismissEndpoint, {
- feature_name: this.featureId,
- })
- .then(() => {
- this.container.remove();
- })
- .catch(() => {
- Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.'));
- });
- }
-}
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 6f008528db4..59cebaba717 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -18,23 +18,19 @@ export default {
required: true,
},
},
-
computed: {
graph() {
return this.pipeline.details && this.pipeline.details.stages;
},
},
-
methods: {
capitalizeStageName(name) {
const escapedName = _.escape(name);
return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
},
-
isFirstColumn(index) {
return index === 0;
},
-
stageConnectorClass(index, stage) {
let className;
@@ -48,7 +44,6 @@ export default {
return className;
},
-
refreshPipelineGraph() {
this.$emit('refreshPipelineGraph');
},
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 782494f72e4..cf9db89e32b 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -84,10 +84,6 @@ export default {
return textBuilder.join(' ');
},
-
- tooltipBoundary() {
- return this.dropdownLength < 5 ? 'viewport' : null;
- },
/**
* Verifies if the provided job has an action path
*
@@ -108,7 +104,7 @@ export default {
<div class="ci-job-component">
<gl-link
v-if="status.has_details"
- v-gl-tooltip="{ boundary: tooltipBoundary }"
+ v-gl-tooltip
:href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index e5924d3a77e..30a5bbf92ce 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -65,7 +65,7 @@ export default {
v-if="pipeline.flags.latest"
v-gl-tooltip
class="js-pipeline-url-latest badge badge-success"
- title="Latest pipeline for this branch"
+ title="__('Latest pipeline for this branch')"
>
latest
</span>
@@ -97,6 +97,14 @@ export default {
<span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning">
stuck
</span>
+ <span
+ v-if="pipeline.flags.merge_request"
+ v-gl-tooltip
+ title="__('This pipeline is run in a merge request context')"
+ class="js-pipeline-url-mergerequest badge badge-info"
+ >
+ merge request
+ </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index 6233fb169e9..9af5660f764 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -1,15 +1,13 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
-import Flash from '../../flash';
import store from '../stores';
-import collapsibleContainer from './collapsible_container.vue';
-import { errorMessages, errorMessagesTypes } from '../constants';
+import CollapsibleContainer from './collapsible_container.vue';
export default {
name: 'RegistryListApp',
components: {
- collapsibleContainer,
+ CollapsibleContainer,
GlLoadingIcon,
},
props: {
@@ -26,7 +24,7 @@ export default {
this.setMainEndpoint(this.endpoint);
},
mounted() {
- this.fetchRepos().catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS]));
+ this.fetchRepos();
},
methods: {
...mapActions(['setMainEndpoint', 'fetchRepos']),
@@ -38,9 +36,9 @@ export default {
<gl-loading-icon v-if="isLoading" :size="3" />
<collapsible-container
- v-for="(item, index) in repos"
+ v-for="item in repos"
v-else-if="!isLoading && repos.length"
- :key="index"
+ :key="item.id"
:repo="item"
/>
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index 6514c05a9c7..5451c61026c 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -1,22 +1,24 @@
<script>
import { mapActions } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
-import Flash from '../../flash';
-import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
-import tableRegistry from './table_registry.vue';
+import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import createFlash from '../../flash';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import Icon from '../../vue_shared/components/icon.vue';
+import TableRegistry from './table_registry.vue';
import { errorMessages, errorMessagesTypes } from '../constants';
import { __ } from '../../locale';
export default {
name: 'CollapsibeContainerRegisty',
components: {
- clipboardButton,
- tableRegistry,
+ ClipboardButton,
+ TableRegistry,
GlLoadingIcon,
+ GlButton,
+ Icon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
repo: {
@@ -29,30 +31,30 @@ export default {
isOpen: false,
};
},
+ computed: {
+ iconName() {
+ return this.isOpen ? 'angle-up' : 'angle-right';
+ },
+ },
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteRepo']),
-
toggleRepo() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
- this.fetchList({ repo: this.repo }).catch(() =>
- this.showError(errorMessagesTypes.FETCH_REGISTRY),
- );
+ this.fetchList({ repo: this.repo });
}
},
-
handleDeleteRepository() {
this.deleteRepo(this.repo)
.then(() => {
- Flash(__('This container registry has been scheduled for deletion.'), 'notice');
+ createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
})
.catch(() => this.showError(errorMessagesTypes.DELETE_REPO));
},
-
showError(message) {
- Flash(errorMessages[message]);
+ createFlash(errorMessages[message]);
},
},
};
@@ -61,18 +63,9 @@ export default {
<template>
<div class="container-image">
<div class="container-image-head">
- <button type="button" class="js-toggle-repo btn-link" @click="toggleRepo">
- <i
- :class="{
- 'fa-chevron-right': !isOpen,
- 'fa-chevron-up': isOpen,
- }"
- class="fa"
- aria-hidden="true"
- >
- </i>
- {{ repo.name }}
- </button>
+ <gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo">
+ <icon :name="iconName" /> {{ repo.name }}
+ </gl-button>
<clipboard-button
v-if="repo.location"
@@ -82,17 +75,17 @@ export default {
/>
<div class="controls d-none d-sm-block float-right">
- <button
+ <gl-button
v-if="repo.canDelete"
- v-tooltip
+ v-gl-tooltip
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
- type="button"
- class="js-remove-repo btn btn-danger"
+ class="js-remove-repo"
+ variant="danger"
@click="handleDeleteRepository"
>
- <i class="fa fa-trash" aria-hidden="true"> </i>
- </button>
+ <icon name="remove" />
+ </gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index 6735c3ff7cf..78c7671856a 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -1,21 +1,24 @@
<script>
import { mapActions } from 'vuex';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '../../locale';
-import Flash from '../../flash';
-import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import tablePagination from '../../vue_shared/components/table_pagination.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
+import createFlash from '../../flash';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import TablePagination from '../../vue_shared/components/table_pagination.vue';
+import Icon from '../../vue_shared/components/icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago';
import { errorMessages, errorMessagesTypes } from '../constants';
import { numberToHumanSize } from '../../lib/utils/number_utils';
export default {
components: {
- clipboardButton,
- tablePagination,
+ ClipboardButton,
+ TablePagination,
+ GlButton,
+ Icon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
@@ -31,29 +34,24 @@ export default {
},
methods: {
...mapActions(['fetchList', 'deleteRegistry']),
-
layers(item) {
return item.layers ? n__('%d layer', '%d layers', item.layers) : '';
},
-
formatSize(size) {
return numberToHumanSize(size);
},
-
handleDeleteRegistry(registry) {
this.deleteRegistry(registry)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
},
-
onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber }).catch(() =>
this.showError(errorMessagesTypes.FETCH_REGISTRY),
);
},
-
showError(message) {
- Flash(errorMessages[message]);
+ createFlash(errorMessages[message]);
},
},
};
@@ -71,10 +69,9 @@ export default {
</tr>
</thead>
<tbody>
- <tr v-for="(item, i) in repo.list" :key="i">
+ <tr v-for="item in repo.list" :key="item.tag">
<td>
{{ item.tag }}
-
<clipboard-button
v-if="item.location"
:title="item.location"
@@ -83,37 +80,34 @@ export default {
/>
</td>
<td>
- <span v-tooltip :title="item.revision" data-placement="bottom">
- {{ item.shortRevision }}
- </span>
+ <span v-gl-tooltip.bottom :title="item.revision">{{ item.shortRevision }}</span>
</td>
<td>
{{ formatSize(item.size) }}
- <template v-if="item.size && item.layers">
- &middot;
- </template>
+ <template v-if="item.size && item.layers"
+ >&middot;</template
+ >
{{ layers(item) }}
</td>
<td>
- <span v-tooltip :title="tooltipTitle(item.createdAt)" data-placement="bottom">
- {{ timeFormated(item.createdAt) }}
- </span>
+ <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
+ timeFormated(item.createdAt)
+ }}</span>
</td>
<td class="content">
- <button
+ <gl-button
v-if="item.canDelete"
- v-tooltip
+ v-gl-tooltip
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
- type="button"
- class="js-delete-registry btn btn-danger d-none d-sm-block float-right"
- data-container="body"
+ variant="danger"
+ class="js-delete-registry d-none d-sm-block float-right"
@click="handleDeleteRegistry(item);"
>
- <i class="fa fa-trash" aria-hidden="true"> </i>
- </button>
+ <icon name="remove" />
+ </gl-button>
</td>
</tr>
</tbody>
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
index a78aa90b7b5..51d057c62c1 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -1,39 +1,45 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
import * as types from './mutation_types';
-
-Vue.use(VueResource);
+import { errorMessages, errorMessagesTypes } from '../constants';
export const fetchRepos = ({ commit, state }) => {
commit(types.TOGGLE_MAIN_LOADING);
- return Vue.http
+ return axios
.get(state.endpoint)
- .then(res => res.json())
- .then(response => {
+ .then(({ data }) => {
+ commit(types.TOGGLE_MAIN_LOADING);
+ commit(types.SET_REPOS_LIST, data);
+ })
+ .catch(() => {
commit(types.TOGGLE_MAIN_LOADING);
- commit(types.SET_REPOS_LIST, response);
+ createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]);
});
};
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
- return Vue.http.get(repo.tagsPath, { params: { page } }).then(response => {
- const { headers } = response;
+ return axios
+ .get(repo.tagsPath, { params: { page } })
+ .then(response => {
+ const { headers, data } = response;
- return response.json().then(resp => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
- commit(types.SET_REGISTRY_LIST, { repo, resp, headers });
+ commit(types.SET_REGISTRY_LIST, { repo, resp: data, headers });
+ })
+ .catch(() => {
+ commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
+ createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]);
});
- });
};
// eslint-disable-next-line no-unused-vars
-export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath);
+export const deleteRepo = ({ commit }, repo) => axios.delete(repo.destroyPath);
// eslint-disable-next-line no-unused-vars
-export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath);
+export const deleteRegistry = ({ commit }, image) => axios.delete(image.destroyPath);
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/stores/index.js
index 78b67881210..1bb06bd6e81 100644
--- a/app/assets/javascripts/registry/stores/index.js
+++ b/app/assets/javascripts/registry/stores/index.js
@@ -3,36 +3,12 @@ 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 default new Vuex.Store({
- state: {
- isLoading: false,
- endpoint: '', // initial endpoint to fetch the repos list
- /**
- * Each object in `repos` has the following strucure:
- * {
- * name: String,
- * isLoading: Boolean,
- * tagsPath: String // endpoint to request the list
- * destroyPath: String // endpoit to delete the repo
- * list: Array // List of the registry images
- * }
- *
- * Each registry image inside `list` has the following structure:
- * {
- * tag: String,
- * revision: String
- * shortRevision: String
- * size: Number
- * layers: Number
- * createdAt: String
- * destroyPath: String // endpoit to delete each image
- * }
- */
- repos: [],
- },
+ state: createState(),
actions,
getters,
mutations,
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
index 69c051cd2d6..1ac699c538f 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -48,6 +48,7 @@ export default {
[types.TOGGLE_REGISTRY_LIST_LOADING](state, list) {
const listToUpdate = state.repos.find(el => el.id === list.id);
+
listToUpdate.isLoading = !listToUpdate.isLoading;
},
};
diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/stores/state.js
new file mode 100644
index 00000000000..feeac10cbe1
--- /dev/null
+++ b/app/assets/javascripts/registry/stores/state.js
@@ -0,0 +1,26 @@
+export default () => ({
+ isLoading: false,
+ endpoint: '', // initial endpoint to fetch the repos list
+ /**
+ * Each object in `repos` has the following strucure:
+ * {
+ * name: String,
+ * isLoading: Boolean,
+ * tagsPath: String // endpoint to request the list
+ * destroyPath: String // endpoit to delete the repo
+ * list: Array // List of the registry images
+ * }
+ *
+ * Each registry image inside `list` has the following structure:
+ * {
+ * tag: String,
+ * revision: String
+ * shortRevision: String
+ * size: Number
+ * layers: Number
+ * createdAt: String
+ * destroyPath: String // endpoit to delete each image
+ * }
+ */
+ repos: [],
+});
diff --git a/app/assets/javascripts/releases/components/app.vue b/app/assets/javascripts/releases/components/app.vue
new file mode 100644
index 00000000000..0ad5ee2915c
--- /dev/null
+++ b/app/assets/javascripts/releases/components/app.vue
@@ -0,0 +1,82 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
+import ReleaseBlock from './release_block.vue';
+
+export default {
+ name: 'ReleasesApp',
+ components: {
+ GlLoadingIcon,
+ GlEmptyState,
+ ReleaseBlock,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ documentationLink: {
+ type: String,
+ required: true,
+ },
+ illustrationPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['isLoading', 'releases', 'hasError']),
+ shouldRenderEmptyState() {
+ return !this.releases.length && !this.hasError && !this.isLoading;
+ },
+ shouldRenderSuccessState() {
+ return this.releases.length && !this.isLoading && !this.hasError;
+ },
+ },
+ created() {
+ this.fetchReleases(this.projectId);
+ },
+ methods: {
+ ...mapActions(['fetchReleases']),
+ },
+};
+</script>
+<template>
+ <div class="prepend-top-default">
+ <gl-loading-icon v-if="isLoading" :size="2" class="js-loading prepend-top-20" />
+
+ <gl-empty-state
+ v-else-if="shouldRenderEmptyState"
+ class="js-empty-state"
+ :title="__('Getting started with releases')"
+ :svg-path="illustrationPath"
+ :description="
+ __(
+ 'Releases mark specific points in a project\'s development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API.',
+ )
+ "
+ :primary-button-link="documentationLink"
+ :primary-button-text="__('Open Documentation')"
+ />
+
+ <div v-else-if="shouldRenderSuccessState" class="js-success-state">
+ <release-block
+ v-for="(release, index) in releases"
+ :key="release.tag_name"
+ :release="release"
+ :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
+ />
+ </div>
+ </div>
+</template>
+<style>
+.linked-card::after {
+ width: 1px;
+ content: ' ';
+ border: 1px solid #e5e5e5;
+ height: 17px;
+ top: 100%;
+ position: absolute;
+ left: 32px;
+}
+</style>
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
new file mode 100644
index 00000000000..9c2aade51fc
--- /dev/null
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { sprintf } from '../../locale';
+
+export default {
+ name: 'ReleaseBlock',
+ components: {
+ GlLink,
+ Icon,
+ UserAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ release: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ releasedTimeAgo() {
+ return sprintf('released %{time}', {
+ time: this.timeFormated(this.release.created_at),
+ });
+ },
+ userImageAltDescription() {
+ return this.commit.author && this.commit.author.username
+ ? sprintf("%{username}'s avatar", { username: this.commit.author.username })
+ : null;
+ },
+ commit() {
+ return this.release.commit || {};
+ },
+ assets() {
+ return this.release.assets || {};
+ },
+ },
+};
+</script>
+<template>
+ <div class="card">
+ <div class="card-body">
+ <h2 class="card-title mt-0">{{ release.name }}</h2>
+
+ <div class="card-subtitle d-flex flex-wrap text-secondary">
+ <div class="append-right-8">
+ <icon name="commit" class="align-middle" />
+ <span v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
+ </div>
+
+ <div class="append-right-8">
+ <icon name="tag" class="align-middle" />
+ <span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
+ </div>
+
+ <div class="append-right-4">
+ &bull;
+ <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)">{{
+ releasedTimeAgo
+ }}</span>
+ </div>
+
+ <div v-if="commit.author" class="d-flex">
+ by
+ <user-avatar-link
+ class="prepend-left-4"
+ :link-href="commit.author.path"
+ :img-src="commit.author.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="commit.author.username"
+ />
+ </div>
+ </div>
+
+ <div
+ v-if="assets.links.length || assets.sources.length"
+ Sclass="card-text prepend-top-default"
+ >
+ <b>
+ {{ __('Assets') }}
+ <span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
+ </b>
+
+ <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
+ <li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
+ <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url">
+ <icon name="package" class="align-middle append-right-4 align-text-bottom" />
+ {{ link.name }}
+ </gl-link>
+ </li>
+ </ul>
+
+ <div v-if="assets.sources.length" class="dropdown">
+ <button
+ type="button"
+ class="btn btn-link"
+ data-toggle="dropdown"
+ aria-haspopup="true"
+ aria-expanded="false"
+ >
+ <icon name="doc-code" class="align-top append-right-4" /> {{ __('Source code') }}
+ <icon name="arrow-down" />
+ </button>
+
+ <div class="js-sources-dropdown dropdown-menu">
+ <li v-for="asset in assets.sources" :key="asset.url">
+ <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
+ </li>
+ </div>
+ </div>
+ </div>
+
+ <div class="card-text prepend-top-default"><div v-html="release.description_html"></div></div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/index.js b/app/assets/javascripts/releases/index.js
new file mode 100644
index 00000000000..6fa7298ac5a
--- /dev/null
+++ b/app/assets/javascripts/releases/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import App from './components/app.vue';
+import createStore from './store';
+
+export default () => {
+ const element = document.getElementById('js-releases-page');
+
+ return new Vue({
+ el: element,
+ store: createStore(),
+ components: {
+ App,
+ },
+ render(createElement) {
+ return createElement('app', {
+ props: {
+ endpoint: element.dataset.endpoint,
+ documentationLink: element.dataset.documentationPath,
+ illustrationPath: element.dataset.illustrationPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/store/actions.js
new file mode 100644
index 00000000000..baa2251403e
--- /dev/null
+++ b/app/assets/javascripts/releases/store/actions.js
@@ -0,0 +1,37 @@
+import * as types from './mutation_types';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import api from '~/api';
+
+/**
+ * Commits a mutation to update the state while the main endpoint is being requested.
+ */
+export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
+
+/**
+ * Fetches the main endpoint.
+ * Will dispatch requestNamespace action before starting the request.
+ * Will dispatch receiveNamespaceSuccess if the request is successfull
+ * Will dispatch receiveNamesapceError if the request returns an error
+ *
+ * @param {String} projectId
+ */
+export const fetchReleases = ({ dispatch }, projectId) => {
+ dispatch('requestReleases');
+
+ api
+ .releases(projectId)
+ .then(({ data }) => dispatch('receiveReleasesSuccess', data))
+ .catch(() => dispatch('receiveReleasesError'));
+};
+
+export const receiveReleasesSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_RELEASES_SUCCESS, data);
+
+export const receiveReleasesError = ({ commit }) => {
+ commit(types.RECEIVE_RELEASES_ERROR);
+ createFlash(__('An error occured while fetching the releases. Please try again.'));
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/releases/store/index.js b/app/assets/javascripts/releases/store/index.js
new file mode 100644
index 00000000000..968b94f0e0d
--- /dev/null
+++ b/app/assets/javascripts/releases/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: state(),
+ });
diff --git a/app/assets/javascripts/releases/store/mutation_types.js b/app/assets/javascripts/releases/store/mutation_types.js
new file mode 100644
index 00000000000..a74bf15c515
--- /dev/null
+++ b/app/assets/javascripts/releases/store/mutation_types.js
@@ -0,0 +1,3 @@
+export const REQUEST_RELEASES = 'REQUEST_RELEASES';
+export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
+export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';
diff --git a/app/assets/javascripts/releases/store/mutations.js b/app/assets/javascripts/releases/store/mutations.js
new file mode 100644
index 00000000000..b97dc6cb0ab
--- /dev/null
+++ b/app/assets/javascripts/releases/store/mutations.js
@@ -0,0 +1,37 @@
+import * as types from './mutation_types';
+
+export default {
+ /**
+ * Sets isLoading to true while the request is being made.
+ * @param {Object} state
+ */
+ [types.REQUEST_RELEASES](state) {
+ state.isLoading = true;
+ },
+
+ /**
+ * Sets isLoading to false.
+ * Sets hasError to false.
+ * Sets the received data
+ * @param {Object} state
+ * @param {Object} data
+ */
+ [types.RECEIVE_RELEASES_SUCCESS](state, data) {
+ state.hasError = false;
+ state.isLoading = false;
+ state.releases = data;
+ },
+
+ /**
+ * Sets isLoading to false.
+ * Sets hasError to true.
+ * Resets the data
+ * @param {Object} state
+ * @param {Object} data
+ */
+ [types.RECEIVE_RELEASES_ERROR](state) {
+ state.isLoading = false;
+ state.releases = [];
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/releases/store/state.js b/app/assets/javascripts/releases/store/state.js
new file mode 100644
index 00000000000..bf25e651c99
--- /dev/null
+++ b/app/assets/javascripts/releases/store/state.js
@@ -0,0 +1,5 @@
+export default () => ({
+ isLoading: false,
+ hasError: false,
+ releases: [],
+});
diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue
new file mode 100644
index 00000000000..2683805f2f7
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/empty_state.vue
@@ -0,0 +1,40 @@
+<script>
+export default {
+ props: {
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row empty-state js-empty-state">
+ <div class="col-12">
+ <div class="text-content">
+ <h4 class="state-title text-center">
+ {{ s__('Serverless|Getting started with serverless') }}
+ </h4>
+ <p class="state-description">
+ {{
+ s__(`Serverless| In order to start using functions as a service,
+ you must first install Knative on your Kubernetes cluster.`)
+ }}
+
+ <a :href="helpPath"> {{ __('More information') }} </a>
+ </p>
+
+ <div class="text-center">
+ <a :href="clustersPath" class="btn btn-success">
+ {{ s__('Serverless|Install Knative') }}
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
new file mode 100644
index 00000000000..31f5427c771
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -0,0 +1,40 @@
+<script>
+import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ Timeago,
+ },
+ props: {
+ func: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ name() {
+ return this.func.name;
+ },
+ url() {
+ return this.func.url;
+ },
+ image() {
+ return this.func.image;
+ },
+ timestamp() {
+ return this.func.created_at;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-responsive-table-row">
+ <div class="table-section section-20">{{ name }}</div>
+ <div class="table-section section-50">
+ <a :href="url">{{ url }}</a>
+ </div>
+ <div class="table-section section-20">{{ image }}</div>
+ <div class="table-section section-10"><timeago :time="timestamp" /></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
new file mode 100644
index 00000000000..7874a7b6b6a
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -0,0 +1,123 @@
+<script>
+import { GlSkeletonLoading } from '@gitlab/ui';
+import FunctionRow from './function_row.vue';
+import EmptyState from './empty_state.vue';
+
+export default {
+ components: {
+ FunctionRow,
+ EmptyState,
+ GlSkeletonLoading,
+ },
+ props: {
+ functions: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ installed: {
+ type: Boolean,
+ required: true,
+ },
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ loadingData: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ hasFunctionData: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <section id="serverless-functions">
+ <div v-if="installed">
+ <div v-if="hasFunctionData">
+ <div class="ci-table js-services-list function-element">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-20" role="rowheader">
+ {{ s__('Serverless|Function') }}
+ </div>
+ <div class="table-section section-50" role="rowheader">
+ {{ s__('Serverless|Domain') }}
+ </div>
+ <div class="table-section section-20" role="rowheader">
+ {{ s__('Serverless|Runtime') }}
+ </div>
+ <div class="table-section section-10" role="rowheader">
+ {{ s__('Serverless|Last Update') }}
+ </div>
+ </div>
+ <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>
+ <function-row v-for="f in functions" :key="f.name" :func="f" />
+ </template>
+ </div>
+ </div>
+ <div v-else class="empty-state js-empty-state">
+ <div class="text-content">
+ <h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4>
+ <p class="state-description">
+ {{
+ s__(`Serverless|There is currently no function data available from Knative.
+ This could be for a variety of reasons including:`)
+ }}
+ </p>
+ <ul>
+ <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li>
+ <li>Your <code>gitlab-ci.yml</code> file is not properly configured.</li>
+ <li>
+ The functions listed in the <code>serverless.yml</code> file don't match the namespace
+ of your cluster.
+ </li>
+ <li>The deploy job has not finished.</li>
+ </ul>
+
+ <p>
+ {{
+ s__(`Serverless|If you believe none of these apply, please check
+ back later as the function data may be in the process of becoming
+ available.`)
+ }}
+ </p>
+ <div class="text-center">
+ <a :href="helpPath" class="btn btn-success">
+ {{ s__('Serverless|Learn more about Serverless') }}
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" />
+ </section>
+</template>
+
+<style>
+.top-area {
+ border-bottom: 0;
+}
+
+.function-element {
+ border-bottom: 1px solid #e5e5e5;
+ border-bottom-color: rgb(229, 229, 229);
+ border-bottom-style: solid;
+ border-bottom-width: 1px;
+}
+</style>
diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/serverless/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js
new file mode 100644
index 00000000000..3e3b81ba247
--- /dev/null
+++ b/app/assets/javascripts/serverless/serverless_bundle.js
@@ -0,0 +1,106 @@
+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 GetFunctionsService from './services/get_functions_service';
+import Functions from './components/functions.vue';
+
+export default class Serverless {
+ constructor() {
+ const { statusPath, clustersPath, helpPath, installed } = 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: () => this.handleError(),
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
+ } else {
+ this.service
+ .fetchData()
+ .then(data => this.handleSuccess(data))
+ .catch(() => this.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();
+ }
+ }
+ }
+
+ 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();
+ }
+}
diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js
new file mode 100644
index 00000000000..303b42dc66c
--- /dev/null
+++ b/app/assets/javascripts/serverless/services/get_functions_service.js
@@ -0,0 +1,11 @@
+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/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
new file mode 100644
index 00000000000..774c15b5b12
--- /dev/null
+++ b/app/assets/javascripts/serverless/stores/serverless_store.js
@@ -0,0 +1,24 @@
+export default class ServerlessStore {
+ constructor(knativeInstalled = false, clustersPath, helpPath) {
+ this.state = {
+ functions: [],
+ hasFunctionData: true,
+ loadingData: true,
+ installed: knativeInstalled,
+ clustersPath,
+ helpPath,
+ };
+ }
+
+ updateFunctionsFromServer(functions = []) {
+ this.state.functions = functions;
+ }
+
+ updateLoadingState(loadingData) {
+ this.state.loadingData = loadingData;
+ }
+
+ toggleNoFunctionData() {
+ this.state.hasFunctionData = false;
+ }
+}
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index 007b83e1927..7404dfbf22a 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -5,11 +5,12 @@ import { spriteIcon } from './lib/utils/common_utils';
import axios from './lib/utils/axios_utils';
export default class Star {
- constructor() {
- $('.project-home-panel .toggle-star').on('click', function toggleStarClickCallback() {
+ constructor(container = '.project-home-panel') {
+ $(`${container} .toggle-star`).on('click', function toggleStarClickCallback() {
const $this = $(this);
const $starSpan = $this.find('span');
- const $startIcon = $this.find('svg');
+ const $starIcon = $this.find('svg');
+ const iconClasses = $starIcon.attr('class').split(' ');
axios
.post($this.data('endpoint'))
@@ -22,12 +23,12 @@ export default class Star {
if (isStarred) {
$starSpan.removeClass('starred').text(s__('StarProject|Star'));
- $startIcon.remove();
- $this.prepend(spriteIcon('star-o'));
+ $starIcon.remove();
+ $this.prepend(spriteIcon('star-o', iconClasses));
} else {
$starSpan.addClass('starred').text(__('Unstar'));
- $startIcon.remove();
- $this.prepend(spriteIcon('star'));
+ $starIcon.remove();
+ $this.prepend(spriteIcon('star', iconClasses));
}
})
.catch(() => Flash('Star toggle failed. Try again later.'));
diff --git a/app/assets/javascripts/terminal/index.js b/app/assets/javascripts/terminal/index.js
index 49aeb377c74..8faff59fd45 100644
--- a/app/assets/javascripts/terminal/index.js
+++ b/app/assets/javascripts/terminal/index.js
@@ -1,3 +1,3 @@
import Terminal from './terminal';
-export default () => new Terminal({ selector: '#terminal' });
+export default () => new Terminal(document.getElementById('terminal'));
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index b24aa8a3a34..560f50ebf8f 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -1,9 +1,15 @@
+import _ from 'underscore';
import $ from 'jquery';
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
+import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils';
+
+const SCROLL_MARGIN = 5;
+
+Terminal.applyAddon(fit);
export default class GLTerminal {
- constructor(options = {}) {
+ constructor(element, options = {}) {
this.options = Object.assign(
{},
{
@@ -13,7 +19,8 @@ export default class GLTerminal {
options,
);
- this.container = document.querySelector(options.selector);
+ this.container = element;
+ this.onDispose = [];
this.setSocketUrl();
this.createTerminal();
@@ -34,8 +41,6 @@ export default class GLTerminal {
}
createTerminal() {
- Terminal.applyAddon(fit);
-
this.terminal = new Terminal(this.options);
this.socket = new WebSocket(this.socketUrl, ['terminal.gitlab.com']);
@@ -72,4 +77,48 @@ export default class GLTerminal {
handleSocketFailure() {
this.terminal.write('\r\nConnection failure');
}
+
+ addScrollListener(onScrollLimit) {
+ const viewport = this.container.querySelector('.xterm-viewport');
+ const listener = _.throttle(() => {
+ onScrollLimit({
+ canScrollUp: canScrollUp(viewport, SCROLL_MARGIN),
+ canScrollDown: canScrollDown(viewport, SCROLL_MARGIN),
+ });
+ });
+
+ this.onDispose.push(() => viewport.removeEventListener('scroll', listener));
+ viewport.addEventListener('scroll', listener);
+
+ // don't forget to initialize value before scroll!
+ listener({ target: viewport });
+ }
+
+ disable() {
+ this.terminal.setOption('cursorBlink', false);
+ this.terminal.setOption('theme', { foreground: '#707070' });
+ this.terminal.setOption('disableStdin', true);
+ this.socket.close();
+ }
+
+ dispose() {
+ this.terminal.off('data');
+ this.terminal.dispose();
+ this.socket.close();
+
+ this.onDispose.forEach(fn => fn());
+ this.onDispose.length = 0;
+ }
+
+ scrollToTop() {
+ this.terminal.scrollToTop();
+ }
+
+ scrollToBottom() {
+ this.terminal.scrollToBottom();
+ }
+
+ fit() {
+ this.terminal.fit();
+ }
}
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
new file mode 100644
index 00000000000..948f4d5e631
--- /dev/null
+++ b/app/assets/javascripts/user_popovers.js
@@ -0,0 +1,107 @@
+import Vue from 'vue';
+
+import UsersCache from './lib/utils/users_cache';
+import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
+
+let renderedPopover;
+let renderFn;
+
+const handleUserPopoverMouseOut = event => {
+ const { target } = event;
+ target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ if (renderFn) {
+ clearTimeout(renderFn);
+ }
+ if (renderedPopover) {
+ renderedPopover.$destroy();
+ renderedPopover = null;
+ }
+};
+
+/**
+ * Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes.
+ * loads based on data-user-id more data about a user from the API and sets it on the popover
+ */
+const handleUserPopoverMouseOver = event => {
+ const { target } = event;
+ // Add listener to actually remove it again
+ target.addEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ renderFn = setTimeout(() => {
+ // Helps us to use current markdown setup without maybe breaking or duplicating for now
+ if (target.dataset.user) {
+ target.dataset.userId = target.dataset.user;
+ // Removing titles so its not showing tooltips also
+ target.dataset.originalTitle = '';
+ target.setAttribute('title', '');
+ }
+
+ const { userId, username, name, avatarUrl } = target.dataset;
+ const user = {
+ userId,
+ username,
+ name,
+ avatarUrl,
+ location: null,
+ bio: null,
+ organization: null,
+ status: null,
+ loaded: false,
+ };
+ if (userId || username) {
+ const UserPopoverComponent = Vue.extend(UserPopover);
+ renderedPopover = new UserPopoverComponent({
+ propsData: {
+ target,
+ user,
+ },
+ });
+
+ renderedPopover.$mount();
+
+ UsersCache.retrieveById(userId)
+ .then(userData => {
+ if (!userData) {
+ return;
+ }
+
+ Object.assign(user, {
+ avatarUrl: userData.avatar_url,
+ username: userData.username,
+ name: userData.name,
+ location: userData.location,
+ bio: userData.bio,
+ organization: userData.organization,
+ loaded: true,
+ });
+
+ UsersCache.retrieveStatusById(userId)
+ .then(status => {
+ if (!status) {
+ return;
+ }
+
+ Object.assign(user, {
+ status,
+ });
+ })
+ .catch(() => {
+ throw new Error(`User status for "${userId}" could not be retrieved!`);
+ });
+ })
+ .catch(() => {
+ renderedPopover.$destroy();
+ renderedPopover = null;
+ });
+ }
+ }, 200); // 200ms delay so not every mouseover triggers Popover + API Call
+};
+
+export default elements => {
+ const userLinks = elements || [...document.querySelectorAll('.js-user-link')];
+
+ userLinks.forEach(el => {
+ el.addEventListener('mouseenter', handleUserPopoverMouseOver);
+ });
+};
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 950347d8863..2f2a37347af 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -112,7 +112,7 @@ export default {
</script>
<template>
- <div class="mr-widget-heading deploy-heading append-bottom-default">
+ <div class="deploy-heading">
<div class="ci-widget media">
<div class="media-body">
<div class="deploy-body">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue
new file mode 100644
index 00000000000..5967ca026e5
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue
@@ -0,0 +1,6 @@
+<template>
+ <div class="mr-widget-heading">
+ <div class="mr-widget-content"><slot name="default"></slot></div>
+ <slot name="footer"></slot>
+ </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 6f422ea3f27..3b9fc2661ef 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
@@ -6,6 +6,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import MrWidgetIcon from './mr_widget_icon.vue';
export default {
name: 'MRWidgetHeader',
@@ -13,6 +14,7 @@ export default {
Icon,
clipboardButton,
TooltipOnTruncate,
+ MrWidgetIcon,
},
directives: {
tooltip,
@@ -76,7 +78,7 @@ export default {
</script>
<template>
<div class="mr-source-target append-bottom-default">
- <div class="git-merge-icon-container append-right-default"><icon name="git-merge" /></div>
+ <mr-widget-icon name="git-merge" />
<div class="git-merge-container d-flex">
<div class="normal">
<strong>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
new file mode 100644
index 00000000000..4b57693e8f1
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
@@ -0,0 +1,19 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: { Icon },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="circle-icon-container append-right-default align-self-start align-self-lg-center">
+ <icon :name="name" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 71571ba9cab..f11cf21b0ca 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
@@ -79,67 +79,65 @@ export default {
</script>
<template>
- <div v-if="hasPipeline || hasCIError" class="mr-widget-heading append-bottom-default">
- <div class="ci-widget media">
- <template v-if="hasCIError">
- <div
- 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>
- <div class="media-body" v-html="errorText"></div>
- </template>
- <template v-else-if="hasPipeline">
- <a :href="status.details_path" class="align-self-start append-right-default">
- <ci-icon :status="status" :size="32" :borderless="true" class="add-border" />
- </a>
- <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 v-if="hasPipeline || hasCIError" class="ci-widget media">
+ <template v-if="hasCIError">
+ <div
+ 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>
+ <div class="media-body" v-html="errorText"></div>
+ </template>
+ <template v-else-if="hasPipeline">
+ <a :href="status.details_path" class="align-self-start append-right-default">
+ <ci-icon :status="status" :size="32" :borderless="true" class="add-border" />
+ </a>
+ <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
+ >
- {{ pipeline.details.status.label }}
+ {{ pipeline.details.status.label }}
- <template v-if="hasCommitInfo">
- for
- <a
- :href="pipeline.commit.commit_path"
- class="commit-sha js-commit-link font-weight-normal"
- >
- {{ pipeline.commit.short_id }}</a
- >
- on
- <tooltip-on-truncate
- :title="sourceBranch"
- truncate-target="child"
- class="label-branch label-truncate"
- v-html="sourceBranchLink"
- />
- </template>
- </div>
- <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div>
+ <template v-if="hasCommitInfo">
+ for
+ <a
+ :href="pipeline.commit.commit_path"
+ class="commit-sha js-commit-link font-weight-normal"
+ >
+ {{ pipeline.commit.short_id }}</a
+ >
+ on
+ <tooltip-on-truncate
+ :title="sourceBranch"
+ truncate-target="child"
+ class="label-branch label-truncate"
+ v-html="sourceBranchLink"
+ />
+ </template>
</div>
+ <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</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>
+ </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>
- </div>
+ </span>
</div>
- </template>
- </div>
+ </div>
+ </template>
</div>
</template>
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
new file mode 100644
index 00000000000..5f5fe67b3c1
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -0,0 +1,74 @@
+<script>
+import Deployment from './deployment.vue';
+import MrWidgetContainer from './mr_widget_container.vue';
+import MrWidgetPipeline from './mr_widget_pipeline.vue';
+
+/**
+ * Renders the pipeline and related deployments from the store.
+ *
+ * | Props | Description
+ * |---------------|-------------
+ * | `mr` | This is the mr_widget store
+ * | `isPostMerge` | If true, show the "post merge" pipeline and deployments
+ */
+export default {
+ name: 'MrWidgetPipelineContainer',
+ components: {
+ Deployment,
+ MrWidgetContainer,
+ MrWidgetPipeline,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ isPostMerge: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ pipeline() {
+ return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
+ },
+ branch() {
+ return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranch;
+ },
+ branchLink() {
+ return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranchLink;
+ },
+ deployments() {
+ return this.isPostMerge ? this.mr.postMergeDeployments : this.mr.deployments;
+ },
+ deploymentClass() {
+ return this.isPostMerge ? 'js-post-deployment' : 'js-pre-deployment';
+ },
+ hasDeploymentMetrics() {
+ return this.isPostMerge;
+ },
+ },
+};
+</script>
+<template>
+ <mr-widget-container>
+ <mr-widget-pipeline
+ :pipeline="pipeline"
+ :ci-status="mr.ciStatus"
+ :has-ci="mr.hasCI"
+ :source-branch="branch"
+ :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>
+ </mr-widget-container>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index adfbcd18588..0bcccc50eb2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -72,7 +72,7 @@ export default {
Flash('Something went wrong. Please try again.');
}
- eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('MRWidgetRebaseSuccess');
stopPolling();
}
})
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 a269c0a4e87..d8a75388e84 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
@@ -6,7 +6,7 @@ import SmartInterval from '~/smart_interval';
import createFlash from '../flash';
import WidgetHeader from './components/mr_widget_header.vue';
import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
-import WidgetPipeline from './components/mr_widget_pipeline.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 MergedState from './components/states/mr_widget_merged.vue';
@@ -44,7 +44,7 @@ export default {
components: {
'mr-widget-header': WidgetHeader,
'mr-widget-merge-help': WidgetMergeHelp,
- 'mr-widget-pipeline': WidgetPipeline,
+ MrWidgetPipelineContainer,
Deployment,
'mr-widget-related-links': WidgetRelatedLinks,
'mr-widget-merged': MergedState,
@@ -155,13 +155,13 @@ export default {
};
return new MRWidgetService(endpoints);
},
- checkStatus(cb) {
+ checkStatus(cb, isRebased) {
return this.service
.checkStatus()
.then(res => res.data)
.then(data => {
this.handleNotification(data);
- this.mr.setData(data);
+ this.mr.setData(data, isRebased);
this.setFaviconHelper();
if (cb) {
@@ -263,6 +263,10 @@ export default {
this.checkStatus(cb);
});
+ eventHub.$on('MRWidgetRebaseSuccess', cb => {
+ this.checkStatus(cb, true);
+ });
+
// `params` should be an Array contains a Boolean, like `[true]`
// Passing parameter as Boolean didn't work.
eventHub.$on('SetBranchRemoveFlag', params => {
@@ -296,23 +300,12 @@ export default {
<template>
<div class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
- <mr-widget-pipeline
+ <mr-widget-pipeline-container
v-if="shouldRenderPipelines"
- :pipeline="mr.pipeline"
- :ci-status="mr.ciStatus"
- :has-ci="mr.hasCI"
- :source-branch="mr.sourceBranch"
- :source-branch-link="mr.sourceBranchLink"
- :troubleshooting-docs-path="mr.troubleshootingDocsPath"
+ class="mr-widget-workflow"
+ :mr="mr"
/>
- <deployment
- v-for="deployment in mr.deployments"
- :key="`pre-merge-deploy-${deployment.id}`"
- class="js-pre-merge-deploy"
- :deployment="deployment"
- :show-metrics="false"
- />
- <div class="mr-section-container">
+ <div class="mr-section-container mr-widget-workflow">
<grouped-test-reports-app
v-if="mr.testResultsPath"
class="js-reports-container"
@@ -336,24 +329,11 @@ export default {
</div>
<div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div>
</div>
-
- <template v-if="shouldRenderMergedPipeline">
- <mr-widget-pipeline
- class="js-post-merge-pipeline prepend-top-default"
- :pipeline="mr.mergePipeline"
- :ci-status="mr.ciStatus"
- :has-ci="mr.hasCI"
- :source-branch="mr.targetBranch"
- :source-branch-link="mr.targetBranch"
- :troubleshooting-docs-path="mr.troubleshootingDocsPath"
- />
- <deployment
- v-for="postMergeDeployment in mr.postMergeDeployments"
- :key="`post-merge-deploy-${postMergeDeployment.id}`"
- :deployment="postMergeDeployment"
- :show-metrics="true"
- class="js-post-deployment"
- />
- </template>
+ <mr-widget-pipeline-container
+ v-if="shouldRenderMergedPipeline"
+ class="js-post-merge-pipeline mr-widget-workflow"
+ :mr="mr"
+ :is-post-merge="true"
+ />
</div>
</template>
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 f7f0c1b6cb7..066a3b833d7 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
@@ -19,7 +19,7 @@ export default function deviseState(data) {
return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {
return stateKey.pipelineBlocked;
- } else if (this.hasSHAChanged) {
+ } else if (this.isSHAMismatch) {
return stateKey.shaMismatch;
} else if (this.mergeWhenPipelineSucceeds) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
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 5c9a7133a6e..c777bcca0fa 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
@@ -11,7 +11,11 @@ export default class MergeRequestStore {
this.setData(data);
}
- setData(data) {
+ setData(data, isRebased) {
+ if (isRebased) {
+ this.sha = data.diff_head_sha;
+ }
+
const currentUser = data.current_user;
const pipelineStatus = data.pipeline ? data.pipeline.details.status : null;
@@ -84,7 +88,7 @@ export default class MergeRequestStore {
this.canMerge = !!data.merge_path;
this.canCreateIssue = currentUser.can_create_issue || false;
this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
- this.hasSHAChanged = this.sha !== data.diff_head_sha;
+ this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
index bb2e0e12c11..75c66ed850b 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
@@ -1,7 +1,10 @@
<script>
+import { diffModes } from '~/ide/constants';
import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils';
import ImageDiffViewer from './viewers/image_diff_viewer.vue';
import DownloadDiffViewer from './viewers/download_diff_viewer.vue';
+import RenamedFile from './viewers/renamed.vue';
+import ModeChanged from './viewers/mode_changed.vue';
export default {
props: {
@@ -30,9 +33,25 @@ export default {
required: false,
default: '',
},
+ aMode: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ bMode: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
viewer() {
+ if (this.diffMode === diffModes.renamed) {
+ return RenamedFile;
+ } else if (this.diffMode === diffModes.mode_changed) {
+ return ModeChanged;
+ }
+
if (!this.newPath) return null;
const previewInfo = viewerInformationForPath(this.newPath);
@@ -67,8 +86,10 @@ export default {
:new-path="fullNewPath"
:old-path="fullOldPath"
:project-path="projectPath"
+ :a-mode="aMode"
+ :b-mode="bMode"
>
- <slot slot="image-overlay" name="image-overlay"> </slot>
+ <slot slot="image-overlay" name="image-overlay"></slot>
</component>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue
new file mode 100644
index 00000000000..53210cbcc93
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue
@@ -0,0 +1,3 @@
+<template>
+ <div class="nothing-here-block">{{ __('Empty file') }}</div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue
new file mode 100644
index 00000000000..3c7a4ea6183
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue
@@ -0,0 +1,30 @@
+<script>
+import { sprintf, __ } from '~/locale';
+
+export default {
+ props: {
+ aMode: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ bMode: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ outputText() {
+ return sprintf(__('File mode changed from %{a_mode} to %{b_mode}'), {
+ a_mode: this.aMode,
+ b_mode: this.bMode,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="nothing-here-block">{{ outputText }}</div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
new file mode 100644
index 00000000000..5c1ea59b471
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -0,0 +1,3 @@
+<template>
+ <div class="nothing-here-block">{{ __('File moved') }}</div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
new file mode 100644
index 00000000000..7e79e63aa1e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -0,0 +1,94 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ components: {
+ UserAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ assignees: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ maxVisibleAssignees: 2,
+ maxAssigneeAvatars: 3,
+ maxAssignees: 99,
+ };
+ },
+ computed: {
+ countOverLimit() {
+ return this.assignees.length - this.maxVisibleAssignees;
+ },
+ assigneesToShow() {
+ if (this.assignees.length > this.maxAssigneeAvatars) {
+ return this.assignees.slice(0, this.maxVisibleAssignees);
+ }
+ return this.assignees;
+ },
+ assigneesCounterTooltip() {
+ const { countOverLimit, maxAssignees } = this;
+ const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
+
+ return sprintf(__('%{count} more assignees'), { count });
+ },
+ shouldRenderAssigneesCounter() {
+ const assigneesCount = this.assignees.length;
+ if (assigneesCount <= this.maxAssigneeAvatars) {
+ return false;
+ }
+
+ return assigneesCount > this.countOverLimit;
+ },
+ assigneeCounterLabel() {
+ if (this.countOverLimit > this.maxAssignees) {
+ return `${this.maxAssignees}+`;
+ }
+
+ return `+${this.countOverLimit}`;
+ },
+ },
+ methods: {
+ avatarUrlTitle(assignee) {
+ return sprintf(__('Avatar for %{assigneeName}'), {
+ assigneeName: assignee.name,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="issue-assignees">
+ <user-avatar-link
+ v-for="assignee in assigneesToShow"
+ :key="assignee.id"
+ :link-href="assignee.web_url"
+ :img-alt="avatarUrlTitle(assignee)"
+ :img-src="assignee.avatar_url"
+ :img-size="24"
+ class="js-no-trigger"
+ tooltip-placement="bottom"
+ >
+ <span class="js-assignee-tooltip">
+ <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
+ <span class="text-white-50">@{{ assignee.username }}</span>
+ </span>
+ </user-avatar-link>
+ <span
+ v-if="shouldRenderAssigneesCounter"
+ v-gl-tooltip
+ :title="assigneesCounterTooltip"
+ class="avatar-counter"
+ data-placement="bottom"
+ >{{ assigneeCounterLabel }}</span
+ >
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
new file mode 100644
index 00000000000..d5d967e25bf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlTooltip } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlTooltip,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ milestone: {
+ type: Object,
+ 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: {
+ isMilestoneStarted() {
+ if (!this.milestoneStart) {
+ return false;
+ }
+ return Date.now() > this.milestoneStart;
+ },
+ isMilestonePastDue() {
+ if (!this.milestoneDue) {
+ return false;
+ }
+ return Date.now() > this.milestoneDue;
+ },
+ milestoneDatesAbsolute() {
+ if (this.milestoneDue) {
+ return `(${dateInWords(this.milestoneDue)})`;
+ } else if (this.milestoneStart) {
+ return `(${dateInWords(this.milestoneStart)})`;
+ }
+ return '';
+ },
+ milestoneDatesHuman() {
+ if (this.milestoneStart || this.milestoneDue) {
+ if (this.milestoneDue) {
+ return timeFor(
+ this.milestoneDue,
+ sprintf(__('Expired %{expiredOn}'), {
+ expiredOn: this.timeFormated(this.milestoneDue),
+ }),
+ );
+ }
+
+ return sprintf(
+ this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'),
+ {
+ startsIn: this.timeFormated(this.milestoneStart),
+ },
+ );
+ }
+ return '';
+ },
+ },
+};
+</script>
+<template>
+ <div ref="milestoneDetails" class="issue-milestone-details">
+ <icon :size="16" class="inline icon" name="clock" />
+ <span class="milestone-title">{{ 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 />
+ <span
+ v-if="milestoneStart || milestoneDue"
+ :class="{
+ 'text-danger-muted': isMilestonePastDue,
+ 'text-tertiary': !isMilestonePastDue,
+ }"
+ ><span>{{ milestoneDatesHuman }}</span
+ ><br /><span>{{ milestoneDatesAbsolute }}</span>
+ </span>
+ </gl-tooltip>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 21d6519191f..2f7ed4a982c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,17 +1,21 @@
<script>
import $ from 'jquery';
-import { s__ } from '~/locale';
+import _ from 'underscore';
+import { __ } from '~/locale';
+import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
import icon from '../icon.vue';
+import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
markdownHeader,
markdownToolbar,
icon,
+ Suggestions,
},
props: {
markdownPreviewPath: {
@@ -48,12 +52,33 @@ export default {
required: false,
default: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ note: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ canSuggest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
+ hasSuggestion: false,
markdownPreviewLoading: false,
previewMarkdown: false,
};
@@ -63,6 +88,39 @@ export default {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
+ lineContent() {
+ const FIRST_CHAR_REGEX = /^(\+|-)/;
+ const [firstSuggestion] = this.suggestions;
+ if (firstSuggestion) {
+ return firstSuggestion.from_content;
+ }
+
+ if (this.line) {
+ const { rich_text: richText, text } = this.line;
+
+ if (text) {
+ return text.replace(FIRST_CHAR_REGEX, '');
+ }
+
+ return _.unescape(stripHtml(richText).replace(/\n/g, ''));
+ }
+
+ return '';
+ },
+ lineNumber() {
+ let lineNumber;
+ if (this.line) {
+ const { new_line: newLine, old_line: oldLine } = this.line;
+ lineNumber = newLine || oldLine;
+ }
+ return lineNumber;
+ },
+ suggestions() {
+ return this.note.suggestions || [];
+ },
+ lineType() {
+ return this.line ? this.line.type : '';
+ },
},
mounted() {
/*
@@ -99,11 +157,12 @@ export default {
if (text) {
this.markdownPreviewLoading = true;
+ this.markdownPreview = __('Loading…');
this.$http
.post(this.versionedPreviewPath(), { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
- .catch(() => new Flash(s__('Error loading markdown preview')));
+ .catch(() => new Flash(__('Error loading markdown preview')));
} else {
this.renderMarkdown();
}
@@ -121,6 +180,7 @@ export default {
if (data.references) {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
+ this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
}
this.$nextTick(() => {
@@ -146,6 +206,8 @@ export default {
>
<markdown-header
:preview-markdown="previewMarkdown"
+ :line-content="lineContent"
+ :can-suggest="canSuggest"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
/>
@@ -162,17 +224,39 @@ export default {
/>
</div>
</div>
- <div v-show="previewMarkdown" class="md md-preview-holder md-preview js-vue-md-preview">
- <div ref="markdown-preview" v-html="markdownPreview"></div>
- <span v-if="markdownPreviewLoading"> Loading... </span>
- </div>
+ <template v-if="hasSuggestion">
+ <div
+ v-show="previewMarkdown"
+ ref="markdown-preview"
+ class="md-preview js-vue-md-preview md md-preview-holder"
+ >
+ <suggestions
+ v-if="hasSuggestion"
+ :note-html="markdownPreview"
+ :from-line="lineNumber"
+ :from-content="lineContent"
+ :line-type="lineType"
+ :disabled="true"
+ :suggestions="suggestions"
+ :help-page-path="helpPagePath"
+ />
+ </div>
+ </template>
+ <template v-else>
+ <div
+ v-show="previewMarkdown"
+ ref="markdown-preview"
+ class="md-preview js-vue-md-preview md md-preview-holder"
+ v-html="markdownPreview"
+ ></div>
+ </template>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
<span>
- <i class="fa fa-exclamation-triangle" aria-hidden="true"> </i> You are about to add
+ <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add
<strong>
- <span class="js-referenced-users-count"> {{ referencedUsers.length }} </span>
+ <span class="js-referenced-users-count">{{ referencedUsers.length }}</span>
</strong>
people to the discussion. Proceed with caution.
</span>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 4c4ba537065..bf4d42670ee 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -17,6 +17,16 @@ export default {
type: Boolean,
required: true,
},
+ lineContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canSuggest: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
mdTable() {
@@ -27,6 +37,9 @@ export default {
'| cell | cell |',
].join('\n');
},
+ mdSuggestion() {
+ return ['```suggestion', `{text}`, '```'].join('\n');
+ },
},
mounted() {
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
@@ -119,6 +132,16 @@ export default {
:button-title="__('Add a table')"
icon="table"
/>
+ <toolbar-button
+ v-if="canSuggest"
+ :tag="mdSuggestion"
+ :prepend="true"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ class="qa-suggestion-btn"
+ />
<button
v-gl-tooltip
aria-label="Go full screen"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
new file mode 100644
index 00000000000..f98560f7336
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -0,0 +1,74 @@
+<script>
+import SuggestionDiffHeader from './suggestion_diff_header.vue';
+
+export default {
+ components: {
+ SuggestionDiffHeader,
+ },
+ props: {
+ newLines: {
+ type: Array,
+ required: true,
+ },
+ fromContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fromLine: {
+ type: Number,
+ required: true,
+ },
+ suggestion: {
+ type: Object,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ applySuggestion(callback) {
+ this.$emit('apply', { suggestionId: this.suggestion.id, callback });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <suggestion-diff-header
+ class="qa-suggestion-diff-header"
+ :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
+ :is-applied="suggestion.applied"
+ :help-page-path="helpPagePath"
+ @apply="applySuggestion"
+ />
+ <table class="mb-3 md-suggestion-diff">
+ <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>
+ </tbody>
+ </table>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..563e2f94fcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -0,0 +1,60 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: { Icon },
+ props: {
+ canApply: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isApplied: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isAppliedSuccessfully: false,
+ isApplying: false,
+ };
+ },
+ methods: {
+ applySuggestion() {
+ if (!this.canApply) return;
+ this.isApplying = true;
+ this.$emit('apply', this.applySuggestionCallback);
+ },
+ applySuggestionCallback() {
+ this.isApplying = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="md-suggestion-header border-bottom-0 mt-2">
+ <div class="qa-suggestion-diff-header font-weight-bold">
+ {{ __('Suggested change') }}
+ <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')">
+ <icon name="question-o" css-classes="link-highlight" />
+ </a>
+ </div>
+ <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
+ <button
+ v-if="canApply"
+ type="button"
+ class="btn qa-apply-btn"
+ :disabled="isApplying"
+ @click="applySuggestion"
+ >
+ {{ __('Apply suggestion') }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
new file mode 100644
index 00000000000..7c6dbee3e19
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -0,0 +1,136 @@
+<script>
+import Vue from 'vue';
+import SuggestionDiff from './suggestion_diff.vue';
+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,
+ default: '',
+ },
+ suggestions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ noteHtml: {
+ type: String,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isRendered: false,
+ };
+ },
+ watch: {
+ suggestions() {
+ this.reset();
+ },
+ noteHtml() {
+ this.reset();
+ },
+ },
+ mounted() {
+ this.renderSuggestions();
+ },
+ methods: {
+ renderSuggestions() {
+ // swaps out suggestion(s) markdown with rich diff components
+ // (while still keeping non-suggestion markdown in place)
+
+ if (!this.noteHtml) return;
+ const { container } = this.$refs;
+ const suggestionElements = container.querySelectorAll('.js-render-suggestion');
+
+ if (this.lineType === 'old') {
+ Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el);
+ }
+
+ suggestionElements.forEach((suggestionEl, i) => {
+ const suggestionParentEl = suggestionEl.parentElement;
+ const newLines = this.extractNewLines(suggestionParentEl);
+ const diffComponent = this.generateDiff(newLines, i);
+ diffComponent.$mount(suggestionParentEl);
+ });
+
+ this.isRendered = true;
+ },
+ extractNewLines(suggestionEl) {
+ // extracts the suggested lines from the markdown
+ // calculates a line number for each line
+
+ const FIRST_CHAR_REGEX = /^(\+|-)/;
+ 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.replace(FIRST_CHAR_REGEX, '')}\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
+
+ 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 },
+ });
+
+ suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
+ this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
+ });
+
+ return suggestionDiff;
+ },
+ reset() {
+ // resets the container HTML (replaces it with the updated noteHTML)
+ // calls `renderSuggestions` once the updated noteHTML is added to the DOM
+
+ this.$refs.container.innerHTML = this.noteHtml;
+ this.isRendered = false;
+ this.renderSuggestions();
+ this.$nextTick(() => this.renderSuggestions());
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="flash-container mt-3"></div>
+ <div v-show="isRendered" ref="container" v-html="noteHtml"></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index a6d2cecdf7e..4572caa907b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -37,6 +37,16 @@ export default {
required: false,
default: false,
},
+ tagContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ cursorOffset: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
};
</script>
@@ -45,8 +55,10 @@ export default {
<button
v-gl-tooltip
:data-md-tag="tag"
+ :data-md-cursor-offset="cursorOffset"
:data-md-select="tagSelect"
:data-md-block="tagBlock"
+ :data-md-tag-content="tagContent"
:data-md-prepend="prepend"
:title="buttonTitle"
:aria-label="buttonTitle"
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 b1faebf409b..8d3a3009c55 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -17,12 +17,14 @@
* />
*/
import { mapGetters } from 'vuex';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import userAvatarLink from '../user_avatar/user_avatar_link.vue';
export default {
name: 'PlaceholderNote',
components: {
userAvatarLink,
+ TimelineEntryItem,
},
props: {
note: {
@@ -37,30 +39,28 @@ export default {
</script>
<template>
- <li class="note being-posted fade-in-half timeline-entry">
- <div class="timeline-entry-inner">
- <div class="timeline-icon">
- <user-avatar-link
- :link-href="getUserData.path"
- :img-src="getUserData.avatar_url"
- :img-size="40"
- />
- </div>
- <div :class="{ discussion: !note.individual_note }" class="timeline-content">
- <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="note-headline-light">@{{ getUserData.username }}</span>
- </a>
- </div>
+ <timeline-entry-item class="note being-posted fade-in-half">
+ <div class="timeline-icon">
+ <user-avatar-link
+ :link-href="getUserData.path"
+ :img-src="getUserData.avatar_url"
+ :img-size="40"
+ />
+ </div>
+ <div :class="{ discussion: !note.individual_note }" class="timeline-content">
+ <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="note-headline-light">@{{ getUserData.username }}</span>
+ </a>
</div>
- <div class="note-body">
- <div class="note-text">
- <p>{{ note.body }}</p>
- </div>
+ </div>
+ <div class="note-body">
+ <div class="note-text">
+ <p>{{ note.body }}</p>
</div>
</div>
</div>
- </li>
+ </timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
index 674f923478d..7689425eb52 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
@@ -1,4 +1,6 @@
<script>
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+
/**
* Common component to render a placeholder system note.
*
@@ -9,6 +11,9 @@
*/
export default {
name: 'PlaceholderSystemNote',
+ components: {
+ TimelineEntryItem,
+ },
props: {
note: {
type: Object,
@@ -19,11 +24,9 @@ export default {
</script>
<template>
- <li class="note system-note timeline-entry being-posted fade-in-half">
- <div class="timeline-entry-inner">
- <div class="timeline-content">
- <em>{{ note.body }}</em>
- </div>
+ <timeline-entry-item class="note system-note being-posted fade-in-half">
+ <div class="timeline-content">
+ <em>{{ note.body }}</em>
</div>
- </li>
+ </timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index c6cf4661222..e61d1fd2031 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -1,22 +1,22 @@
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
export default {
name: 'SkeletonNote',
components: {
GlSkeletonLoading,
+ TimelineEntryItem,
},
};
</script>
<template>
- <li class="timeline-entry note note-wrapper">
- <div class="timeline-entry-inner">
- <div class="timeline-icon"></div>
- <div class="timeline-content">
- <div class="note-header"></div>
- <div class="note-body"><gl-skeleton-loading /></div>
- </div>
+ <timeline-entry-item class="note note-wrapper">
+ <div class="timeline-icon"></div>
+ <div class="timeline-content">
+ <div class="note-header"></div>
+ <div class="note-body"><gl-skeleton-loading /></div>
</div>
- </li>
+ </timeline-entry-item>
</template>
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 fb86262d0b4..31df26f7b05 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -20,6 +20,7 @@ import $ from 'jquery';
import { mapGetters } from 'vuex';
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';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
@@ -29,6 +30,7 @@ export default {
components: {
Icon,
noteHeader,
+ TimelineEntryItem,
},
props: {
note: {
@@ -73,36 +75,34 @@ export default {
</script>
<template>
- <li
+ <timeline-entry-item
:id="noteAnchorId"
:class="{ target: isTargetNote }"
- class="note system-note timeline-entry note-wrapper"
+ class="note system-note note-wrapper"
>
- <div class="timeline-entry-inner">
- <div class="timeline-icon" v-html="iconHtml"></div>
- <div class="timeline-content">
- <div class="note-header">
- <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
- <span v-html="actionTextHtml"></span>
- </note-header>
- </div>
- <div class="note-body">
- <div
- :class="{
- 'system-note-commit-list': hasMoreCommits,
- 'hide-shade': expanded,
- }"
- class="note-text"
- v-html="note.note_html"
- ></div>
- <div v-if="hasMoreCommits" class="flex-list">
- <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;">
- <icon :name="toggleIcon" :size="8" class="append-right-5" />
- <span>Toggle commit list</span>
- </div>
+ <div class="timeline-icon" v-html="iconHtml"></div>
+ <div class="timeline-content">
+ <div class="note-header">
+ <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
+ <span v-html="actionTextHtml"></span>
+ </note-header>
+ </div>
+ <div class="note-body">
+ <div
+ :class="{
+ 'system-note-commit-list': hasMoreCommits,
+ 'hide-shade': expanded,
+ }"
+ class="note-text"
+ v-html="note.note_html"
+ ></div>
+ <div v-if="hasMoreCommits" class="flex-list">
+ <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;">
+ <icon :name="toggleIcon" :size="8" class="append-right-5" />
+ <span>Toggle commit list</span>
</div>
</div>
</div>
</div>
- </li>
+ </timeline-entry-item>
</template>
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
new file mode 100644
index 00000000000..06974a12aed
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue
@@ -0,0 +1,11 @@
+<script>
+export default {
+ name: 'TimelineEntryItem',
+};
+</script>
+
+<template>
+ <li class="timeline-entry">
+ <div class="timeline-entry-inner"><slot></slot></div>
+ </li>
+</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 e742900dbcb..373794fb1f2 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
@@ -44,6 +44,7 @@ export default {
class="sidebar-collapsed-icon"
data-placement="left"
data-container="body"
+ data-boundary="viewport"
@click="handleClick"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-tags"> </i>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index 01b8b94f9e3..e833a8e0483 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -67,7 +67,7 @@ export default {
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`;
+ if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`;
return baseSrc;
},
resultantSrcAttribute() {
@@ -97,6 +97,7 @@ export default {
class="avatar"
/>
<gl-tooltip
+ v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
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
new file mode 100644
index 00000000000..fad1a2f3f56
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
+import { glEmojiTag } from '../../../emoji';
+
+export default {
+ name: 'UserPopover',
+ components: {
+ GlPopover,
+ GlSkeletonLoading,
+ UserAvatarImage,
+ },
+ props: {
+ target: {
+ type: HTMLAnchorElement,
+ required: true,
+ },
+ user: {
+ type: Object,
+ required: true,
+ default: null,
+ },
+ loaded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ jobLine() {
+ if (this.user.bio && this.user.organization) {
+ return sprintf(
+ __('%{bio} at %{organization}'),
+ {
+ bio: this.user.bio,
+ organization: this.user.organization,
+ },
+ false,
+ );
+ } else if (this.user.bio) {
+ return this.user.bio;
+ } else if (this.user.organization) {
+ return this.user.organization;
+ }
+ return null;
+ },
+ statusHtml() {
+ if (this.user.status.emoji && this.user.status.message) {
+ return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`;
+ } else if (this.user.status.message) {
+ return this.user.status.message;
+ }
+ return '';
+ },
+ nameIsLoading() {
+ return !this.user.name;
+ },
+ jobInfoIsLoading() {
+ return !this.user.loaded && this.user.organization === null;
+ },
+ locationIsLoading() {
+ return !this.user.loaded && this.user.location === null;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover :target="target" boundary="viewport" placement="top" show>
+ <div class="user-popover d-flex">
+ <div class="p-1 flex-shrink-1">
+ <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
+ </div>
+ <div class="p-1 w-100">
+ <h5 class="m-0">
+ {{ user.name }}
+ <gl-skeleton-loading
+ v-if="nameIsLoading"
+ :lines="1"
+ class="animation-container-small mb-1"
+ />
+ </h5>
+ <div class="text-secondary mb-2">
+ <span v-if="user.username">@{{ user.username }}</span>
+ <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
+ </div>
+ <div class="text-secondary">
+ {{ jobLine }}
+ <gl-skeleton-loading
+ v-if="jobInfoIsLoading"
+ :lines="1"
+ class="animation-container-small mb-1"
+ />
+ </div>
+ <div class="text-secondary">
+ {{ user.location }}
+ <gl-skeleton-loading
+ v-if="locationIsLoading"
+ :lines="1"
+ class="animation-container-small mb-1"
+ />
+ </div>
+ <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div>
+ </div>
+ </div>
+ </gl-popover>
+</template>
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index bd1cca69c03..bdf20866197 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -35,6 +35,11 @@
@import "pages/**/*";
/*
+ * Component specific styles, will be moved to gitlab-ui
+ */
+@import "components/**/*";
+
+/*
* Code highlight
*/
@import "highlight/dark";
@@ -42,6 +47,7 @@
@import "highlight/solarized_dark";
@import "highlight/solarized_light";
@import "highlight/white";
+@import "highlight/none";
/*
* Styles for JS behaviors.
diff --git a/app/assets/stylesheets/bootstrap.scss b/app/assets/stylesheets/bootstrap.scss
index a040c2f8c20..4a09da3d580 100644
--- a/app/assets/stylesheets/bootstrap.scss
+++ b/app/assets/stylesheets/bootstrap.scss
@@ -1,5 +1,5 @@
/*
- * Includes specific styles from the bootstrap4 foler in node_modules
+ * Includes specific styles from the bootstrap4 folder in node_modules
*/
@import "../../../node_modules/bootstrap/scss/functions";
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index 1e00aa4ff7e..f0671e36130 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -18,8 +18,10 @@ $input-border: $border-color;
$padding-base-vertical: $gl-vert-padding;
$padding-base-horizontal: $gl-padding;
-html {
- // Override default font size used in bs4
+body,
+.form-control,
+.search form {
+ // Override default font size used in non-csslab UI
font-size: 14px;
}
@@ -336,3 +338,12 @@ input[type=color].form-control {
.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:
+ - https://gitlab.com/gitlab-org/gitlab-ce/issues/51362
+ */
+svg {
+ vertical-align: baseline;
+}
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
new file mode 100644
index 00000000000..2f4d30fe923
--- /dev/null
+++ b/app/assets/stylesheets/components/popover.scss
@@ -0,0 +1,9 @@
+.popover {
+ min-width: 300px;
+
+ .popover-body .user-popover {
+ padding: $gl-padding-8;
+ font-size: $gl-font-size-small;
+ line-height: $gl-line-height;
+ }
+}
diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss
new file mode 100644
index 00000000000..acaa41e2677
--- /dev/null
+++ b/app/assets/stylesheets/csslab.scss
@@ -0,0 +1 @@
+@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim";
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 4041f2b4479..834e7ffce81 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -65,3 +65,4 @@
@import 'framework/feature_highlight';
@import 'framework/terms';
@import 'framework/read_more';
+@import 'framework/flex_grid';
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index fcf282a7d7c..e132aa4c216 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -21,6 +21,7 @@
&.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); }
@@ -80,6 +81,7 @@
&.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; }
@@ -106,6 +108,7 @@
width: 100%;
height: 100%;
display: flex;
+ text-decoration: none;
}
.avatar {
@@ -118,6 +121,7 @@
}
&.s40 { min-width: 40px; min-height: 40px; }
+ &.s64 { min-width: 64px; min-height: 64px; }
}
.avatar-counter {
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 219fd99b097..a4a9276c580 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -142,8 +142,14 @@
&.btn-sm {
padding: 4px 10px;
- font-size: 13px;
- line-height: 18px;
+ font-size: $gl-btn-small-font-size;
+ line-height: $gl-btn-small-line-height;
+ }
+
+ &.btn-xs {
+ padding: 2px $gl-btn-padding;
+ font-size: $gl-btn-xs-font-size;
+ line-height: $gl-btn-xs-line-height;
}
&.btn-success,
diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss
index bdd7f09d926..0d8e4afa76f 100644
--- a/app/assets/stylesheets/framework/callout.scss
+++ b/app/assets/stylesheets/framework/callout.scss
@@ -33,7 +33,11 @@
.bs-callout-warning {
background-color: $orange-100;
border-color: $orange-200;
- color: $orange-700;
+ color: $orange-900;
+
+ a {
+ color: $orange-900;
+ }
}
.bs-callout-info {
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 626c8f92d1d..e037b02a30c 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -386,3 +386,18 @@ img.emoji {
.flex-no-shrink { flex-shrink: 0; }
.mw-460 { max-width: 460px; }
.ws-initial { white-space: initial; }
+.min-height-0 { min-height: 0; }
+
+.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}; }
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 6f103e4e89a..8b6a7017c47 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -261,7 +261,7 @@
height: 1px;
margin: 4px -1px;
padding: 0;
- background-color: $dropdown-divider-color;
+ background-color: $dropdown-divider-bg;
}
> .active {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index ce5d36a340f..afcb230797a 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -176,9 +176,9 @@
display: block;
font-weight: $gl-font-weight-normal;
position: relative;
- padding: 8px 16px;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
color: $gl-text-color;
- line-height: normal;
+ line-height: $gl-btn-line-height;
white-space: normal;
overflow: hidden;
text-align: left;
@@ -290,14 +290,18 @@
}
}
+ .dropdown-item {
+ @include dropdown-link;
+ }
+
.divider {
height: 1px;
margin: #{$grid-size / 2} 0;
padding: 0;
- background-color: $dropdown-divider-color;
+ background-color: $dropdown-divider-bg;
&:hover {
- background-color: $dropdown-divider-color;
+ background-color: $dropdown-divider-bg;
}
}
@@ -306,7 +310,7 @@
height: 1px;
margin-top: 8px;
margin-bottom: 8px;
- background-color: $dropdown-divider-color;
+ background-color: $dropdown-divider-bg;
}
.dropdown-menu-empty-item a {
@@ -319,8 +323,8 @@
.dropdown-header {
color: $gl-text-color-secondary;
font-size: 13px;
- line-height: 22px;
- padding: 8px 16px;
+ line-height: $gl-line-height;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
&.capitalize-header .dropdown-header {
@@ -329,13 +333,8 @@
.dropdown-bold-header {
font-weight: $gl-font-weight-bold;
- line-height: 22px;
- padding: 0 16px;
- }
-
- .separator + .dropdown-header,
- .separator + .dropdown-bold-header {
- padding-top: 10px;
+ line-height: $gl-line-height;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
.unclickable {
@@ -535,14 +534,15 @@
.dropdown-title {
position: relative;
- padding: 2px 25px 10px;
- margin: 0 10px 10px;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
+ padding-bottom: #{2 * $dropdown-item-padding-y};
+ margin-bottom: $dropdown-item-padding-y;
font-weight: $gl-font-weight-bold;
line-height: 1;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
- border-bottom: 1px solid $dropdown-divider-color;
+ border-bottom: 1px solid $dropdown-divider-bg;
overflow: hidden;
}
@@ -621,7 +621,7 @@
padding: 0 7px;
color: $gl-gray-700;
line-height: 30px;
- border: 1px solid $dropdown-divider-color;
+ border: 1px solid $dropdown-divider-bg;
border-radius: 2px;
outline: 0;
@@ -656,7 +656,7 @@
padding-top: 10px;
margin-top: 10px;
font-size: 13px;
- border-top: 1px solid $dropdown-divider-color;
+ border-top: 1px solid $dropdown-divider-bg;
}
.dropdown-footer-content {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 037a5adfb7e..3ac7b6b704b 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -24,7 +24,7 @@
}
}
- table {
+ &:not(.use-csslab) table {
@extend .table;
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index d5693a5d1a1..f48b3ddc912 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -363,6 +363,12 @@
background-color: $white-light;
border-top: 0;
}
+
+ .filter-dropdown-container {
+ .dropdown {
+ margin-left: 0;
+ }
+ }
}
@include media-breakpoint-down(sm) {
@@ -372,16 +378,6 @@
.dropdown-menu {
width: 100%;
}
-
- .dropdown {
- margin-left: 0;
- }
-
- .fa-chevron-down {
- position: absolute;
- right: 10px;
- top: 10px;
- }
}
}
diff --git a/app/assets/stylesheets/framework/flex_grid.scss b/app/assets/stylesheets/framework/flex_grid.scss
new file mode 100644
index 00000000000..10537fd5549
--- /dev/null
+++ b/app/assets/stylesheets/framework/flex_grid.scss
@@ -0,0 +1,52 @@
+.flex-grid {
+ .grid-row {
+ border-bottom: 1px solid $border-color;
+ padding: 0;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ @include media-breakpoint-down(md) {
+ border-bottom: 0;
+ border-right: 1px solid $border-color;
+
+ &:last-child {
+ border-right: 0;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ border-right: 0;
+ border-bottom: 1px solid $border-color;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+ }
+ }
+
+ .grid-cell {
+ padding: 10px $gl-padding;
+ border-right: 1px solid $border-color;
+
+ &:last-child {
+ border-right: 0;
+ }
+
+ @include media-breakpoint-up(md) {
+ flex: 1;
+ }
+
+ @include media-breakpoint-down(md) {
+ border-right: 0;
+ flex: none;
+ }
+ }
+}
+
+.card {
+ .card-body.flex-grid {
+ padding: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index afd888af672..4da2243981e 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -256,7 +256,12 @@ label {
}
}
+.input-md {
+ max-width: $input-md-width;
+ width: 100%;
+}
+
.input-lg {
- max-width: 320px;
+ max-width: $input-lg-width;
width: 100%;
}
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index b8bb9e1e07b..0ef50e139f2 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -22,6 +22,10 @@
.container-fluid {
.navbar-toggler {
border-left: 1px solid lighten($border-and-box-shadow, 10%);
+
+ svg {
+ fill: $search-and-nav-links;
+ }
}
}
@@ -309,12 +313,14 @@ body {
.navbar-nav {
> li {
> a:hover,
- > a:focus {
+ > a:focus,
+ > button:hover {
color: $theme-gray-900;
}
&.active > a,
- &.active > a:hover {
+ &.active > a:hover,
+ &.active > button {
color: $white-light;
}
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 39410ac56af..7d283dcfb71 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -33,6 +33,7 @@
.close-icon {
display: block;
+ margin: auto;
}
}
@@ -90,12 +91,6 @@
padding: 2px 8px;
margin: 5px 2px 5px -8px;
border-radius: $border-radius-default;
-
- .tanuki-logo {
- @include media-breakpoint-up(sm) {
- margin-right: 8px;
- }
- }
}
.project-item-select {
@@ -127,12 +122,6 @@
}
}
- li.dropdown-bold-header {
- color: $gl-text-color-secondary;
- font-size: 12px;
- padding: 0 16px;
- }
-
.navbar-collapse {
flex: 0 0 auto;
border-top: 0;
@@ -180,12 +169,6 @@
color: currentColor;
background-color: transparent;
}
-
- .more-icon,
- .close-icon {
- fill: $white-light;
- margin: auto;
- }
}
.navbar-nav {
@@ -383,6 +366,16 @@
top: 1px;
}
}
+
+ .dropdown-menu li a .identicon {
+ width: 17px;
+ height: 17px;
+ font-size: $gl-font-size-xs;
+ vertical-align: middle;
+ text-indent: 0;
+ line-height: $gl-font-size-xs + 2px;
+ display: inline-block;
+ }
}
.breadcrumbs-list {
@@ -531,7 +524,7 @@
left: auto;
li.current-user {
- padding: 5px 18px;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
.user-name {
display: block;
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 452e946f95f..73533571a2f 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -42,11 +42,12 @@
padding: 10px;
text-align: right;
float: left;
+ line-height: 1;
a {
font-family: $monospace-font;
display: block;
- font-size: $code_font_size !important;
+ font-size: $code-font-size !important;
min-height: 19px;
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index abd26e38d18..8db7d63266e 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -80,3 +80,15 @@
.user-avatar-link {
text-decoration: none;
}
+
+.circle-icon-container {
+ $border-size: 1px;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: $border-size solid $theme-gray-400;
+ border-radius: 50%;
+ padding: $gl-padding-8 - $border-size;
+ color: $theme-gray-700;
+}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 0f6fb16774c..5609a2086e6 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -131,7 +131,7 @@
width: 100%;
}
-.md {
+.md:not(.use-csslab) {
&.md-preview-holder {
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
@@ -277,6 +277,27 @@
}
}
+.md-suggestion-diff {
+ display: table !important;
+ border: 1px solid $border-color !important;
+}
+
+.md-suggestion-header {
+ height: $suggestion-header-height;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: $gray-light;
+ border: 1px solid $border-color;
+ padding: $gl-padding;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+
+ svg {
+ vertical-align: middle;
+ margin-bottom: 3px;
+ }
+}
+
@include media-breakpoint-down(xs) {
.atwho-view-ul {
width: 350px;
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 6d20c46b99d..3bb046d0e51 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -39,15 +39,6 @@
.git-clone-holder {
display: none;
}
-
- // Display Star and Fork buttons without counters on mobile.
- .project-repo-buttons {
- display: block;
-
- .count-buttons .count-badge {
- margin-top: $gl-padding-8;
- }
- }
}
.group-buttons {
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index de9e7c37695..19640ab5986 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -158,6 +158,10 @@
width: 100%;
}
+ .dropdown-menu-toggle {
+ margin-bottom: 0;
+ }
+
form {
display: block;
height: auto;
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 7f0edd88dfb..a68f1e4e570 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -1,6 +1,11 @@
/** Select2 selectbox style override **/
.select2-container {
width: 100% !important;
+
+ &.input-md,
+ &.input-lg {
+ display: block;
+ }
}
.select2-container,
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 4a311da1675..3d5208c3db5 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -31,16 +31,6 @@
.timeline-entry-inner {
position: relative;
-
- @include notes-media('max', map-get($grid-breakpoints, sm)) {
- .timeline-icon {
- display: none;
- }
-
- .timeline-content {
- margin-left: 0;
- }
- }
}
&:target,
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index b3b99df5790..0c81dc2e156 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -368,11 +368,11 @@ code {
* Apply Markdown typography
*
*/
-.wiki {
+.wiki:not(.use-csslab) {
@include md-typography;
}
-.md {
+.md:not(.use-csslab) {
@include md-typography;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index b43bb3feef5..ce5aaa8963c 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21;
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
+$shadow-color: rgba($black, 0.1);
$almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor);
@@ -197,6 +198,8 @@ $well-light-text-color: #5b6169;
$gl-font-size: 14px;
$gl-font-size-xs: 11px;
$gl-font-size-small: 12px;
+$gl-font-size-medium: 1.43rem;
+$gl-font-size-large: 16px;
$gl-font-weight-normal: 400;
$gl-font-weight-bold: 600;
$gl-text-color: #2e2e2e;
@@ -243,11 +246,13 @@ $gl-padding-top: 10px;
$gl-sidebar-padding: 22px;
$gl-bar-padding: 3px;
$input-horizontal-padding: 12px;
+$browserScrollbarSize: 10px;
/*
* Misc
*/
$header-height: 40px;
+$suggestion-header-height: 46px;
$ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
@@ -269,9 +274,11 @@ $performance-bar-height: 35px;
$flash-height: 52px;
$context-header-height: 60px;
$breadcrumb-min-height: 48px;
-$project-title-row-height: 24px;
+$project-title-row-height: 64px;
+$project-avatar-mobile-size: 24px;
$gl-line-height: 16px;
$gl-line-height-24: 24px;
+$gl-line-height-14: 14px;
/*
* Common component specific colors
@@ -331,7 +338,6 @@ $dropdown-max-height: 312px;
$dropdown-vertical-offset: 4px;
$dropdown-empty-row-bg: rgba(#000, 0.04);
$dropdown-shadow-color: rgba(#000, 0.1);
-$dropdown-divider-color: rgba(#000, 0.1);
$dropdown-title-btn-color: #bfbfbf;
$dropdown-input-fa-color: #c7c7c7;
$dropdown-input-focus-shadow: rgba($blue-300, 0.4);
@@ -365,6 +371,10 @@ $gl-btn-padding: 10px;
$gl-btn-line-height: 16px;
$gl-btn-vert-padding: 8px;
$gl-btn-horz-padding: 12px;
+$gl-btn-small-font-size: 13px;
+$gl-btn-small-line-height: 18px;
+$gl-btn-xs-font-size: 13px;
+$gl-btn-xs-line-height: 13px;
/*
* Badges
@@ -395,7 +405,7 @@ $award-emoji-positive-add-lines: #bb9c13;
* Search Box
*/
$search-input-border-color: rgba($blue-400, 0.8);
-$search-input-width: 240px;
+$search-input-width: 200px;
$search-input-active-width: 320px;
$location-icon-color: #e7e9ed;
@@ -499,6 +509,8 @@ $gl-field-focus-shadow: rgba(0, 0, 0, 0.075);
$gl-field-focus-shadow-error: rgba($red-500, 0.6);
$input-short-width: 200px;
$input-short-md-width: 280px;
+$input-md-width: 240px;
+$input-lg-width: 320px;
/*
* Help
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index 759b4f333ca..5ca76bb6c5a 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -19,3 +19,12 @@ $info: $blue-500;
$warning: $orange-500;
$danger: $red-500;
$zindex-modal-backdrop: 1040;
+$nav-divider-margin-y: ($grid-size / 2);
+$dropdown-divider-bg: $theme-gray-200;
+$dropdown-item-padding-y: 8px;
+$dropdown-item-padding-x: 12px;
+$popover-max-width: 300px;
+$popover-border-width: 1px;
+$popover-border-color: $border-color;
+$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color;
+$popover-arrow-outer-color: $shadow-color;
diff --git a/app/assets/stylesheets/highlight/none.scss b/app/assets/stylesheets/highlight/none.scss
new file mode 100644
index 00000000000..7d692a87e33
--- /dev/null
+++ b/app/assets/stylesheets/highlight/none.scss
@@ -0,0 +1,242 @@
+/*
+* None Syntax Colors
+*/
+
+
+
+@mixin matchLine {
+ color: $black-transparent;
+ background-color: $white-normal;
+}
+
+.code.none {
+ // Line numbers
+ .line-numbers,
+ .diff-line-num {
+ background-color: $gray-light;
+ }
+
+ .diff-line-num,
+ .diff-line-num a {
+ color: $black-transparent;
+ }
+
+ // Code itself
+ pre.code,
+ .diff-line-num {
+ border-color: $white-normal;
+ }
+
+ &,
+ pre.code,
+ .line_holder .line_content {
+ background-color: $white-light;
+ color: $gl-text-color;
+ }
+
+// Diff line
+
+ $none-over-bg: #ded7fc;
+ $none-expanded-border: #e0e0e0;
+ $none-expanded-bg: #f7f7f7;
+
+ .line_holder {
+
+ &.match .line_content,
+ .new-nonewline.line_content,
+ .old-nonewline.line_content {
+ @include matchLine;
+ }
+
+ .diff-line-num {
+ &.old {
+ background-color: $line-number-old;
+ border-color: $line-removed-dark;
+
+ a {
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
+ }
+ }
+
+ &.new {
+ background-color: $line-number-new;
+ border-color: $line-added-dark;
+
+ a {
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
+ }
+ }
+
+ &.is-over,
+ &.hll:not(.empty-cell).is-over {
+ background-color: $none-over-bg;
+ border-color: darken($none-over-bg, 5%);
+
+ a {
+ color: darken($none-over-bg, 15%);
+ }
+ }
+
+ &.hll:not(.empty-cell) {
+ background-color: $line-number-select;
+ border-color: $line-select-yellow-dark;
+ }
+ }
+
+ &:not(.diff-expanded) + .diff-expanded,
+ &.diff-expanded + .line_holder:not(.diff-expanded) {
+ > .diff-line-num,
+ > .line_content {
+ border-top: 1px solid $none-expanded-border;
+ }
+ }
+
+ &.diff-expanded {
+ > .diff-line-num,
+ > .line_content {
+ background: $none-expanded-bg;
+ border-color: $none-expanded-bg;
+ }
+ }
+
+ .line_content {
+ &.old {
+ background-color: $line-removed;
+
+ &::before {
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
+ }
+
+ span.idiff {
+ background-color: $line-removed-dark;
+ }
+ }
+
+ &.new {
+ background-color: $line-added;
+
+ &::before {
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
+ }
+
+ span.idiff {
+ background-color: $line-added-dark;
+ }
+ }
+
+ &.match {
+ @include matchLine;
+ }
+
+ &.hll:not(.empty-cell) {
+ background-color: $line-select-yellow;
+ }
+ }
+ }
+
+ // highlight line via anchor
+ pre .hll {
+ background-color: $white-normal;
+ }
+
+ // Search result highlight
+ span.highlight_word {
+ background-color: $white-normal;
+ }
+
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $gl-text-color;
+ text-decoration: underline;
+ }
+
+ .hll { background-color: $white-light; }
+
+ .gd {
+ color: $gl-text-color;
+ background-color: $white-light;
+
+ .x {
+ color: $gl-text-color;
+ background-color: $white-light;
+ }
+ }
+
+ .gi {
+ color: $gl-text-color;
+ background-color: $white-light;
+
+ .x {
+ color: $gl-text-color;
+ background-color: $white-light;
+ }
+ }
+
+ .c { color: $gl-text-color; } /* Comment */
+ .err { color: $gl-text-color; } /* Error */
+ .g { color: $gl-text-color; } /* Generic */
+ .k { color: $gl-text-color; } /* Keyword */
+ .l { color: $gl-text-color; } /* Literal */
+ .n { color: $gl-text-color; } /* Name */
+ .o { color: $gl-text-color; } /* Operator */
+ .x { color: $gl-text-color; } /* Other */
+ .p { color: $gl-text-color; } /* Punctuation */
+ .cm { color: $gl-text-color; } /* Comment.Multiline */
+ .cp { color: $gl-text-color; } /* Comment.Preproc */
+ .c1 { color: $gl-text-color; } /* Comment.Single */
+ .cs { color: $gl-text-color; } /* Comment.Special */
+ .ge { color: $gl-text-color; } /* Generic.Emph */
+ .gr { color: $gl-text-color; } /* Generic.Error */
+ .gh { color: $gl-text-color; } /* Generic.Heading */
+ .go { color: $gl-text-color; } /* Generic.Output */
+ .gp { color: $gl-text-color; } /* Generic.Prompt */
+ .gs { color: $gl-text-color; } /* Generic.Strong */
+ .gu { color: $gl-text-color; } /* Generic.Subheading */
+ .gt { color: $gl-text-color; } /* Generic.Traceback */
+ .kc { color: $gl-text-color; } /* Keyword.Constant */
+ .kd { color: $gl-text-color; } /* Keyword.Declaration */
+ .kn { color: $gl-text-color; } /* Keyword.Namespace */
+ .kp { color: $gl-text-color; } /* Keyword.Pseudo */
+ .kr { color: $gl-text-color; } /* Keyword.Reserved */
+ .kt { color: $gl-text-color; } /* Keyword.Type */
+ .ld { color: $gl-text-color; } /* Literal.Date */
+ .m { color: $gl-text-color; } /* Literal.Number */
+ .s { color: $gl-text-color; } /* Literal.String */
+ .na { color: $gl-text-color; } /* Name.Attribute */
+ .nb { color: $gl-text-color; } /* Name.Builtin */
+ .nc { color: $gl-text-color; } /* Name.Class */
+ .no { color: $gl-text-color; } /* Name.Constant */
+ .nd { color: $gl-text-color; } /* Name.Decorator */
+ .ni { color: $gl-text-color; } /* Name.Entity */
+ .ne { color: $gl-text-color; } /* Name.Exception */
+ .nf { color: $gl-text-color; } /* Name.Function */
+ .nl { color: $gl-text-color; } /* Name.Label */
+ .nn { color: $gl-text-color; } /* Name.Namespace */
+ .nx { color: $gl-text-color; } /* Name.Other */
+ .py { color: $gl-text-color; } /* Name.Property */
+ .nt { color: $gl-text-color; } /* Name.Tag */
+ .nv { color: $gl-text-color; } /* Name.Variable */
+ .ow { color: $gl-text-color; } /* Operator.Word */
+ .w { color: $gl-text-color; } /* Text.Whitespace */
+ .mf { color: $gl-text-color; } /* Literal.Number.Float */
+ .mh { color: $gl-text-color; } /* Literal.Number.Hex */
+ .mi { color: $gl-text-color; } /* Literal.Number.Integer */
+ .mo { color: $gl-text-color; } /* Literal.Number.Oct */
+ .sb { color: $gl-text-color; } /* Literal.String.Backtick */
+ .sc { color: $gl-text-color; } /* Literal.String.Char */
+ .sd { color: $gl-text-color; } /* Literal.String.Doc */
+ .s2 { color: $gl-text-color; } /* Literal.String.Double */
+ .se { color: $gl-text-color; } /* Literal.String.Escape */
+ .sh { color: $gl-text-color; } /* Literal.String.Heredoc */
+ .si { color: $gl-text-color; } /* Literal.String.Interpol */
+ .sx { color: $gl-text-color; } /* Literal.String.Other */
+ .sr { color: $gl-text-color; } /* Literal.String.Regex */
+ .s1 { color: $gl-text-color; } /* Literal.String.Single */
+ .ss { color: $gl-text-color; } /* Literal.String.Symbol */
+ .bp { color: $gl-text-color; } /* Name.Builtin.Pseudo */
+ .vc { color: $gl-text-color; } /* Name.Variable.Class */
+ .vg { color: $gl-text-color; } /* Name.Variable.Global */
+ .vi { color: $gl-text-color; } /* Name.Variable.Instance */
+ .il { color: $gl-text-color; } /* Literal.Number.Integer.Long */
+
+}
diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
new file mode 100644
index 00000000000..896a3466cb4
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
@@ -0,0 +1,18 @@
+@mixin ide-trace-view {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ margin-top: -$grid-size;
+ margin-bottom: -$grid-size;
+
+ &.build-page .top-bar {
+ top: 0;
+ height: auto;
+ font-size: 12px;
+ border-top-right-radius: $border-radius-default;
+ }
+
+ .top-bar {
+ margin-left: -$gl-padding;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 07d82e984ba..98d0a2d43ea 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -1,5 +1,6 @@
@import 'framework/variables';
@import 'framework/mixins';
+@import './ide_mixins';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
@@ -1111,11 +1112,7 @@ $ide-commit-header-height: 48px;
}
.ide-pipeline {
- display: flex;
- flex-direction: column;
- height: 100%;
- margin-top: -$grid-size;
- margin-bottom: -$grid-size;
+ @include ide-trace-view();
.empty-state {
margin-top: auto;
@@ -1133,17 +1130,9 @@ $ide-commit-header-height: 48px;
}
}
- .build-trace,
- .top-bar {
+ .build-trace {
margin-left: -$gl-padding;
}
-
- &.build-page .top-bar {
- top: 0;
- height: auto;
- font-size: 12px;
- border-top-right-radius: $border-radius-default;
- }
}
.ide-pipeline-list {
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index c6074eb9df4..37984a8666f 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -41,7 +41,7 @@
.issue-board-dropdown-content {
margin: 0 8px 10px;
padding-bottom: 10px;
- border-bottom: 1px solid $dropdown-divider-color;
+ border-bottom: 1px solid $dropdown-divider-bg;
> p {
margin: 0;
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 81cb519883b..c7dde2f0f2a 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -228,9 +228,21 @@
padding: 16px 0;
}
+ .trigger-variables-btn-container {
+ @extend .d-flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .trigger-variables-btn {
+ margin-top: -5px;
+ margin-bottom: -5px;
+ }
+ }
+
.trigger-build-variables {
margin: 0;
overflow-x: auto;
+ width: 100%;
-ms-overflow-style: scrollbar;
-webkit-overflow-scrolling: touch;
}
@@ -243,7 +255,15 @@
.trigger-build-value {
padding: 2px 4px;
color: $black;
- background-color: $white-light;
+ }
+
+ .trigger-variables-table-cell {
+ font-size: $gl-font-size-small;
+ line-height: $gl-line-height;
+ border: 1px solid $theme-gray-200;
+ padding: $gl-padding-4 6px;
+ width: 50%;
+ vertical-align: top;
}
.badge.badge-pill {
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 5405f20a760..18c62cb4f1e 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -914,6 +914,7 @@
padding: 0;
width: (2px * $image-comment-cursor-left-offset);
height: (2px * $image-comment-cursor-top-offset);
+ color: $blue-400;
// center the indicator to match the top left click region
margin-top: (-1px * $image-comment-cursor-top-offset) + 2;
margin-left: (-1px * $image-comment-cursor-left-offset) + 1;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 8ea34f5d19d..bb6b6f84849 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -259,6 +259,16 @@ ul.related-merge-requests > li {
display: block;
}
+.issue-sort-dropdown {
+ .btn-group {
+ width: 100%;
+ }
+
+ .reverse-sort-btn {
+ color: $gl-text-color-secondary;
+ }
+}
+
@include media-breakpoint-up(sm) {
.emoji-block .row {
display: flex;
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index fa0ab1a3bae..67d7a8175ac 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -49,8 +49,8 @@
.login-box,
.omniauth-container {
box-shadow: 0 0 0 1px $border-color;
- border-bottom-right-radius: 2px;
- border-bottom-left-radius: 2px;
+ border-bottom-right-radius: $border-radius-small;
+ border-bottom-left-radius: $border-radius-small;
padding: 15px;
.login-heading h3 {
@@ -95,6 +95,7 @@
}
.omniauth-container {
+ border-radius: $border-radius-small;
font-size: 13px;
p {
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss
index d26659701e1..e0f7d075fc7 100644
--- a/app/assets/stylesheets/pages/merge_conflicts.scss
+++ b/app/assets/stylesheets/pages/merge_conflicts.scss
@@ -93,8 +93,28 @@ $colors: (
solarized-dark-line-origin-chosen : rgba(#2878c9, .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, .25),
+ solarized_dark_line_not_chosen : rgba(#839496, .15),
+
+ none_header_head_neutral : $gray-normal,
+ none_line_head_neutral : $gray-normal,
+ none_button_head_neutral : $gray-normal,
+
+ none_header_head_chosen : $gray-darker,
+ none_line_head_chosen : $gray-darker,
+ none_button_head_chosen : $gray-darker,
+
+ none_header_origin_neutral : $gray-normal,
+ none_line_origin_neutral : $gray-normal,
+ none_button_origin_neutral : $gray-normal,
+
+ none_header_origin_chosen : $gray-darker,
+ none_line_origin_chosen : $gray-darker,
+ none_button_origin_chosen : $gray-darker,
+
+ none_header_not_chosen : $gray-light,
+ none_line_not_chosen : $gray-light
+
);
// scss-lint:enable ColorVariable
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index b075009b57c..221b4e934ff 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -50,9 +50,19 @@
.mr-widget-heading {
position: relative;
border: 1px solid $border-color;
- border-radius: 4px;
+ border-radius: $border-radius-default;
+}
- &:not(.deploy-heading)::before {
+.mr-widget-extension {
+ border-top: 1px solid $border-color;
+ background-color: $gray-light;
+}
+
+.mr-widget-workflow {
+ margin-top: $gl-padding;
+ position: relative;
+
+ &::before {
content: '';
border-left: 1px solid $theme-gray-200;
position: absolute;
@@ -68,8 +78,8 @@
border-top: 0;
}
-.mr-widget-heading,
.mr-widget-section,
+.mr-widget-content,
.mr-widget-footer {
padding: $gl-padding;
}
@@ -560,19 +570,6 @@
color: $gl-text-color;
}
- .git-merge-icon-container {
- border: 1px solid $theme-gray-400;
- border-radius: 50%;
- height: 32px;
- width: 32px;
- color: $theme-gray-700;
- line-height: 28px;
-
- .ic-git-merge {
- vertical-align: middle;
- width: 31px;
- }
- }
.git-merge-container {
justify-content: space-between;
@@ -854,11 +851,6 @@
}
.deploy-heading {
- margin-top: -19px;
- border-top-left-radius: 0;
- border-top-right-radius: 0;
- background-color: $gray-light;
-
@include media-breakpoint-up(md) {
padding: $gl-padding-8 $gl-padding;
}
@@ -868,6 +860,10 @@
font-size: 12px;
margin-left: 48px;
}
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $border-color;
+ }
}
.deploy-body {
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 97b3f696139..5b30295adf9 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -178,7 +178,7 @@
table {
.discussion-form-container {
- padding: $gl-padding-top $gl-padding $gl-padding;
+ padding: $gl-padding;
}
}
@@ -237,11 +237,12 @@ table {
}
.discussion-body,
-.diff-file {
+.diff-file,
+.commit-diff {
.discussion-reply-holder {
background-color: $white-light;
- padding: 10px 16px;
border-radius: 0 0 3px 3px;
+ padding: $gl-padding;
&.is-replying {
padding-bottom: $gl-padding;
@@ -254,7 +255,6 @@ table {
display: flex;
}
-
.discussion-actions {
display: table;
@@ -275,8 +275,10 @@ table {
}
}
- .btn {
- width: 100%;
+ @include media-breakpoint-down(xs) {
+ .btn {
+ width: 100%;
+ }
}
.btn-text-field {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 4fda2964fd5..a5b1eff3e1d 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -14,7 +14,7 @@ $note-form-margin-left: 72px;
}
@mixin outline-comment() {
- margin: $gl-padding;
+ margin: $gl-padding $gl-padding 0;
border: 1px solid $border-color;
border-radius: $border-radius-default;
}
@@ -27,8 +27,10 @@ $note-form-margin-left: 72px;
}
}
-.main-notes-list {
- @include vertical-line(36px);
+.issuable-discussion {
+ .main-notes-list {
+ @include vertical-line(36px);
+ }
}
.notes {
@@ -76,10 +78,10 @@ $note-form-margin-left: 72px;
.card {
border: 0;
}
+ }
- li.note {
- border-bottom: 1px solid $border-color;
- }
+ li.note {
+ border-bottom: 1px solid $border-color;
}
.replies-toggle {
@@ -150,6 +152,16 @@ $note-form-margin-left: 72px;
display: block;
position: relative;
+ .timeline-discussion-body {
+ margin-top: -8px;
+ overflow-x: auto;
+ overflow-y: hidden;
+
+ .discussion-resolved-text {
+ margin-bottom: 8px;
+ }
+ }
+
.diff-content {
overflow: visible;
padding: 0;
@@ -161,20 +173,6 @@ $note-form-margin-left: 72px;
position: relative;
border-bottom: 0;
- &:target,
- &.target {
- border-bottom: 1px solid $white-normal;
-
- &:not(:first-child) {
- border-top: 1px solid $white-normal;
- margin-top: -1px;
- }
-
- .timeline-entry-inner {
- border-bottom: 0;
- }
- }
-
&.being-posted {
pointer-events: none;
opacity: 0.5;
@@ -371,10 +369,10 @@ $note-form-margin-left: 72px;
&::after {
content: '';
- width: 100%;
height: 70px;
position: absolute;
- left: 0;
+ left: $gl-padding-24;
+ right: 0;
bottom: 0;
background: linear-gradient(rgba($white-light, 0.1) -100px, $white-light 100%);
}
@@ -462,7 +460,7 @@ $note-form-margin-left: 72px;
font-family: $regular-font;
td {
- border: 1px solid $white-normal;
+ border: 1px solid $border-color;
border-left: 0;
&.notes_content {
@@ -504,8 +502,6 @@ $note-form-margin-left: 72px;
}
.note-wrapper {
- @include outline-comment();
-
&.system-note {
border: 0;
margin-left: 20px;
@@ -514,23 +510,14 @@ $note-form-margin-left: 72px;
.discussion-reply-holder {
border-radius: 0 0 $border-radius-default $border-radius-default;
- border-top: 1px solid $border-color;
position: relative;
}
}
.commit-diff {
- .notes {
- @include vertical-line(52px);
- }
-
.notes_content {
background-color: $white-light;
}
-
- .discussion-reply-holder {
- border-top: 1px solid $border-color;
- }
}
.discussion-header,
@@ -589,12 +576,6 @@ $note-form-margin-left: 72px;
padding-bottom: 0;
}
-.note-header-author-name {
- @include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
- display: none;
- }
-}
-
.note-headline-light {
display: inline;
@@ -949,12 +930,6 @@ $note-form-margin-left: 72px;
border-bottom: 1px solid $border-color;
}
- .note-wrapper.outlined {
- margin: 0;
- border: 0;
- border-radius: 0;
- }
-
.discussion-form-container {
padding: $gl-padding;
}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 1d691d1d8b8..b813eb16dad 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -98,7 +98,6 @@
// Limits the width of the user bio for readability.
max-width: 600px;
margin: 10px auto;
- padding: 0 16px;
}
.user-avatar-button {
@@ -144,11 +143,13 @@
.provider-btn-group {
display: inline-block;
margin-right: 10px;
+ margin-bottom: 10px;
border: 1px solid $border-color;
border-radius: 3px;
&:last-child {
margin-right: 0;
+ margin-bottom: 0;
}
}
@@ -220,7 +221,11 @@
}
.profile-header {
- margin: 0 auto;
+ margin: 0 $gl-padding;
+
+ &.with-no-profile-tabs {
+ margin-bottom: $gl-padding-24;
+ }
.avatar-holder {
width: 90px;
@@ -451,4 +456,15 @@ table.u2f-registrations {
}
}
}
+
+ @include media-breakpoint-down(sm) {
+ .input-md,
+ .input-lg {
+ max-width: 100%;
+ }
+ }
+}
+
+.help-block {
+ color: $gl-text-color-secondary;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 80ec390d18e..0ce0db038a7 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -144,7 +144,6 @@
.group-home-panel {
padding-top: 24px;
padding-bottom: 24px;
- border-bottom: 1px solid $border-color;
.group-avatar {
float: none;
@@ -155,7 +154,6 @@
}
}
- .project-title,
.group-title {
margin-top: 10px;
margin-bottom: 10px;
@@ -195,25 +193,69 @@
}
.project-home-panel {
- padding-top: $gl-padding-8;
- padding-bottom: $gl-padding-24;
-
- .project-title-row {
- margin-right: $gl-padding-8;
- }
+ padding-top: $gl-padding;
+ padding-bottom: $gl-padding;
.project-avatar {
width: $project-title-row-height;
height: $project-title-row-height;
flex-shrink: 0;
flex-basis: $project-title-row-height;
- margin: 0 $gl-padding-8 0 0;
+ margin: 0 $gl-padding 0 0;
}
.project-title {
+ margin-top: 8px;
+ margin-bottom: 5px;
font-size: 20px;
- line-height: $project-title-row-height;
+ line-height: $gl-line-height-24;
font-weight: bold;
+
+ .icon {
+ font-size: $gl-font-size-large;
+ }
+
+ .project-visibility {
+ color: $gl-text-color-secondary;
+ }
+
+ .project-tag-list {
+ font-size: $gl-font-size;
+ font-weight: $gl-font-weight-normal;
+
+ .icon {
+ position: relative;
+ top: 3px;
+ margin-right: $gl-padding-4;
+ }
+ }
+ }
+
+ .project-title-row {
+ @include media-breakpoint-down(sm) {
+ .project-avatar {
+ width: $project-avatar-mobile-size;
+ height: $project-avatar-mobile-size;
+ flex-basis: $project-avatar-mobile-size;
+
+ .avatar {
+ font-size: 20px;
+ line-height: 46px;
+ }
+ }
+
+ .project-title {
+ margin-top: 4px;
+ margin-bottom: 2px;
+ font-size: $gl-font-size;
+ line-height: $gl-font-size-large;
+ }
+
+ .project-tag-list,
+ .project-metadata {
+ font-size: $gl-font-size-small;
+ }
+ }
}
.project-metadata {
@@ -222,16 +264,6 @@
line-height: $gl-btn-line-height;
color: $gl-text-color-secondary;
- .icon {
- margin-right: $gl-padding-4;
- font-size: 16px;
- }
-
- .project-visibility,
- .project-license,
- .project-tag-list {
- margin-right: $gl-padding-8;
- }
.project-license {
.btn {
@@ -240,12 +272,22 @@
}
}
- .project-tag-list,
- .project-license {
- .icon {
- position: relative;
- top: 2px;
- }
+ .access-request-link,
+ .project-tag-list {
+ padding-left: $gl-padding-8;
+ border-left: 1px solid $gl-text-color-secondary;
+ }
+ }
+
+ .project-description {
+ @include media-breakpoint-up(md) {
+ font-size: $gl-font-size-large;
+ }
+ }
+
+ .notifications-btn {
+ .fa-bell {
+ margin-right: 0;
}
}
}
@@ -298,14 +340,6 @@
vertical-align: top;
margin-top: $gl-padding;
- .count-badge {
- height: $input-height;
-
- .icon {
- top: -1px;
- }
- }
-
.count-badge-count,
.count-badge-button {
border: 1px solid $border-color;
@@ -319,29 +353,25 @@
.count-badge-count {
padding: 0 12px;
- border-right: 0;
- border-radius: $border-radius-base 0 0 $border-radius-base;
background: $gray-light;
+ border-radius: 0 $border-radius-base $border-radius-base 0;
}
.count-badge-button {
- border-radius: 0 $border-radius-base $border-radius-base 0;
+ border-right: 0;
+ border-radius: $border-radius-base 0 0 $border-radius-base;
}
}
.project-clone-holder {
display: inline-block;
- margin: $gl-padding $gl-padding-8 0 0;
+ margin: $gl-padding 0 0;
input {
height: $input-height;
}
}
- .clone-dropdown-btn {
- background-color: $white-light;
- }
-
.clone-options-dropdown {
min-width: 240px;
@@ -355,6 +385,31 @@
}
}
+.project-repo-buttons {
+ .icon {
+ top: 0;
+ }
+
+ .count-badge,
+ .btn-xs {
+ height: 24px;
+ }
+
+ .dropdown-toggle,
+ .clone-dropdown-btn {
+ .fa {
+ color: unset;
+ }
+ }
+
+ .btn {
+ .notifications-icon {
+ top: 1px;
+ margin-right: 0;
+ }
+ }
+}
+
.split-one {
display: inline-table;
margin-right: 12px;
@@ -715,15 +770,16 @@
border-bottom: 1px solid $border-color;
}
-.project-stats {
+.project-stats,
+.project-buttons {
font-size: 0;
text-align: center;
- border-bottom: 1px solid $border-color;
.scrolling-tabs-container {
.scrolling-tabs {
margin-top: $gl-padding-8;
- margin-bottom: $gl-padding-8;
+ margin-bottom: $gl-padding-8 - $browserScrollbarSize;
+ padding-bottom: $browserScrollbarSize;
flex-wrap: wrap;
border-bottom: 0;
}
@@ -731,7 +787,7 @@
.fade-left,
.fade-right {
top: 0;
- height: 100%;
+ height: calc(100% - #{$browserScrollbarSize});
.fa {
top: 50%;
@@ -785,23 +841,43 @@
font-size: $gl-font-size;
line-height: $gl-btn-line-height;
color: $gl-text-color-secondary;
- white-space: nowrap;
+ white-space: pre-wrap;
}
.stat-link {
border-bottom: 0;
+ color: $black;
&:hover,
&:focus {
- color: $gl-text-color;
text-decoration: underline;
border-bottom: 0;
}
+
+ .project-stat-value {
+ color: $gl-text-color;
+ }
+
+ .icon {
+ color: $gl-text-color-secondary;
+ }
+
+ .add-license-link {
+ &,
+ .icon {
+ color: $blue-600;
+ }
+ }
}
.btn {
- padding: $gl-btn-vert-padding $gl-btn-horz-padding;
+ margin-top: $gl-padding;
+ padding: $gl-btn-vert-padding $gl-btn-padding;
line-height: $gl-btn-line-height;
+
+ .icon {
+ top: 0;
+ }
}
.btn-missing {
@@ -810,6 +886,13 @@
}
}
+.project-buttons {
+ .stat-text {
+ @extend .btn;
+ @extend .btn-default;
+ }
+}
+
.repository-languages-bar {
height: 8px;
margin-bottom: $gl-padding-8;
@@ -886,34 +969,73 @@ pre.light-well {
@include basic-list-stats;
display: flex;
align-items: center;
- }
+ color: $gl-text-color-secondary;
+ padding: $gl-padding 0;
- h3 {
- font-size: $gl-font-size;
+ @include media-breakpoint-up(lg) {
+ padding: $gl-padding-24 0;
+ }
+
+ &.no-description {
+ @include media-breakpoint-up(sm) {
+ .avatar-container {
+ align-self: center;
+ }
+
+ .metadata-info {
+ margin-bottom: 0;
+ }
+ }
+ }
}
- .avatar-container,
- .controls {
- flex: 0 0 auto;
+ h2 {
+ font-size: $gl-font-size-medium;
+ font-weight: $gl-font-weight-bold;
+ margin-bottom: 0;
+
+ @include media-breakpoint-up(sm) {
+ .namespace-name {
+ font-weight: $gl-font-weight-normal;
+ }
+ }
}
.avatar-container {
+ flex: 0 0 auto;
align-self: flex-start;
}
.project-details {
min-width: 0;
+ line-height: $gl-line-height;
+
+ .flex-wrapper {
+ min-width: 0;
+ margin-top: -$gl-padding-8; // negative margin required for flex-wrap
+ }
p,
.commit-row-message {
@include str-truncated(100%);
margin-bottom: 0;
}
- }
- .controls {
- margin-left: auto;
- text-align: right;
+ .user-access-role {
+ margin: 0;
+ }
+
+ @include media-breakpoint-up(md) {
+ .description {
+ color: $gl-text-color;
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ .user-access-role {
+ line-height: $gl-line-height-14;
+ }
+ }
}
.ci-status-link {
@@ -925,6 +1047,149 @@ pre.light-well {
text-decoration: none;
}
}
+
+ .controls {
+ margin-top: $gl-padding;
+
+ @include media-breakpoint-down(md) {
+ margin-top: 0;
+ }
+
+ @include media-breakpoint-down(xs) {
+ margin-top: $gl-padding-8;
+ }
+
+ .icon-wrapper {
+ color: inherit;
+ margin-right: $gl-padding;
+
+ @include media-breakpoint-down(md) {
+ margin-right: 0;
+ margin-left: $gl-padding-8;
+ }
+
+ @include media-breakpoint-down(xs) {
+ &:first-child {
+ margin-left: 0;
+ }
+ }
+ }
+
+ .ci-status-link {
+ display: inline-flex;
+ }
+ }
+
+ .star-button {
+ .icon {
+ top: 0;
+ }
+ }
+
+ .icon-container {
+ @include media-breakpoint-down(xs) {
+ margin-right: $gl-padding-8;
+ }
+ }
+
+ &.compact {
+ .project-row {
+ padding: $gl-padding 0;
+ }
+
+ h2 {
+ font-size: $gl-font-size;
+ }
+
+ .avatar-container {
+ @include avatar-size(40px, 10px);
+ min-height: 40px;
+ min-width: 40px;
+
+ .identicon.s64 {
+ font-size: 16px;
+ }
+ }
+
+ .controls {
+ @include media-breakpoint-up(sm) {
+ margin-top: 0;
+ }
+ }
+
+ .updated-note {
+ @include media-breakpoint-up(sm) {
+ margin-top: $gl-padding-8;
+ }
+ }
+
+ .icon-wrapper {
+ margin-left: $gl-padding-8;
+ margin-right: 0;
+
+ @include media-breakpoint-down(xs) {
+ &:first-child {
+ margin-left: 0;
+ }
+ }
+ }
+
+ .user-access-role {
+ line-height: $gl-line-height-14;
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ h2 {
+ font-size: $gl-font-size;
+ }
+
+ .avatar-container {
+ @include avatar-size(40px, 10px);
+ min-height: 40px;
+ min-width: 40px;
+
+ .identicon.s64 {
+ font-size: 16px;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ .updated-note {
+ margin-top: $gl-padding-8;
+ text-align: right;
+ }
+ }
+
+ .forks,
+ .pipeline-status,
+ .updated-note {
+ display: flex;
+ }
+
+ @include media-breakpoint-down(md) {
+ &:not(.explore) {
+ .forks {
+ display: none;
+
+ }
+ }
+
+ &.explore {
+ .pipeline-status,
+ .updated-note {
+ display: none !important;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .updated-note {
+ margin-top: 0;
+ text-align: left;
+ }
+ }
}
.card .projects-list li {
@@ -933,8 +1198,6 @@ pre.light-well {
}
.git-clone-holder {
- width: 320px;
-
.btn-clipboard {
border: 1px solid $border-color;
}
@@ -957,6 +1220,15 @@ pre.light-well {
}
}
+.git-clone-holder,
+.mobile-git-clone {
+ .btn {
+ .icon {
+ fill: $white;
+ }
+ }
+}
+
.cannot-be-merged,
.cannot-be-merged:hover {
color: $red-500;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 04151b1cd59..149c3254d84 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -101,8 +101,6 @@ input[type='checkbox']:hover {
.dropdown-header {
// Necessary because glDropdown doesn't support a second style of headers
font-weight: $gl-font-weight-bold;
- // .dropdown-menu li has 1px side padding
- padding: $gl-padding-8 17px;
color: $gl-text-color;
font-size: $gl-font-size;
line-height: 16px;
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index dc5ca78ff58..a46b8679a42 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -104,11 +104,23 @@
border-bottom: 1px solid $white-normal;
border-top: 1px solid $white-normal;
+ &:last-of-type {
+ border-bottom-color: $white-light;
+ }
+
td,
th {
line-height: 21px;
}
+ th {
+ border-top-color: $gray-light;
+ }
+
+ td {
+ border-color: $border-color;
+ }
+
&:hover:not(.tree-truncated-warning) {
td {
background-color: $blue-50;
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 800f5c68e39..82e887aa62a 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -180,7 +180,7 @@ ul.wiki-pages-list.content-list {
}
}
-.wiki {
+.wiki:not(.use-csslab) {
table {
@include markdown-table;
}
diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb
index 25cc241e5b0..7cd80e8b5e1 100644
--- a/app/controllers/admin/health_check_controller.rb
+++ b/app/controllers/admin/health_check_controller.rb
@@ -2,6 +2,12 @@
class Admin::HealthCheckController < Admin::ApplicationController
def show
- @errors = HealthCheck::Utils.process_checks(['standard'])
+ @errors = HealthCheck::Utils.process_checks(checks)
+ end
+
+ private
+
+ def checks
+ ['standard']
end
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 08d7e3b4fa2..65fe22bd8f4 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -5,23 +5,12 @@ class Admin::ImpersonationsController < Admin::ApplicationController
before_action :authenticate_impersonator!
def destroy
- original_user = current_user
-
- warden.set_user(impersonator, scope: :user)
-
- Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{original_user.username}")
-
- session[:impersonator_id] = nil
-
+ original_user = stop_impersonation
redirect_to admin_user_path(original_user), status: :found
end
private
- def impersonator
- @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id]
- end
-
def authenticate_impersonator!
render_404 unless impersonator && impersonator.admin? && !impersonator.blocked?
end
diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb
index 64d74ae4231..57f7d3e3951 100644
--- a/app/controllers/admin/requests_profiles_controller.rb
+++ b/app/controllers/admin/requests_profiles_controller.rb
@@ -11,7 +11,7 @@ class Admin::RequestsProfilesController < Admin::ApplicationController
profile = Gitlab::RequestProfiler::Profile.find(clean_name)
if profile
- render text: profile.content
+ render html: profile.content
else
redirect_to admin_requests_profiles_path, alert: 'Profile not found'
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index b783c0e2a6f..e93be1c1ba2 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -2,6 +2,7 @@
class Admin::UsersController < Admin::ApplicationController
before_action :user, except: [:index, :new, :create]
+ before_action :check_impersonation_availability, only: :impersonate
def index
@users = User.order_name_asc.filter(params[:filter])
@@ -227,4 +228,8 @@ class Admin::UsersController < Admin::ApplicationController
result[:status] == :success
end
+
+ def check_impersonation_availability
+ access_denied! unless Gitlab.config.gitlab.impersonation_enabled
+ end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 9b40ffb26a2..6f0dc2a3a20 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -8,15 +8,11 @@ class ApplicationController < ActionController::Base
include GitlabRoutingHelper
include PageLayoutHelper
include SafeParamsHelper
- include SentryHelper
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
include WithPerformanceBar
- # this can be removed after switching to rails 5
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/51908
- include InvalidUTF8ErrorHandler unless Gitlab.rails5?
+ include SessionlessAuthentication
- before_action :authenticate_sessionless_user!
before_action :authenticate_user!
before_action :enforce_terms!, if: :should_enforce_terms?
before_action :validate_user_service_ticket!
@@ -28,6 +24,7 @@ class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
before_action :set_usage_stats_consent_flag
+ before_action :check_impersonation_availability
around_action :set_locale
@@ -128,6 +125,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
logged_user = auth_user
@@ -153,17 +151,10 @@ class ApplicationController < ActionController::Base
end
end
- # This filter handles personal access tokens, and atom requests with rss tokens
- def authenticate_sessionless_user!
- user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user
-
- sessionless_sign_in(user) if user
- end
-
def log_exception(exception)
- Raven.capture_exception(exception) if sentry_enabled?
+ Gitlab::Sentry.track_acceptable_exception(exception)
- backtrace_cleaner = Gitlab.rails5? ? request.env["action_dispatch.backtrace_cleaner"] : env
+ backtrace_cleaner = request.env["action_dispatch.backtrace_cleaner"]
application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace
application_trace.map! { |t| " #{t}\n" }
logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}"
@@ -426,25 +417,11 @@ class ApplicationController < ActionController::Base
Gitlab::I18n.with_user_locale(current_user, &block)
end
- def sessionless_sign_in(user)
- if user && can?(user, :log_in)
- # Notice we are passing store false, so the user is not
- # actually stored in the session and a token is needed
- # for every request. If you want the token to work as a
- # sign in token, you can simply remove store: false.
- sign_in(user, store: false, message: :sessionless_sign_in)
- end
- 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'))
end
- def sessionless_user?
- current_user && !session.keys.include?('warden.user.user.key')
- end
-
def peek_request?
request.path.start_with?('/-/peek')
end
@@ -483,4 +460,32 @@ class ApplicationController < ActionController::Base
.new(settings, current_user, application_setting_params)
.execute
end
+
+ def check_impersonation_availability
+ return unless session[:impersonator_id]
+
+ unless Gitlab.config.gitlab.impersonation_enabled
+ stop_impersonation
+ access_denied! _('Impersonation has been disabled')
+ end
+ end
+
+ def stop_impersonation
+ impersonated_user = current_user
+
+ Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{impersonated_user.username}")
+
+ warden.set_user(impersonator, scope: :user)
+ session[:impersonator_id] = nil
+
+ impersonated_user
+ end
+
+ def impersonator
+ @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id]
+ end
+
+ def sentry_context
+ Gitlab::Sentry.context(current_user)
+ end
end
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 7f874687212..0dd7500623d 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -100,18 +100,12 @@ module Boards
.merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
end
+ def serializer
+ IssueSerializer.new(current_user: current_user)
+ end
+
def serialize_as_json(resource)
- resource.as_json(
- only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position, :weight],
- labels: true,
- issue_endpoints: true,
- include_full_project_path: board.group_board?,
- include: {
- project: { only: [:id, :path] },
- assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
- milestone: { only: [:id, :title] }
- }
- )
+ serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?)
end
def whitelist_query_limiting
diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb
index b4f46cddbe9..8d518c14b90 100644
--- a/app/controllers/chaos_controller.rb
+++ b/app/controllers/chaos_controller.rb
@@ -15,7 +15,7 @@ class ChaosController < ActionController::Base
duration_taken = (Time.now - start).seconds
Kernel.sleep duration_s - duration_taken if duration_s > duration_taken
- render text: "OK", content_type: 'text/plain'
+ render plain: "OK"
end
def cpuspin
@@ -24,14 +24,14 @@ class ChaosController < ActionController::Base
rand while Time.now < end_time
- render text: "OK", content_type: 'text/plain'
+ render plain: "OK"
end
def sleep
duration_s = (params[:duration_s]&.to_i || 30).seconds
Kernel.sleep duration_s
- render text: "OK", content_type: 'text/plain'
+ render plain: "OK"
end
def kill
@@ -44,13 +44,13 @@ class ChaosController < ActionController::Base
secret = ENV['GITLAB_CHAOS_SECRET']
# GITLAB_CHAOS_SECRET is required unless you're running in Development mode
if !secret && !Rails.env.development?
- render text: "chaos misconfigured: please configure GITLAB_CHAOS_SECRET when using GITLAB_ENABLE_CHAOS_ENDPOINTS outside of a development environment", content_type: 'text/plain', status: 500
+ render plain: "chaos misconfigured: please configure GITLAB_CHAOS_SECRET when using GITLAB_ENABLE_CHAOS_ENDPOINTS outside of a development environment", status: :internal_server_error
end
return unless secret
unless request.headers["HTTP_X_CHAOS_SECRET"] == secret
- render text: "To experience chaos, please set X-Chaos-Secret header", content_type: 'text/plain', status: 401
+ render plain: "To experience chaos, please set X-Chaos-Secret header", status: :unauthorized
end
end
end
diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb
index 250f42f3096..c4e7fc950f9 100644
--- a/app/controllers/clusters/applications_controller.rb
+++ b/app/controllers/clusters/applications_controller.rb
@@ -23,6 +23,6 @@ class Clusters::ApplicationsController < Clusters::BaseController
end
def create_cluster_application_params
- params.permit(:application, :hostname)
+ params.permit(:application, :hostname, :email)
end
end
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 2e9c77ae55c..b9717b97640 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -18,8 +18,20 @@ class Clusters::ClustersController < Clusters::BaseController
STATUS_POLLING_INTERVAL = 10_000
def index
- clusters = ClustersFinder.new(clusterable, current_user, :all).execute
- @clusters = clusters.page(params[:page]).per(20)
+ finder = ClusterAncestorsFinder.new(clusterable.subject, current_user)
+ clusters = finder.execute
+
+ # Note: We are paginating through an array here but this should OK as:
+ #
+ # In CE, we can have a maximum group nesting depth of 21, so including
+ # project cluster, we can have max 22 clusters for a group hierachy.
+ # In EE (Premium) we can have any number, as multiple clusters are
+ # supported, but the number of clusters are fairly low currently.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/55260 also.
+ @clusters = Kaminari.paginate_array(clusters).page(params[:page]).per(20)
+
+ @has_ancestor_clusters = finder.has_ancestor_clusters?
end
def new
@@ -181,15 +193,15 @@ class Clusters::ClustersController < Clusters::BaseController
end
def gcp_cluster
- @gcp_cluster = ::Clusters::Cluster.new.tap do |cluster|
- cluster.build_provider_gcp
- end.present(current_user: current_user)
+ cluster = Clusters::BuildService.new(clusterable.subject).execute
+ cluster.build_provider_gcp
+ @gcp_cluster = cluster.present(current_user: current_user)
end
def user_cluster
- @user_cluster = ::Clusters::Cluster.new.tap do |cluster|
- cluster.build_platform_kubernetes
- end.present(current_user: current_user)
+ cluster = Clusters::BuildService.new(clusterable.subject).execute
+ cluster.build_platform_kubernetes
+ @user_cluster = cluster.present(current_user: current_user)
end
def validate_gcp_token
diff --git a/app/controllers/concerns/invalid_utf8_error_handler.rb b/app/controllers/concerns/invalid_utf8_error_handler.rb
deleted file mode 100644
index 44c6d6b0da0..00000000000
--- a/app/controllers/concerns/invalid_utf8_error_handler.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module InvalidUTF8ErrorHandler
- extend ActiveSupport::Concern
-
- included do
- rescue_from ArgumentError, with: :handle_invalid_utf8
- end
-
- private
-
- def handle_invalid_utf8(error)
- if error.message == "invalid byte sequence in UTF-8"
- render_412
- else
- raise(error)
- end
- end
-
- def render_412
- respond_to do |format|
- format.html { render "errors/precondition_failed", layout: "errors", status: 412 }
- format.js { render json: { error: 'Invalid UTF-8' }, status: :precondition_failed, content_type: 'application/json' }
- format.any { head :precondition_failed }
- end
- end
-end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 34a8c50fcbd..789e0dc736e 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -91,7 +91,7 @@ module IssuableCollections
options = {
scope: params[:scope],
state: params[:state],
- sort: set_sort_order_from_cookie || default_sort_order
+ sort: set_sort_order
}
# Used by view to highlight active option
@@ -102,7 +102,7 @@ module IssuableCollections
elsif @group
options[:group_id] = @group.id
options[:include_subgroups] = true
- options[:use_cte_for_search] = true
+ options[:attempt_group_search_optimizations] = true
end
params.permit(finder_type.valid_params).merge(options)
@@ -113,6 +113,34 @@ module IssuableCollections
'opened'
end
+ def set_sort_order
+ set_sort_order_from_user_preference || set_sort_order_from_cookie || default_sort_order
+ end
+
+ def set_sort_order_from_user_preference
+ return unless current_user
+ return unless issuable_sorting_field
+
+ user_preference = current_user.user_preference
+
+ sort_param = params[:sort]
+ sort_param ||= user_preference[issuable_sorting_field]
+
+ return sort_param if Gitlab::Database.read_only?
+
+ if user_preference[issuable_sorting_field] != sort_param
+ user_preference.update_attribute(issuable_sorting_field, sort_param)
+ end
+
+ sort_param
+ end
+
+ # Implement default_sorting_field method on controllers
+ # to choose which column to store the sorting parameter.
+ def issuable_sorting_field
+ nil
+ end
+
def set_sort_order_from_cookie
sort_param = params[:sort] if params[:sort].present?
# fallback to legacy cookie value for backward compatibility
@@ -141,12 +169,6 @@ module IssuableCollections
case value
when 'id_asc' then sort_value_oldest_created
when 'id_desc' then sort_value_recently_created
- when 'created_asc' then sort_value_created_date
- when 'created_desc' then sort_value_created_date
- when 'due_date_asc' then sort_value_due_date
- when 'due_date_desc' then sort_value_due_date
- when 'milestone_due_asc' then sort_value_milestone
- when 'milestone_due_desc' then sort_value_milestone
when 'downvotes_asc' then sort_value_popularity
when 'downvotes_desc' then sort_value_popularity
else value
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 9576eb14fdd..5572c3cee2d 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -94,6 +94,7 @@ module LfsRequest
def lfs_upload_access?
return false unless project.lfs_enabled?
return false unless has_authentication_ability?(:push_code)
+ return false if limit_exceeded?
lfs_deploy_token? || can?(user, :push_code, project)
end
@@ -121,4 +122,9 @@ module LfsRequest
def has_authentication_ability?(capability)
(authentication_abilities || []).include?(capability)
end
+
+ # Overriden in EE
+ def limit_exceeded?
+ false
+ end
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 777b147e2dd..0319948a12f 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -6,6 +6,7 @@ module NotesActions
extend ActiveSupport::Concern
included do
+ prepend_before_action :normalize_create_params, only: [:create]
before_action :set_polling_interval_header, only: [:index]
before_action :require_noteable!, only: [:index, :create]
before_action :authorize_admin_note!, only: [:update, :destroy]
@@ -247,6 +248,15 @@ module NotesActions
DiscussionSerializer.new(project: project, noteable: noteable, current_user: current_user, note_entity: ProjectNoteEntity)
end
+ # Avoids checking permissions in the wrong object - this ensures that the object we checked permissions for
+ # is the object we're actually creating a note in.
+ def normalize_create_params
+ params[:note].try do |note|
+ note[:noteable_id] = params[:target_id]
+ note[:noteable_type] = params[:target_type].classify
+ end
+ end
+
def note_project
strong_memoize(:note_project) do
next nil unless project
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index c61b9fabe9e..4b0f0b8255c 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -12,7 +12,7 @@ module PreviewMarkdown
when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
when 'snippets' then { skip_project_check: true }
when 'groups' then { group: group }
- when 'projects' then { issuable_state_filter_enabled: true }
+ when 'projects' then projects_filter_params
else {}
end
@@ -22,9 +22,17 @@ module PreviewMarkdown
body: view_context.markdown(result[:text], markdown_params),
references: {
users: result[:users],
+ suggestions: result[:suggestions],
commands: view_context.markdown(result[:commands])
}
}
end
+
+ def projects_filter_params
+ {
+ issuable_state_filter_enabled: true,
+ suggestions_filter_enabled: params[:preview_suggestions].present?
+ }
+ end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb
index f48e0586211..ed9b898a2a3 100644
--- a/app/controllers/concerns/renders_commits.rb
+++ b/app/controllers/concerns/renders_commits.rb
@@ -26,4 +26,10 @@ module RendersCommits
commits
end
+
+ def valid_ref?(ref_name)
+ return true unless ref_name.present?
+
+ Gitlab::GitRefValidator.validate(ref_name)
+ end
end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 8bd93a349ef..c6ae4fe15bf 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -70,7 +70,7 @@ module ServiceParams
def service_params
dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables
- service_params = params.permit(:id, service: ALLOWED_PARAMS_CE + dynamic_params)
+ service_params = params.permit(:id, service: allowed_service_params + dynamic_params)
if service_params[:service].is_a?(Hash)
FILTER_BLANK_PARAMS.each do |param|
@@ -80,4 +80,8 @@ module ServiceParams
service_params
end
+
+ def allowed_service_params
+ ALLOWED_PARAMS_CE
+ end
end
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
new file mode 100644
index 00000000000..590eefc6dab
--- /dev/null
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# == SessionlessAuthentication
+#
+# Controller concern to handle PAT and RSS token authentication methods
+#
+module SessionlessAuthentication
+ # This filter handles personal access tokens, and atom requests with rss tokens
+ def authenticate_sessionless_user!(request_format)
+ user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format)
+
+ sessionless_sign_in(user) if user
+ end
+
+ def sessionless_user?
+ current_user && !session.keys.include?('warden.user.user.key')
+ end
+
+ def sessionless_sign_in(user)
+ if user && can?(user, :log_in)
+ # Notice we are passing store false, so the user is not
+ # actually stored in the session and a token is needed
+ # for every request. If you want the token to work as a
+ # sign in token, you can simply remove store: false.
+ sign_in(user, store: false, message: :sessionless_sign_in)
+ end
+ end
+end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 8c22490700c..014232a7d05 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -10,6 +10,8 @@ module SnippetsActions
def raw
disposition = params[:inline] == 'false' ? 'attachment' : 'inline'
+ workhorse_set_content_type!
+
send_data(
convert_line_endings(@snippet.content),
type: 'text/plain; charset=utf-8',
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 7a1c7abfb8f..0eea0cdd50f 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -1,17 +1,11 @@
# frozen_string_literal: true
module UploadsActions
- extend ActiveSupport::Concern
-
include Gitlab::Utils::StrongMemoize
include SendFileUpload
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
- included do
- prepend_before_action :set_html_format, only: :show
- end
-
def create
link_to_file = UploadService.new(model, params[:file], uploader_class).execute
@@ -44,6 +38,7 @@ module UploadsActions
return render_404 unless uploader
+ workhorse_set_content_type!
send_upload(uploader, attachment: uploader.filename, disposition: disposition)
end
@@ -61,13 +56,6 @@ module UploadsActions
private
- # Explicitly set the format.
- # Otherwise rails 5 will set it from a file extension.
- # See https://github.com/rails/rails/commit/84e8accd6fb83031e4c27e44925d7596655285f7#diff-2b8f2fbb113b55ca8e16001c393da8f1
- def set_html_format
- request.format = :html
- end
-
def uploader_class
raise NotImplementedError
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index e9686ed8d06..f073b6de444 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -4,6 +4,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
include ParamsBackwardCompatibility
include RendersMemberAccess
+ prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
before_action :set_non_archived_param
before_action :default_sorting
skip_cross_project_access_check :index, :starred
@@ -55,7 +56,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
projects = ProjectsFinder
.new(params: finder_params, current_user: current_user)
.execute
- .includes(:route, :creator, namespace: [:route, :owner])
+ .includes(:route, :creator, :group, namespace: [:route, :owner])
.page(finder_params[:page])
prepare_projects_for_rendering(projects)
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index b82caf30a91..3fa582cf25b 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -4,6 +4,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
include ActionView::Helpers::NumberHelper
before_action :authorize_read_project!, only: :index
+ before_action :authorize_read_group!, only: :index
before_action :find_todos, only: [:index, :destroy_all]
def index
@@ -60,6 +61,15 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
end
+ def authorize_read_group!
+ group_id = params[:group_id]
+
+ if group_id.present?
+ group = Group.find(group_id)
+ render_404 unless can?(current_user, :read_group, group)
+ end
+ end
+
def find_todos
@todos ||= TodosFinder.new(current_user, todo_params).execute
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 4ce9be44403..be2d9512c01 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -4,6 +4,9 @@ class DashboardController < Dashboard::ApplicationController
include IssuesAction
include MergeRequestsAction
+ prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) }
+ prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) }
+
before_action :event_filter, only: :activity
before_action :projects, only: [:issues, :merge_requests]
before_action :set_show_full_reference, only: [:issues, :merge_requests]
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 7ecbc32cf4e..778fdda8dbd 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -57,7 +57,7 @@ class Explore::ProjectsController < Explore::ApplicationController
def load_projects
projects = ProjectsFinder.new(current_user: current_user, params: params)
.execute
- .includes(:route, namespace: :route)
+ .includes(:route, :creator, :group, namespace: [:route, :owner])
.page(params[:page])
.without_count
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index a1ec144410b..3ef03bc9622 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -3,6 +3,7 @@
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) }
before_action :check_graphql_feature_flag!
@@ -42,6 +43,6 @@ class GraphqlController < ApplicationController
end
def check_graphql_feature_flag!
- render_404 unless Feature.enabled?(:graphql)
+ render_404 unless Gitlab::Graphql.enabled?
end
end
diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb
index 50c44b7a58b..b846fb21266 100644
--- a/app/controllers/groups/clusters_controller.rb
+++ b/app/controllers/groups/clusters_controller.rb
@@ -3,8 +3,8 @@
class Groups::ClustersController < Clusters::ClustersController
include ControllerWithCrossProjectAccessCheck
- prepend_before_action :check_group_clusters_feature_flag!
prepend_before_action :group
+ prepend_before_action :check_group_clusters_feature_flag!
requires_cross_project_access
layout 'group'
@@ -20,6 +20,10 @@ class Groups::ClustersController < Clusters::ClustersController
end
def check_group_clusters_feature_flag!
- render_404 unless Feature.enabled?(:group_clusters)
+ render_404 unless group_clusters_enabled?
+ end
+
+ def group_clusters_enabled?
+ group.group_clusters_enabled?
end
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 062c8c4e9e1..c5d8ac2ed77 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -9,6 +9,9 @@ class GroupsController < Groups::ApplicationController
respond_to :html
+ prepend_before_action(only: [:show, :issues]) { authenticate_sessionless_user!(:rss) }
+ prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) }
+
before_action :authenticate_user!, only: [:new, :create]
before_action :group, except: [:index, :new, :create]
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 58565aaf8c9..d4c26fa0709 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -7,7 +7,7 @@ class Import::GithubController < Import::BaseController
rescue_from Octokit::Unauthorized, with: :provider_unauthorized
def new
- if logged_in_with_provider?
+ if github_import_configured? && logged_in_with_provider?
go_to_provider_for_permissions
elsif session[access_token_key]
redirect_to status_import_url
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index 7353be478e1..c2089a0fca3 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -15,7 +15,7 @@ class MetricsController < ActionController::Base
"# Metrics are disabled, see: #{help_page}\n"
end
- render text: response, content_type: 'text/plain; version=0.0.4'
+ render plain: response, content_type: 'text/plain; version=0.0.4'
end
private
diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb
index 84dce74ace8..384f308269a 100644
--- a/app/controllers/notification_settings_controller.rb
+++ b/app/controllers/notification_settings_controller.rb
@@ -16,7 +16,11 @@ class NotificationSettingsController < ApplicationController
@notification_setting = current_user.notification_settings.find(params[:id])
@saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source))
- render_response
+ if params[:hide_label].present?
+ render_response("projects/buttons/_notifications")
+ else
+ render_response
+ end
end
private
@@ -37,9 +41,9 @@ class NotificationSettingsController < ApplicationController
can?(current_user, ability_name, resource)
end
- def render_response
+ def render_response(response_template = "shared/notifications/_button")
render json: {
- html: view_to_html_string("shared/notifications/_button", notification_setting: @notification_setting),
+ html: view_to_html_string(response_template, notification_setting: @notification_setting),
saved: @saved
}
end
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index b50f140dc80..ab4ca56bb49 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -9,7 +9,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
before_action :verify_user_oauth_applications_enabled, except: :index
before_action :authenticate_user!
before_action :add_gon_variables
- before_action :load_scopes, only: [:index, :create, :edit]
+ before_action :load_scopes, only: [:index, :create, :edit, :update]
helper_method :can?
diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb
index cb3180f4196..b0d65f284af 100644
--- a/app/controllers/profiles/accounts_controller.rb
+++ b/app/controllers/profiles/accounts_controller.rb
@@ -4,7 +4,7 @@ class Profiles::AccountsController < Profiles::ApplicationController
include AuthHelper
def show
- @user = current_user
+ render(locals: show_view_variables)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -23,4 +23,10 @@ class Profiles::AccountsController < Profiles::ApplicationController
redirect_to profile_account_path
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def show_view_variables
+ {}
+ end
end
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index 912421e3d08..055d900eece 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -40,13 +40,12 @@ class Profiles::KeysController < Profiles::ApplicationController
begin
user = UserFinder.new(params[:username]).find_by_username
if user.present?
- headers['Content-Disposition'] = 'attachment'
- render text: user.all_ssh_keys.join("\n"), content_type: 'text/plain'
+ render plain: user.all_ssh_keys.join("\n")
else
return render_404
end
rescue => e
- render text: e.message
+ render html: e.message
end
else
return render_404
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index ae9c17802b9..1a91e07b97f 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -9,7 +9,6 @@ class Projects::ArtifactsController < Projects::ApplicationController
before_action :authorize_read_build!
before_action :authorize_update_build!, only: [:keep]
before_action :extract_ref_name_and_path
- before_action :set_request_format, only: [:file]
before_action :validate_artifacts!, except: [:download]
before_action :entry, only: [:file]
@@ -110,12 +109,4 @@ class Projects::ArtifactsController < Projects::ApplicationController
render_404 unless @entry.exists?
end
-
- def set_request_format
- request.format = :html if set_request_format?
- end
-
- def set_request_format?
- request.format != :json
- end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 873c96a5523..60fabd15333 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -9,7 +9,6 @@ class Projects::BlobController < Projects::ApplicationController
include ActionView::Helpers::SanitizeHelper
prepend_before_action :authenticate_user!, only: [:edit]
- before_action :set_request_format, only: [:edit, :show, :update, :destroy]
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
@@ -242,18 +241,6 @@ class Projects::BlobController < Projects::ApplicationController
.last_for_path(@repository, @ref, @path).sha
end
- # In Rails 4.2 if params[:format] is empty, Rails set it to :html
- # But since Rails 5.0 the framework now looks for an extension.
- # E.g. for `blob/master/CHANGELOG.md` in Rails 4 the format would be `:html`, but in Rails 5 on it'd be `:md`
- # This before_action explicitly sets the `:html` format for all requests unless `:format` is set by a client e.g. by JS for XHR requests.
- def set_request_format
- request.format = :html if set_request_format?
- end
-
- def set_request_format?
- params[:id].present? && params[:format].blank? && request.format != "json"
- end
-
def show_html
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 95a014d24da..a6bfb913900 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -22,7 +22,7 @@ class Projects::BranchesController < Projects::ApplicationController
# Fetch branches for the specified mode
fetch_branches_by_mode
- @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name))
+ @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
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 84a2a461da7..2510a31c9b3 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -6,12 +6,13 @@ class Projects::CommitsController < Projects::ApplicationController
include ExtractsPath
include RendersCommits
+ prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :whitelist_query_limiting, except: :commits_root
before_action :require_non_empty_project
before_action :assign_ref_vars, except: :commits_root
before_action :authorize_download_code!
+ before_action :validate_ref!, except: :commits_root
before_action :set_commits, except: :commits_root
- before_action :set_request_format, only: :show
def commits_root
redirect_to project_commits_path(@project, @project.default_branch)
@@ -54,6 +55,10 @@ class Projects::CommitsController < Projects::ApplicationController
private
+ def validate_ref!
+ render_404 unless valid_ref?(@ref)
+ end
+
def set_commits
render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
@limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
@@ -70,19 +75,6 @@ class Projects::CommitsController < Projects::ApplicationController
@commits = set_commits_for_rendering(@commits)
end
- # Rails 5 sets request.format from the extension.
- # Explicitly set to :html.
- def set_request_format
- request.format = :html if set_request_format?
- end
-
- # Rails 5 sets request.format from extension.
- # In this case if the ref ends with `.atom`, it's expected to be the html response,
- # not the atom one. So explicitly set request.format as :html to act like rails4.
- def set_request_format?
- request.format.to_s == "text/html" || @commits.ref.ends_with?("atom")
- end
-
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42330')
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 2917925947f..5586c2fc631 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -65,12 +65,6 @@ class Projects::CompareController < Projects::ApplicationController
private
- def valid_ref?(ref_name)
- return true unless ref_name.present?
-
- Gitlab::GitRefValidator.validate(ref_name)
- end
-
def validate_refs!
valid = [head_ref, start_ref].map { |ref| valid_ref?(ref) }
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 0a593bd35b6..6824a07dc76 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -24,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create
- @key = DeployKeys::CreateService.new(current_user, create_params).execute
+ @key = DeployKeys::CreateService.new(current_user, create_params).execute(project: @project)
unless @key.valid?
flash[:alert] = @key.errors.full_messages.join(', ').html_safe
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index de10783df1a..a63eea0ca0e 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -11,6 +11,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
+ before_action do
+ push_frontend_feature_flag(:area_chart, project)
+ end
+
def index
@environments = project.environments
.with_state(params[:scope] || :available)
@@ -122,7 +126,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
set_workhorse_internal_api_content_type
render json: Gitlab::Workhorse.terminal_websocket(terminal)
else
- render text: 'Not found', status: :not_found
+ render html: 'Not found', status: :not_found
end
end
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index e55065c5817..8b33fa85c1e 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -13,10 +13,8 @@ class Projects::ImportsController < Projects::ApplicationController
end
def create
- @project.import_url = params[:project][:import_url]
-
- if @project.save
- @project.reload.import_schedule
+ if @project.update(import_params)
+ @project.import_state.reload.schedule
end
redirect_to project_import_path(@project)
@@ -24,7 +22,7 @@ class Projects::ImportsController < Projects::ApplicationController
def show
if @project.import_finished?
- if continue_params
+ if continue_params&.key?(:to)
redirect_to continue_params[:to], notice: continue_params[:notice]
else
redirect_to project_path(@project), notice: finished_notice
@@ -67,4 +65,12 @@ class Projects::ImportsController < Projects::ApplicationController
redirect_to project_path(@project)
end
end
+
+ def import_params_attributes
+ [:import_url]
+ end
+
+ def import_params
+ params.require(:project).permit(import_params_attributes)
+ end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index d6d7110355b..5ed46fc0545 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -9,10 +9,6 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuesCalendar
include SpammableActions
- def self.authenticate_user_only_actions
- %i[new]
- end
-
def self.issue_except_actions
%i[index calendar new create bulk_update]
end
@@ -21,7 +17,10 @@ class Projects::IssuesController < Projects::ApplicationController
%i[index calendar]
end
- prepend_before_action :authenticate_user!, only: authenticate_user_only_actions
+ prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
+ prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
+ prepend_before_action :authenticate_new_issue!, only: [:new]
+ prepend_before_action :store_uri, only: [:new, :show]
before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
@@ -232,16 +231,18 @@ class Projects::IssuesController < Projects::ApplicationController
] + [{ label_ids: [], assignee_ids: [] }]
end
- def authenticate_user!
+ def authenticate_new_issue!
return if current_user
notice = "Please sign in to create the new issue."
+ redirect_to new_user_session_path, notice: notice
+ end
+
+ def store_uri
if request.get? && !request.xhr?
store_location_for :user, request.fullpath
end
-
- redirect_to new_user_session_path, notice: notice
end
def serializer
@@ -267,7 +268,6 @@ class Projects::IssuesController < Projects::ApplicationController
end
def set_suggested_issues_feature_flags
- push_frontend_feature_flag(:graphql)
- push_frontend_feature_flag(:issue_suggestions)
+ push_frontend_feature_flag(:graphql, default_enabled: true)
end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 3ecf94c008e..bfbbcba883f 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -9,7 +9,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :cancel_all, :erase]
before_action :authorize_erase_build!, only: [:erase]
- before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_workhorse_authorize]
+ before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
layout 'project'
@@ -140,15 +140,22 @@ class Projects::JobsController < Projects::ApplicationController
def raw
if trace_artifact_file
+ workhorse_set_content_type!
send_upload(trace_artifact_file,
send_params: raw_send_params,
redirect_params: raw_redirect_params)
else
build.trace.read do |stream|
if stream.file?
+ workhorse_set_content_type!
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
- send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log'
+ # In this case we can't use workhorse_set_content_type! and let
+ # Workhorse handle the response because the data is streamed directly
+ # to the user but, because we have the trace content, we can calculate
+ # the proper content type and disposition here.
+ raw_data = stream.raw
+ send_data raw_data, type: 'text/plain; charset=utf-8', disposition: raw_trace_content_disposition(raw_data), filename: 'job.log'
end
end
end
@@ -201,4 +208,13 @@ class Projects::JobsController < Projects::ApplicationController
def build_path(build)
project_job_path(build.project, build)
end
+
+ def raw_trace_content_disposition(raw_data)
+ mime_type = MimeMagic.by_magic(raw_data)
+
+ # if mime_type is nil can also represent 'text/plain'
+ return 'inline' if mime_type.nil? || mime_type.type == 'text/plain'
+
+ 'attachment'
+ end
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index b3d77335c2a..ddffbb17ace 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -22,12 +22,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
def render_diffs
@environment = @merge_request.environments_for(current_user).last
- notes_grouped_by_path = renderable_notes.group_by { |note| note.position.file_path }
- @diffs.diff_files.each do |diff_file|
- notes = notes_grouped_by_path.fetch(diff_file.file_path, [])
- notes.each { |note| diff_file.unfold_diff_lines(note.position) }
- end
+ note_positions = renderable_notes.map(&:position).compact
+ @diffs.unfold_diff_files(note_positions)
@diffs.write_cache
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index d521db79f85..da9316d5f22 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -122,17 +122,21 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
respond_to do |format|
format.html do
- if @merge_request.valid?
- redirect_to([@merge_request.target_project.namespace.becomes(Namespace), @merge_request.target_project, @merge_request])
- else
+ if @merge_request.errors.present?
define_edit_vars
render :edit
+ else
+ redirect_to project_merge_request_path(@merge_request.target_project, @merge_request)
end
end
format.json do
- render json: serializer.represent(@merge_request, serializer: 'basic')
+ if merge_request.errors.present?
+ render json: @merge_request.errors, status: :bad_request
+ else
+ render json: serializer.represent(@merge_request, serializer: 'basic')
+ end
end
end
rescue ActiveRecord::StaleObjectError
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 20998c97730..8e68014a30d 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -11,7 +11,10 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :authorize_read_milestone!
# Allow admin milestone
- before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote]
+ before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels]
+
+ # Allow to promote milestone
+ before_action :authorize_promote_milestone!, only: :promote
respond_to :html
@@ -78,7 +81,7 @@ class Projects::MilestonesController < Projects::ApplicationController
def promote
promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone)
- flash[:notice] = flash_notice_for(promoted_milestone, project.group)
+ flash[:notice] = flash_notice_for(promoted_milestone, project_group)
respond_to do |format|
format.html do
@@ -109,6 +112,12 @@ class Projects::MilestonesController < Projects::ApplicationController
protected
+ def project_group
+ strong_memoize(:project_group) do
+ project.group
+ end
+ end
+
def milestones
strong_memoize(:milestones) do
MilestonesFinder.new(search_params).execute
@@ -125,13 +134,17 @@ class Projects::MilestonesController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_milestone, @project)
end
+ def authorize_promote_milestone!
+ return render_404 unless can?(current_user, :admin_milestone, project_group)
+ end
+
def milestone_params
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
def search_params
- if request.format.json? && @project.group && can?(current_user, :read_group, @project.group)
- groups = @project.group.self_and_ancestors_ids
+ if request.format.json? && project_group && can?(current_user, :read_group, project_group)
+ groups = project_group.self_and_ancestors_ids
end
params.permit(:state).merge(project_ids: @project.id, group_ids: groups)
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 53b29d4146e..67827b1d3bb 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -46,7 +46,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def new
- @pipeline = project.pipelines.new(ref: @project.default_branch)
+ @pipeline = project.all_pipelines.new(ref: @project.default_branch)
end
def create
@@ -142,9 +142,9 @@ class Projects::PipelinesController < Projects::ApplicationController
@charts[:pipeline_times] = Gitlab::Ci::Charts::PipelineTime.new(project)
@counts = {}
- @counts[:total] = @project.pipelines.count(:all)
- @counts[:success] = @project.pipelines.success.count(:all)
- @counts[:failed] = @project.pipelines.failed.count(:all)
+ @counts[:total] = @project.all_pipelines.count(:all)
+ @counts[:success] = @project.all_pipelines.success.count(:all)
+ @counts[:failed] = @project.all_pipelines.failed.count(:all)
end
private
@@ -164,7 +164,7 @@ class Projects::PipelinesController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def pipeline
@pipeline ||= project
- .pipelines
+ .all_pipelines
.includes(user: :status)
.find_by!(id: params[:id])
.present(current_user: current_user)
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index a860be83e95..c5454883060 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -15,6 +15,10 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
@protected_ref = @project.protected_branches.find(params[:id])
end
+ def access_levels
+ [:merge_access_levels, :push_access_levels]
+ end
+
def protected_ref_params
params.require(:protected_branch).permit(:name,
merge_access_levels_attributes: access_level_attributes,
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 3a3a29ddd0d..4e2a9df5576 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -32,7 +32,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
@protected_ref = update_service_class.new(@project, current_user, protected_ref_params).execute(@protected_ref)
if @protected_ref.valid?
- render json: @protected_ref, status: :ok
+ render json: @protected_ref, status: :ok, include: access_levels
else
render json: @protected_ref.errors, status: :unprocessable_entity
end
@@ -62,6 +62,6 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
end
def access_level_attributes
- %i(access_level id)
+ %i[access_level id]
end
end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
index 01cedba95ac..41191639c2b 100644
--- a/app/controllers/projects/protected_tags_controller.rb
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -15,6 +15,10 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
@protected_ref = @project.protected_tags.find(params[:id])
end
+ def access_levels
+ [:create_access_levels]
+ end
+
def protected_ref_params
params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes)
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 55827075896..58d5ea4762f 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -4,39 +4,16 @@ class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :authorize_download_code!
- before_action :authorize_push_code!
- before_action :tag
- before_action :release
+ before_action :check_releases_page_feature_flag
- def edit
- 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)
+ def index
end
private
- def tag
- @tag ||= @repository.find_tag(params[:tag_id])
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def release
- @release ||= @project.releases.find_or_initialize_by(tag: @tag.name)
- end
- # rubocop: enable CodeReuse/ActiveRecord
+ def check_releases_page_feature_flag
+ return render_404 unless Feature.enabled?(:releases_page)
- def release_params
- params.require(:release).permit(:description)
+ push_frontend_feature_flag(:releases_page)
end
end
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
new file mode 100644
index 00000000000..0af2b7ef343
--- /dev/null
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Projects
+ module Serverless
+ class FunctionsController < Projects::ApplicationController
+ include ProjectUnauthorized
+
+ before_action :authorize_read_cluster!
+
+ INDEX_PRIMING_INTERVAL = 10_000
+ INDEX_POLLING_INTERVAL = 30_000
+
+ def index
+ finder = Projects::Serverless::FunctionsFinder.new(project.clusters)
+
+ respond_to do |format|
+ format.json do
+ functions = finder.execute
+
+ if functions.any?
+ Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
+ render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions)
+ else
+ Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
+ head :no_content
+ end
+ end
+
+ format.html do
+ @installed = finder.installed?
+ render
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 1d76c90d4eb..ac3004d069f 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -20,6 +20,20 @@ module Projects
render_show
end
+ def cleanup
+ cleanup_params = params.require(:project).permit(:bfg_object_map)
+ result = Projects::UpdateService.new(project, current_user, cleanup_params).execute
+
+ if result[:status] == :success
+ RepositoryCleanupWorker.perform_async(project.id, current_user.id)
+ flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.')
+ else
+ flash[:alert] = _('Failed to upload object map file')
+ end
+
+ redirect_to project_settings_repository_path(project)
+ end
+
private
def render_show
diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb
new file mode 100644
index 00000000000..334e1847cc8
--- /dev/null
+++ b/app/controllers/projects/tags/releases_controller.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class Projects::Tags::ReleasesController < Projects::ApplicationController
+ # Authorize
+ before_action :require_non_empty_project
+ before_action :authorize_download_code!
+ before_action :authorize_push_code!
+ before_action :tag
+ before_action :release
+
+ def edit
+ 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)
+ end
+
+ private
+
+ def tag
+ @tag ||= @repository.find_tag(params[:tag_id])
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def release
+ @release ||= @project.releases.find_or_initialize_by(tag: @tag.name)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def release_params
+ params.require(:release).permit(:description)
+ end
+end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index c8442ff3592..a50a1475eb2 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -3,6 +3,8 @@
class Projects::TagsController < Projects::ApplicationController
include SortingHelper
+ prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
+
# Authorize
before_action :require_non_empty_project
before_action :authorize_download_code!
@@ -18,7 +20,7 @@ class Projects::TagsController < Projects::ApplicationController
@tags = Kaminari.paginate_array(@tags).page(params[:page])
tag_names = @tags.map(&:name)
- @tags_pipelines = @project.pipelines.latest_successful_for_refs(tag_names)
+ @tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names)
@releases = project.releases.where(tag: tag_names)
respond_to do |format|
@@ -40,7 +42,7 @@ class Projects::TagsController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def create
- result = Tags::CreateService.new(@project, current_user)
+ result = ::Tags::CreateService.new(@project, current_user)
.execute(params[:tag_name], params[:ref], params[:message], params[:release_description])
if result[:status] == :success
@@ -56,7 +58,7 @@ class Projects::TagsController < Projects::ApplicationController
end
def destroy
- result = Tags::DestroyService.new(project, current_user).execute(params[:id])
+ result = ::Tags::DestroyService.new(project, current_user).execute(params[:id])
respond_to do |format|
if result[:status] == :success
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 7f4a9f5151b..8bf93bfd68d 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -7,6 +7,8 @@ class ProjectsController < Projects::ApplicationController
include PreviewMarkdown
include SendFileUpload
+ prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
+
before_action :whitelist_query_limiting, only: [:create]
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
before_action :redirect_git_extension, only: [:show]
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 5b70c69d7f4..072d62ddf38 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -14,6 +14,7 @@ class UsersController < ApplicationController
calendar_activities: true
skip_before_action :authenticate_user!
+ prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists]
before_action :authorize_read_user_profile!,
only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :snippets]
@@ -57,11 +58,13 @@ class UsersController < ApplicationController
load_projects
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
+ skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
+ compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode])
respond_to do |format|
format.html { render 'show' }
format.json do
- pager_json("shared/projects/_list", @projects.count, projects: @projects, skip_pagination: skip_pagination)
+ pager_json("shared/projects/_list", @projects.count, projects: @projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
end
end
end
diff --git a/app/finders/cluster_ancestors_finder.rb b/app/finders/cluster_ancestors_finder.rb
new file mode 100644
index 00000000000..2f9709ee057
--- /dev/null
+++ b/app/finders/cluster_ancestors_finder.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ClusterAncestorsFinder
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(clusterable, current_user)
+ @clusterable = clusterable
+ @current_user = current_user
+ end
+
+ def execute
+ return [] unless can_read_clusters?
+
+ clusterable.clusters + ancestor_clusters
+ end
+
+ def has_ancestor_clusters?
+ ancestor_clusters.any?
+ end
+
+ private
+
+ attr_reader :clusterable, :current_user
+
+ def can_read_clusters?
+ Ability.allowed?(current_user, :read_cluster, clusterable)
+ end
+
+ # This unfortunately returns an Array, not a Relation!
+ def ancestor_clusters
+ strong_memoize(:ancestor_clusters) do
+ Clusters::Cluster.ancestor_clusters_for_clusterable(clusterable)
+ end
+ end
+end
diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb
index 220f62bcc7f..06ebb286086 100644
--- a/app/finders/concerns/finder_with_cross_project_access.rb
+++ b/app/finders/concerns/finder_with_cross_project_access.rb
@@ -5,7 +5,8 @@
#
# This module depends on the finder implementing the following methods:
#
-# - `#execute` should return an `ActiveRecord::Relation`
+# - `#execute` should return an `ActiveRecord::Relation` or the `model` needs to
+# be defined in the call to `requires_cross_project_access`.
# - `#current_user` the user that requires access (or nil)
module FinderWithCrossProjectAccess
extend ActiveSupport::Concern
@@ -13,20 +14,35 @@ module FinderWithCrossProjectAccess
prepended do
extend Gitlab::CrossProjectAccess::ClassMethods
+
+ cattr_accessor :finder_model
+
+ def self.requires_cross_project_access(*args)
+ super
+
+ self.finder_model = extract_model_from_arguments(args)
+ end
+
+ private
+
+ def self.extract_model_from_arguments(args)
+ args.detect { |argument| argument.is_a?(Hash) && argument[:model] }
+ &.fetch(:model)
+ end
end
override :execute
def execute(*args)
check = Gitlab::CrossProjectAccess.find_check(self)
- original = super
+ original = -> { super }
- return original unless check
- return original if should_skip_cross_project_check || can_read_cross_project?
+ return original.call unless check
+ return original.call if should_skip_cross_project_check || can_read_cross_project?
if check.should_run?(self)
- original.model.none
+ finder_model&.none || original.call.model.none
else
- original
+ original.call
end
end
@@ -48,8 +64,6 @@ module FinderWithCrossProjectAccess
skip_cross_project_check { super }
end
- private
-
attr_accessor :should_skip_cross_project_check
def skip_cross_project_check
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 8df01f1dad9..234b7090fd9 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -3,22 +3,27 @@
class EventsFinder
prepend FinderMethods
prepend FinderWithCrossProjectAccess
+
+ MAX_PER_PAGE = 100
+
attr_reader :source, :params, :current_user
- requires_cross_project_access unless: -> { source.is_a?(Project) }
+ requires_cross_project_access unless: -> { source.is_a?(Project) }, model: Event
# Used to filter Events
#
# Arguments:
# source - which user or project to looks for events on
# current_user - only return events for projects visible to this user
- # WARNING: does not consider project feature visibility!
# params:
# action: string
# target_type: string
# before: datetime
# after: datetime
- #
+ # per_page: integer (max. 100)
+ # page: integer
+ # with_associations: boolean
+ # sort: 'asc' or 'desc'
def initialize(params = {})
@source = params.delete(:source)
@current_user = params.delete(:current_user)
@@ -33,15 +38,18 @@ class EventsFinder
events = by_target_type(events)
events = by_created_at_before(events)
events = by_created_at_after(events)
+ events = sort(events)
+
+ events = events.with_associations if params[:with_associations]
- events
+ paginated_filtered_by_user_visibility(events)
end
private
# rubocop: disable CodeReuse/ActiveRecord
def by_current_user_access(events)
- events.merge(ProjectsFinder.new(current_user: current_user).execute) # rubocop: disable CodeReuse/Finder
+ events.merge(Project.public_or_visible_to_user(current_user))
.joins(:project)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -77,4 +85,31 @@ class EventsFinder
events.where('events.created_at > ?', params[:after].end_of_day)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def sort(events)
+ return events unless params[:sort]
+
+ if params[:sort] == 'asc'
+ events.order_id_asc
+ else
+ events.order_id_desc
+ end
+ end
+
+ def paginated_filtered_by_user_visibility(events)
+ limited_events = events.page(page).per(per_page)
+ visible_events = limited_events.select { |event| event.visible_to_user?(current_user) }
+
+ Kaminari.paginate_array(visible_events, total_count: events.count)
+ end
+
+ def per_page
+ return MAX_PER_PAGE unless params[:per_page]
+
+ [params[:per_page], MAX_PER_PAGE].min
+ end
+
+ def page
+ params[:page] || 1
+ end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index e04e3a2a7e0..b73a3fa6e01 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -27,12 +27,13 @@
# created_before: datetime
# updated_after: datetime
# updated_before: datetime
-# use_cte_for_search: boolean
+# attempt_group_search_optimizations: boolean
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
include CreatedAtFilter
+ include Gitlab::Utils::StrongMemoize
requires_cross_project_access unless: -> { project? }
@@ -75,8 +76,9 @@ 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 use_cte_for_search param
+ # 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
# https://www.postgresql.org/docs/current/static/queries-with.html
items = by_search(items)
@@ -85,6 +87,8 @@ class IssuableFinder
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)
@@ -282,12 +286,31 @@ 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: false)
+ 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)
+ end
+ end
+
private
def init_collection
klass.all
end
+ def attempt_group_search_optimizations?
+ search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations]
+ end
+
def count_key(value)
Array(value).last.to_sym
end
@@ -351,12 +374,13 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- def use_cte_for_search?
- return false unless search
- return false unless Gitlab::Database.postgresql?
- return false unless Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true)
-
- params[:use_cte_for_search]
+ # 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
diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb
index 35d0e1acce5..f5aadc42ff0 100644
--- a/app/finders/pipelines_finder.rb
+++ b/app/finders/pipelines_finder.rb
@@ -8,7 +8,7 @@ class PipelinesFinder
def initialize(project, current_user, params = {})
@project = project
@current_user = current_user
- @pipelines = project.pipelines
+ @pipelines = project.all_pipelines
@params = params
end
diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb
new file mode 100644
index 00000000000..2b5d67e79d7
--- /dev/null
+++ b/app/finders/projects/serverless/functions_finder.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Projects
+ module Serverless
+ class FunctionsFinder
+ def initialize(clusters)
+ @clusters = clusters
+ end
+
+ def execute
+ knative_services.flatten.compact
+ end
+
+ def installed?
+ clusters_with_knative_installed.exists?
+ end
+
+ private
+
+ def knative_services
+ clusters_with_knative_installed.preload_knative.map do |cluster|
+ cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
+ end
+ end
+
+ def clusters_with_knative_installed
+ @clusters.with_knative_installed
+ end
+ end
+ end
+end
diff --git a/app/finders/remote_mirror_finder.rb b/app/finders/remote_mirror_finder.rb
new file mode 100644
index 00000000000..420db0077aa
--- /dev/null
+++ b/app/finders/remote_mirror_finder.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class RemoteMirrorFinder
+ attr_accessor :params
+
+ def initialize(params)
+ @params = params
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ RemoteMirror.find_by(id: params[:id])
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index ed13c5cfdd6..3f69af50f25 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -2,7 +2,12 @@
module AppearancesHelper
def brand_title
- current_appearance&.title.presence || 'GitLab Community Edition'
+ current_appearance&.title.presence || default_brand_title
+ end
+
+ def default_brand_title
+ # This resides in a separate method so that EE can easily redefine it.
+ 'GitLab Community Edition'
end
def brand_image
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 74042f0bae8..82bb2d1a805 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -171,7 +171,6 @@ module ApplicationHelper
def page_filter_path(options = {})
without = options.delete(:without)
- add_label = options.delete(:label)
options = request.query_parameters.merge(options)
@@ -181,11 +180,7 @@ module ApplicationHelper
end
end
- params = options.compact
-
- params.delete(:label_name) unless add_label
-
- "#{request.path}?#{params.to_param}"
+ "#{request.path}?#{options.compact.to_param}"
end
def outdated_browser?
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 44f85e9c0f8..654fb9d9987 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -57,6 +57,10 @@ module AuthHelper
auth_providers.reject { |provider| form_based_provider?(provider) }
end
+ def display_providers_on_profile?
+ button_based_providers.any?
+ end
+
def providers_for_base_controller
auth_providers.reject { |provider| LDAP_PROVIDER === provider }
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 638744a1426..bd42f00944f 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -140,6 +140,8 @@ module BlobHelper
Gitlab::Sanitizers::SVG.clean(data)
end
+ # Remove once https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 is closed
+ # and :workhorse_set_content_type flag is removed
# If we blindly set the 'real' content type when serving a Git blob we
# are enabling XSS attacks. An attacker could upload e.g. a Javascript
# file to a Git repository, trick the browser of a victim into
@@ -161,6 +163,8 @@ module BlobHelper
end
def content_disposition(blob, inline)
+ # Remove the following line when https://gitlab.com/gitlab-org/gitlab-ce/issues/36103
+ # is closed and :workhorse_set_content_type flag is removed
return 'attachment' if blob.extension == 'svg'
inline ? 'inline' : 'attachment'
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 7f071d55a6b..494c754e7d5 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -85,13 +85,14 @@ module ButtonHelper
dropdown_item_with_description('SSH', dropdown_description, href: append_url, data: { clone_type: 'ssh' })
end
- def dropdown_item_with_description(title, description, href: nil, data: nil)
+ def dropdown_item_with_description(title, description, href: nil, data: nil, default: false)
+ active_class = "is-active" if default
button_content = content_tag(:strong, title, class: 'dropdown-menu-inner-title')
button_content << content_tag(:span, description, class: 'dropdown-menu-inner-content') if description
content_tag (href ? :a : :span),
(href ? button_content : title),
- class: "#{title.downcase}-selector",
+ class: "#{title.downcase}-selector #{active_class}",
href: (href if href),
data: (data if data)
end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 4b6c5b215e8..8d8c62f1291 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -11,6 +11,10 @@ module DropdownsHelper
dropdown_output = dropdown_toggle(toggle_text, data_attr, options)
+ if options.key?(:toggle_link)
+ dropdown_output = dropdown_toggle_link(toggle_text, data_attr, options)
+ end
+
dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do
output = []
@@ -49,6 +53,11 @@ module DropdownsHelper
end
end
+ def dropdown_toggle_link(toggle_text, data_attr, options = {})
+ output = content_tag(:a, toggle_text, class: "dropdown-toggle-text #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), data: data_attr)
+ output.html_safe
+ end
+
def dropdown_title(title, options: {})
content_tag :div, class: "dropdown-title" do
title_output = []
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 2d2e89a2a50..e4c46ceeaa2 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -98,4 +98,29 @@ module EmailsHelper
"#{string} on #{Gitlab.config.gitlab.host}"
end
+
+ def create_list_id_string(project, list_id_max_length = 255)
+ project_path_as_domain = project.full_path.downcase
+ .split('/').reverse.join('/')
+ .gsub(%r{[^a-z0-9\/]}, '-')
+ .gsub(%r{\/+}, '.')
+ .gsub(/(\A\.+|\.+\z)/, '')
+
+ max_domain_length = list_id_max_length - Gitlab.config.gitlab.host.length - project.id.to_s.length - 2
+
+ if max_domain_length < 3
+ return project.id.to_s + "..." + Gitlab.config.gitlab.host
+ end
+
+ if project_path_as_domain.length > max_domain_length
+ project_path_as_domain = project_path_as_domain.slice(0, max_domain_length)
+
+ last_dot_index = project_path_as_domain[0..-2].rindex(".")
+ last_dot_index ||= max_domain_length - 2
+
+ project_path_as_domain = project_path_as_domain.slice(0, last_dot_index).concat("..")
+ end
+
+ project.id.to_s + "." + project_path_as_domain + "." + Gitlab.config.gitlab.host
+ end
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 3ce2398f1de..1371e9993b4 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -161,6 +161,10 @@ module EventsHelper
project_commit_url(event.project, event.note_target, anchor: dom_id(event.target))
elsif event.project_snippet_note?
project_snippet_url(event.project, event.note_target, anchor: dom_id(event.target))
+ elsif event.issue_note?
+ project_issue_url(event.project, id: event.note_target, anchor: dom_id(event.target))
+ elsif event.merge_request_note?
+ project_merge_request_url(event.project, id: event.note_target, anchor: dom_id(event.target))
else
polymorphic_url([event.project.namespace.becomes(Namespace),
event.project, event.note_target],
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index e9b9b9b7721..866fc555856 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -140,7 +140,7 @@ module GroupsHelper
can?(current_user, "read_group_#{resource}".to_sym, @group)
end
- if can?(current_user, :read_cluster, @group) && Feature.enabled?(:group_clusters)
+ if can?(current_user, :read_cluster, @group) && @group.group_clusters_enabled?
links << :kubernetes
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index b0f63de2fb8..4e11772b252 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -42,7 +42,7 @@ module IconsHelper
end
def sprite_icon(icon_name, size: nil, css_class: nil)
- if Gitlab::Sentry.should_raise?
+ if Gitlab::Sentry.should_raise_for_dev?
unless known_sprites.include?(icon_name)
exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg")
raise exception
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
new file mode 100644
index 00000000000..8e50bbc6c04
--- /dev/null
+++ b/app/helpers/ide_helper.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module IdeHelper
+ def ide_data
+ {
+ "empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
+ "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
+ "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
+ "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
+ "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
+ "ci-help-page-path" => help_page_path('ci/quick_start/README'),
+ "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
+ "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s
+ }
+ end
+end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index dfa86f52e40..da991458ea7 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -179,7 +179,7 @@ module IssuablesHelper
output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
output << content_tag(:strong) do
- author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true)
+ 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")
if status = user_status(issuable.author)
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index 94a030d9d57..9666080092b 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -2,6 +2,7 @@
module MilestonesHelper
include EntityDateHelper
+ include Gitlab::Utils::StrongMemoize
def milestones_filter_path(opts = {})
if @project
@@ -243,4 +244,16 @@ module MilestonesHelper
dashboard_milestone_path(milestone.safe_title, title: milestone.title)
end
end
+
+ def can_admin_project_milestones?
+ strong_memoize(:can_admin_project_milestones) do
+ can?(current_user, :admin_milestone, @project)
+ end
+ end
+
+ def can_admin_group_milestones?
+ strong_memoize(:can_admin_group_milestones) do
+ can?(current_user, :admin_milestone, @project.group)
+ end
+ end
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index a7fe8c3d59c..05da5ebdb22 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -47,8 +47,8 @@ module NavHelper
class_names
end
- def show_separator?
- Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics)
+ def has_extra_nav_icons?
+ Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics) || current_user.admin?
end
def page_has_markdown?
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 0a7f930110a..aa54172e108 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -2,7 +2,7 @@
module ProjectsHelper
def link_to_project(project)
- link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
+ link_to namespace_project_path(namespace_id: project.namespace, id: project), title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
if project.namespace
@@ -50,6 +50,12 @@ module ProjectsHelper
default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts)
+ data_attrs = {
+ user_id: author.id,
+ username: author.username,
+ name: author.name
+ }
+
return "(deleted)" unless author
author_html = []
@@ -65,7 +71,7 @@ module ProjectsHelper
author_html = author_html.join.html_safe
if opts[:name]
- link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe
+ link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe
else
title = opts[:title].sub(":name", sanitize(author.name))
link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe
@@ -257,6 +263,10 @@ module ProjectsHelper
"xcode://clone?repo=#{CGI.escape(default_url_to_repo(project))}"
end
+ def link_to_bfg
+ link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer'
+ end
+
def legacy_render_context(params)
params[:legacy_render] ? { markdown_engine: :redcarpet } : {}
end
@@ -267,7 +277,7 @@ module ProjectsHelper
nav_tabs = [:home]
if !project.empty_repo? && can?(current_user, :download_code, project)
- nav_tabs << [:files, :commits, :network, :graphs, :forks]
+ nav_tabs << [:files, :commits, :network, :graphs, :forks, :releases]
end
if project.repo_exists? && can?(current_user, :read_merge_request, project)
@@ -307,6 +317,7 @@ module ProjectsHelper
settings: :admin_project,
builds: :read_build,
clusters: :read_cluster,
+ serverless: :read_cluster,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
@@ -380,6 +391,10 @@ module ProjectsHelper
end
end
+ def sidebar_operations_link_path(project = @project)
+ metrics_project_environments_path(project) if can?(current_user, :read_environment, project)
+ end
+
def project_last_activity(project)
if project.last_activity_at
time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago')
@@ -500,10 +515,25 @@ module ProjectsHelper
end
end
+ def explore_projects_tab?
+ current_page?(explore_projects_path) ||
+ current_page?(trending_explore_projects_path) ||
+ current_page?(starred_explore_projects_path)
+ end
+
+ def show_merge_request_count?(merge_requests, compact_mode)
+ merge_requests && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true)
+ end
+
+ def show_issue_count?(issues, compact_mode)
+ issues && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true)
+ end
+
def sidebar_projects_paths
%w[
projects#show
projects#activity
+ releases#index
cycle_analytics#show
]
end
@@ -535,7 +565,6 @@ module ProjectsHelper
projects/repositories
tags
branches
- releases
graphs
network
]
@@ -545,6 +574,7 @@ module ProjectsHelper
%w[
environments
clusters
+ functions
user
gcp
]
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index cf60696ef39..2f802e4eab8 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -29,6 +29,11 @@ module SelectsHelper
classes = Array.wrap(opts[:class])
classes << 'ajax-groups-select'
+ # EE requires this line to be present, but there is no easy way of injecting
+ # this into EE without causing merge conflicts. Given this line is very
+ # simple and not really EE specific on its own, we just include it in CE.
+ classes << 'multiselect' if opts[:multiple]
+
opts[:class] = classes.join(' ')
select2_tag(id, opts)
diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb
deleted file mode 100644
index d53eaef9952..00000000000
--- a/app/helpers/sentry_helper.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module SentryHelper
- def sentry_enabled?
- Gitlab::Sentry.enabled?
- end
-
- def sentry_context
- Gitlab::Sentry.context(current_user)
- end
-end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 8ed2a2ec9f4..6ac1f42c321 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -120,10 +120,70 @@ module SortingHelper
}
end
+ def users_sort_options_hash
+ {
+ sort_value_name => sort_title_name,
+ sort_value_recently_signin => sort_title_recently_signin,
+ sort_value_oldest_signin => sort_title_oldest_signin,
+ sort_value_recently_created => sort_title_recently_created,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_oldest_updated => sort_title_oldest_updated
+ }
+ end
+
def sortable_item(item, path, sorted_by)
link_to item, path, class: sorted_by == item ? 'is-active' : ''
end
+ def issuable_sort_option_overrides
+ {
+ sort_value_oldest_created => sort_value_created_date,
+ sort_value_oldest_updated => sort_value_recently_updated,
+ sort_value_milestone_later => sort_value_milestone
+ }
+ end
+
+ def issuable_reverse_sort_order_hash
+ {
+ 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
+ }.merge(issuable_sort_option_overrides)
+ end
+
+ def issuable_sort_option_title(sort_value)
+ sort_value = issuable_sort_option_overrides[sort_value] || sort_value
+
+ sort_options_hash[sort_value]
+ end
+
+ def issuable_sort_icon_suffix(sort_value)
+ case sort_value
+ when sort_value_milestone, sort_value_due_date, /_asc\z/
+ 'lowest'
+ else
+ 'highest'
+ end
+ end
+
+ 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]
+
+ if reverse_sort
+ reverse_url = page_filter_path(sort: reverse_sort)
+ else
+ reverse_url = '#'
+ link_class += ' disabled'
+ end
+
+ link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do
+ sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16)
+ end
+ end
+
# Titles.
def sort_title_access_level_asc
s_('SortOptions|Access level, ascending')
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 42b533ad772..73c1402eae5 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -8,7 +8,7 @@ module UsersHelper
end
def user_email_help_text(user)
- return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present?
+ return 'We also use email for avatar detection if no avatar is uploaded' unless user.unconfirmed_email.present?
confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
@@ -70,6 +70,10 @@ module UsersHelper
end
end
+ def impersonation_enabled?
+ Gitlab.config.gitlab.impersonation_enabled
+ end
+
private
def get_profile_tabs
diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb
index ab77b149072..5e519cf5c19 100644
--- a/app/helpers/version_check_helper.rb
+++ b/app/helpers/version_check_helper.rb
@@ -6,8 +6,7 @@ module VersionCheckHelper
return unless Gitlab::CurrentSettings.version_check_enabled
return if User.single_user&.requires_usage_stats_consent?
- image_url = VersionCheck.new.url
- image_tag image_url, class: 'js-version-status-badge'
+ image_tag VersionCheck.url, class: 'js-version-status-badge'
end
def link_to_version
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index e690350a0d1..712f0f808dd 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -140,7 +140,7 @@ module VisibilityLevelHelper
end
def project_visibility_icon_description(level)
- "#{project_visibility_level_description(level)}"
+ "#{visibility_level_label(level)} - #{project_visibility_level_description(level)}"
end
def visibility_level_label(level)
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index 49c08dce96c..e9fc39e451b 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -6,8 +6,13 @@ module WorkhorseHelper
# Send a Git blob through Workhorse
def send_git_blob(repository, blob, inline: true)
headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob))
+
headers['Content-Disposition'] = content_disposition(blob, inline)
headers['Content-Type'] = safe_content_type(blob)
+
+ # If enabled, this will override the values set above
+ workhorse_set_content_type!
+
render plain: ""
end
@@ -40,4 +45,8 @@ module WorkhorseHelper
def set_workhorse_internal_api_content_type
headers['Content-Type'] = Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE
end
+
+ def workhorse_set_content_type!
+ headers[Gitlab::Workhorse::DETECT_HEADER] = "true" if Feature.enabled?(:workhorse_set_content_type)
+ end
end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index d3284e90568..1b3c1f9a8a9 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -26,7 +26,7 @@ module Emails
mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id))
end
- def note_snippet_email(recipient_id, note_id)
+ def note_project_snippet_email(recipient_id, note_id)
setup_note_mail(note_id, recipient_id)
@snippet = @note.noteable
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index d7e6c2ba7b2..2500622caa7 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -24,6 +24,21 @@ module Emails
subject: subject("Project export error"))
end
+ def repository_cleanup_success_email(project, user)
+ @project = project
+ @user = user
+
+ mail(to: user.notification_email, subject: subject("Project cleanup has completed"))
+ end
+
+ def repository_cleanup_failure_email(project, user, error)
+ @project = project
+ @user = user
+ @error = error
+
+ mail(to: user.notification_email, subject: subject("Project cleanup failure"))
+ end
+
def repository_push_email(project_id, opts = {})
@message =
Gitlab::Email::Message::RepositoryPush.new(self, project_id, opts)
diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb
new file mode 100644
index 00000000000..2018eb7260b
--- /dev/null
+++ b/app/mailers/emails/remote_mirrors.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Emails
+ module RemoteMirrors
+ def remote_mirror_update_failed_email(remote_mirror_id, recipient_id)
+ @remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute
+ @project = @remote_mirror.project
+
+ mail(to: recipient(recipient_id), subject: subject('Remote mirror update failed'))
+ end
+ end
+end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 662f3e00047..15710bee4d4 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -3,6 +3,7 @@
class Notify < BaseMailer
include ActionDispatch::Routing::PolymorphicRoutes
include GitlabRoutingHelper
+ include EmailsHelper
include Emails::Issues
include Emails::MergeRequests
@@ -13,6 +14,7 @@ class Notify < BaseMailer
include Emails::Pipelines
include Emails::Members
include Emails::AutoDevops
+ include Emails::RemoteMirrors
helper MergeRequestsHelper
helper DiffHelper
@@ -128,7 +130,7 @@ class Notify < BaseMailer
address.display_name = reply_display_name(model)
end
- fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze
+ fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>"
headers['References'] ||= []
headers['References'].unshift(fallback_reply_message_id)
@@ -166,7 +168,7 @@ class Notify < BaseMailer
headers['In-Reply-To'] = message_id(model)
headers['References'] = [message_id(model)]
- headers[:subject]&.prepend('Re: ')
+ headers[:subject] = "Re: #{headers[:subject]}" if headers[:subject]
mail_thread(model, headers)
end
@@ -178,7 +180,7 @@ class Notify < BaseMailer
headers['X-GitLab-Discussion-ID'] = note.discussion.id if note.part_of_discussion?
- headers[:subject]&.prepend('Re: ')
+ headers[:subject] = "Re: #{headers[:subject]}" if headers[:subject]
mail_thread(model, headers)
end
@@ -193,6 +195,7 @@ class Notify < BaseMailer
headers['X-GitLab-Project'] = @project.name
headers['X-GitLab-Project-Id'] = @project.id
headers['X-GitLab-Project-Path'] = @project.full_path
+ headers['List-Id'] = "#{@project.full_path} <#{create_list_id_string(@project)}>"
end
def add_unsubscription_headers_and_links
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index e7e8d96eca4..2ac4610967d 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -145,6 +145,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.autodevops_disabled_email(pipeline, user.email).message
end
+ def remote_mirror_update_failed_email
+ Notify.remote_mirror_update_failed_email(remote_mirror.id, user.id).message
+ end
+
private
def project
@@ -167,6 +171,10 @@ class NotifyPreview < ActionMailer::Preview
@pipeline = Ci::Pipeline.last
end
+ def remote_mirror
+ @remote_mirror ||= RemoteMirror.last
+ end
+
def user
@user ||= User.last
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index da2e095e336..73be94eade6 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -7,7 +7,7 @@ class ApplicationSetting < ActiveRecord::Base
include IgnorableColumn
include ChronicDurationAttribute
- add_authentication_token_field :runners_registration_token
+ add_authentication_token_field :runners_registration_token, encrypted: true, fallback: true
add_authentication_token_field :health_check_access_token
DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index baf8adb318b..2d237383e60 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -16,20 +16,24 @@ class BroadcastMessage < ActiveRecord::Base
default_value_for :color, '#E75E40'
default_value_for :font, '#FFFFFF'
- CACHE_KEY = 'broadcast_message_current'.freeze
+ CACHE_KEY = 'broadcast_message_current_json'.freeze
+ LEGACY_CACHE_KEY = 'broadcast_message_current'.freeze
after_commit :flush_redis_cache
def self.current
- messages = Rails.cache.fetch(CACHE_KEY, expires_in: cache_expires_in) { current_and_future_messages.to_a }
+ messages = cache.fetch(CACHE_KEY, as: BroadcastMessage, expires_in: cache_expires_in) do
+ remove_legacy_cache_key
+ current_and_future_messages
+ end
- return messages if messages.empty?
+ return [] unless messages&.present?
now_or_future = messages.select(&:now_or_future?)
# If there are cached entries but none are to be displayed we'll purge the
# cache so we don't keep running this code all the time.
- Rails.cache.delete(CACHE_KEY) if now_or_future.empty?
+ cache.expire(CACHE_KEY) if now_or_future.empty?
now_or_future.select(&:now?)
end
@@ -38,10 +42,22 @@ class BroadcastMessage < ActiveRecord::Base
where('ends_at > :now', now: Time.zone.now).order_id_asc
end
+ def self.cache
+ Gitlab::JsonCache.new(cache_key_with_version: false)
+ end
+
def self.cache_expires_in
nil
end
+ # This can be removed in GitLab 12.0+
+ # The old cache key had an indefinite lifetime, and in an HA
+ # environment a one-shot migration would not work because the cache
+ # would be repopulated by a node that has not been upgraded.
+ def self.remove_legacy_cache_key
+ cache.expire(LEGACY_CACHE_KEY)
+ end
+
def active?
started? && !ended?
end
@@ -67,6 +83,7 @@ class BroadcastMessage < ActiveRecord::Base
end
def flush_redis_cache
- Rails.cache.delete(CACHE_KEY)
+ self.class.cache.expire(CACHE_KEY)
+ self.class.remove_legacy_cache_key
end
end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
new file mode 100644
index 00000000000..29aa00a66d9
--- /dev/null
+++ b/app/models/ci/bridge.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Ci
+ class Bridge < CommitStatus
+ include Importable
+ include AfterCommitQueue
+ include Gitlab::Utils::StrongMemoize
+
+ belongs_to :project
+ validates :ref, presence: true
+
+ def self.retry(bridge, current_user)
+ raise NotImplementedError
+ end
+
+ def tags
+ [:bridge]
+ end
+
+ def detailed_status(current_user)
+ Gitlab::Ci::Status::Bridge::Factory
+ .new(self, current_user)
+ .fabricate!
+ end
+
+ def predefined_variables
+ raise NotImplementedError
+ end
+
+ def execute_hooks
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index d60861dc95f..16a72c680fa 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -120,7 +120,7 @@ module Ci
acts_as_taggable
- add_authentication_token_field :token
+ add_authentication_token_field :token, encrypted: true, fallback: true
before_save :update_artifacts_size, if: :artifacts_file_changed?
before_save :ensure_token
@@ -742,7 +742,7 @@ module Ci
def collect_test_reports!(test_reports)
test_reports.get_suite(group_name).tap do |test_suite|
each_report(Ci::JobArtifact::TEST_REPORT_FILE_TYPES) do |file_type, blob|
- Gitlab::Ci::Parsers::Test.fabricate!(file_type).parse!(blob, test_suite)
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, test_suite)
end
end
end
@@ -840,6 +840,7 @@ module Ci
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)
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 7c84bd734bb..da08214963f 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -15,6 +15,8 @@ module Ci
WRITE_LOCK_SLEEP = 0.01.seconds
WRITE_LOCK_TTL = 1.minute
+ FailedToPersistDataError = Class.new(StandardError)
+
# Note: The ordering of this enum is related to the precedence of persist store.
# The bottom item takes the higest precedence, and the top item takes the lowest precedence.
enum data_store: {
@@ -109,16 +111,19 @@ module Ci
def unsafe_persist_to!(new_store)
return if data_store == new_store.to_s
- raise ArgumentError, 'Can not persist empty data' unless size > 0
- old_store_class = self.class.get_store_class(data_store)
+ current_data = get_data
- get_data.tap do |the_data|
- self.raw_data = nil
- self.data_store = new_store
- unsafe_set_data!(the_data)
+ unless current_data&.bytesize.to_i == CHUNK_SIZE
+ raise FailedToPersistDataError, 'Data is not fullfilled in a bucket'
end
+ old_store_class = self.class.get_store_class(data_store)
+
+ self.raw_data = nil
+ self.data_store = new_store
+ unsafe_set_data!(current_data)
+
old_store_class.delete_data(self)
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 9512ba42f67..25937065011 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -12,13 +12,14 @@ module Ci
include AtomicInternalId
include EnumWithNil
- belongs_to :project, inverse_of: :pipelines
+ belongs_to :project, inverse_of: :all_pipelines
belongs_to :user
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
+ belongs_to :merge_request, class_name: 'MergeRequest'
has_internal_id :iid, scope: :project, presence: false, init: ->(s) do
- s&.project&.pipelines&.maximum(:iid) || s&.project&.pipelines&.count
+ s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count
end
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
@@ -26,6 +27,8 @@ module Ci
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
+ has_many :deployments, through: :builds
+ has_many :environments, -> { distinct }, through: :deployments
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
@@ -48,13 +51,12 @@ 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 :status, presence: { unless: :importing? }
validate :valid_commit_sha, unless: :importing?
-
- # Replace validator below with
- # `validates :source, presence: { unless: :importing? }, on: :create`
- # when removing Gitlab.rails5? code.
- validate :valid_source, unless: :importing?, on: :create
+ validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
after_create :keep_around_commits, unless: :importing?
@@ -62,11 +64,7 @@ module Ci
# this `Hash` with new values.
enum_with_nil source: ::Ci::PipelineEnums.sources
- enum_with_nil config_source: {
- unknown_source: nil,
- repository_source: 1,
- auto_devops_source: 2
- }
+ enum_with_nil config_source: ::Ci::PipelineEnums.config_sources
# We use `Ci::PipelineEnums.failure_reasons` here so that EE can more easily
# extend this `Hash` with new values.
@@ -168,6 +166,16 @@ module Ci
end
scope :internal, -> { where(source: internal_sources) }
+ scope :ci_sources, -> { where(config_source: ci_sources_values) }
+
+ scope :sort_by_merge_request_pipelines, -> do
+ sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC'
+ query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request]]) # rubocop:disable GitlabSecurity/PublicSend
+
+ order(query)
+ end
+
+ scope :for_user, -> (user) { where(user: user) }
# Returns the pipelines in descending order (= newest first), optionally
# limited to a number of references.
@@ -256,6 +264,10 @@ module Ci
sources.reject { |source| source == "external" }.values
end
+ def self.ci_sources_values
+ config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source)
+ end
+
def stages_count
statuses.select(:stage).distinct.count
end
@@ -368,7 +380,7 @@ module Ci
end
def branch?
- !tag?
+ !tag? && !merge_request?
end
def stuck?
@@ -494,6 +506,8 @@ module Ci
end
def ci_yaml_file_path
+ return unless repository_source? || unknown_source?
+
if project.ci_config_path.blank?
'.gitlab-ci.yml'
else
@@ -523,10 +537,6 @@ module Ci
yaml_errors.present?
end
- def environments
- builds.where.not(environment: nil).success.pluck(:environment).uniq
- end
-
# Manually set the notes for a Ci::Pipeline
# There is no ActiveRecord relation between Ci::Pipeline and notes
# as they are related to a commit sha. This method helps importing
@@ -587,13 +597,18 @@ module Ci
end
def predefined_variables
- Gitlab::Ci::Variables::Collection.new
- .append(key: 'CI_PIPELINE_IID', value: iid.to_s)
- .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path)
- .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s)
- .append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s)
- .append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s)
- .append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s)
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s)
+ variables.append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path)
+ variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s)
+ variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s)
+ variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s)
+ variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s)
+
+ if merge_request? && merge_request
+ variables.concat(merge_request.predefined_variables)
+ end
+ end
end
def queued_duration
@@ -617,7 +632,12 @@ module Ci
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
- @all_merge_requests ||= project.merge_requests.where(source_branch: ref)
+ @all_merge_requests ||=
+ if merge_request?
+ project.merge_requests.where(id: merge_request.id)
+ else
+ project.merge_requests.where(source_branch: ref)
+ end
end
def detailed_status(current_user)
@@ -666,6 +686,7 @@ module Ci
def ci_yaml_from_repo
return unless project
return unless sha
+ return unless ci_yaml_file_path
project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path)
rescue GRPC::NotFound, GRPC::Internal
@@ -693,6 +714,8 @@ module Ci
def git_ref
if branch?
Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
+ elsif merge_request?
+ Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
elsif tag?
Gitlab::Git::TAG_REF_PREFIX + ref.to_s
else
@@ -711,11 +734,5 @@ module Ci
project.repository.keep_around(self.sha, self.before_sha)
end
-
- def valid_source
- if source.nil? || source == "unknown"
- errors.add(:source, "invalid source")
- end
- end
end
end
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 8d8d16e2ec1..2994aaae4aa 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -21,7 +21,18 @@ module Ci
trigger: 3,
schedule: 4,
api: 5,
- external: 6
+ external: 6,
+ merge_request: 10
+ }
+ end
+
+ # Returns the `Hash` to use for creating the `config_sources` enum for
+ # `Ci::Pipeline`.
+ def self.config_sources
+ {
+ unknown_source: nil,
+ repository_source: 1,
+ auto_devops_source: 2
}
end
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 31330d0682e..3e5cedf92b9 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -8,6 +8,9 @@ module Ci
include RedisCacheable
include ChronicDurationAttribute
include FromUnion
+ include TokenAuthenticatable
+
+ add_authentication_token_field :token, encrypted: true, migrating: true
enum access_level: {
not_protected: 0,
@@ -39,7 +42,7 @@ module Ci
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
- before_validation :set_default_values
+ before_save :ensure_token
scope :active, -> { where(active: true) }
scope :paused, -> { where(active: false) }
@@ -55,8 +58,7 @@ module Ci
# BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb`
scope :deprecated_shared, -> { instance_type }
- # this should get replaced with `project_type.or(group_type)` once using Rails5
- scope :deprecated_specific, -> { where(runner_type: [runner_types[:project_type], runner_types[:group_type]]) }
+ scope :deprecated_specific, -> { project_type.or(group_type) }
scope :belonging_to_project, -> (project_id) {
joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
@@ -111,7 +113,8 @@ module Ci
cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at
- chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout
+ chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout,
+ error_message: 'Maximum job timeout has a value which could not be accepted'
validates :maximum_timeout, allow_nil: true,
numericality: { greater_than_or_equal_to: 600,
@@ -145,10 +148,6 @@ module Ci
end
end
- def set_default_values
- self.token = SecureRandom.hex(15) if self.token.blank?
- end
-
def assign_to(project, current_user = nil)
if instance_type?
self.runner_type = :project_type
diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb
index 077e2bda143..74ef7c7e145 100644
--- a/app/models/clusters/applications/cert_manager.rb
+++ b/app/models/clusters/applications/cert_manager.rb
@@ -14,6 +14,10 @@ module Clusters
default_value_for :version, VERSION
+ default_value_for :email do |cert_manager|
+ cert_manager.cluster&.user&.email
+ end
+
validates :email, presence: true
def chart
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index e43a0fd1786..421a923d386 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -56,7 +56,11 @@ module Clusters
def specification
{
"ingress" => {
- "hosts" => [hostname]
+ "hosts" => [hostname],
+ "tls" => [{
+ "hosts" => [hostname],
+ "secretName" => "jupyter-cert"
+ }]
},
"hub" => {
"extraEnv" => {
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index c0aaa8dce20..0c72d7d8340 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Knative < ActiveRecord::Base
- VERSION = '0.1.3'.freeze
+ VERSION = '0.2.2'.freeze
REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'.freeze
FETCH_IP_ADDRESS_DELAY = 30.seconds
@@ -15,6 +15,9 @@ 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] }
state_machine :status do
before_transition any => [:installed] do |application|
@@ -29,6 +32,8 @@ module Clusters
validates :hostname, presence: true, hostname: true
+ scope :for_cluster, -> (cluster) { where(cluster: cluster) }
+
def chart
'knative/knative'
end
@@ -55,12 +60,39 @@ module Clusters
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 }
+ end
+
def ingress_service
cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system')
end
- def client
- cluster.platform_kubernetes.kubeclient.knative_client
+ def services_for(ns: namespace)
+ return unless services
+ return [] unless ns
+
+ services.select do |service|
+ service.dig('metadata', 'namespace') == ns
+ end
+ end
+
+ private
+
+ def read_services
+ client.get_services.as_json
+ rescue Kubeclient::ResourceNotFoundError
+ []
end
end
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 67746e34913..c931b340b24 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ActiveRecord::Base
- VERSION = '0.1.38'.freeze
+ VERSION = '0.1.39'.freeze
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 13906c903b9..7fe43cd2de0 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -4,6 +4,7 @@ module Clusters
class Cluster < ActiveRecord::Base
include Presentable
include Gitlab::Utils::StrongMemoize
+ include FromUnion
self.table_name = 'clusters'
@@ -86,6 +87,29 @@ module Clusters
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
+ scope :missing_kubernetes_namespace, -> (kubernetes_namespaces) do
+ subquery = kubernetes_namespaces.select('1').where('clusters_kubernetes_namespaces.cluster_id = clusters.id')
+
+ where('NOT EXISTS (?)', subquery)
+ end
+
+ scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) }
+
+ scope :preload_knative, -> {
+ preload(
+ :kubernetes_namespace,
+ :platform_kubernetes,
+ :application_knative
+ )
+ }
+
+ def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc)
+ 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)
+ end
+
def status_name
if provider
provider.status_name
@@ -122,6 +146,16 @@ module Clusters
!user?
end
+ def all_projects
+ if project_type?
+ projects
+ elsif group_type?
+ first_group.all_projects
+ else
+ Project.none
+ end
+ end
+
def first_project
strong_memoize(:first_project) do
projects.first
@@ -140,11 +174,17 @@ module Clusters
platform_kubernetes.kubeclient if kubernetes?
end
- def find_or_initialize_kubernetes_namespace(cluster_project)
- kubernetes_namespaces.find_or_initialize_by(
- project: cluster_project.project,
- cluster_project: cluster_project
- )
+ 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
+ )
+ end
end
def allow_user_defined_namespace?
diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb
index 34f5e38ff79..73da6cb37d7 100644
--- a/app/models/clusters/kubernetes_namespace.rb
+++ b/app/models/clusters/kubernetes_namespace.rb
@@ -33,14 +33,12 @@ module Clusters
end
def predefined_variables
- config = YAML.dump(kubeconfig)
-
Gitlab::Ci::Variables::Collection.new.tap do |variables|
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: 'KUBECONFIG', value: config, public: false, file: 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 3c5d7756eec..867f0edcb07 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -85,18 +85,16 @@ module Clusters
if kubernetes_namespace = cluster.kubernetes_namespaces.has_service_account_token.find_by(project: project)
variables.concat(kubernetes_namespace.predefined_variables)
- else
+ 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
- config = YAML.dump(kubeconfig)
-
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: config, public: false, file: true)
+ .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
end
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 546fcc54a15..a422a0995ff 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -177,7 +177,9 @@ class Commit
def title
return full_title if full_title.length < 100
- full_title.truncate(81, separator: ' ', omission: '…')
+ # Use three dots instead of the ellipsis Unicode character because
+ # some clients show the raw Unicode value in the merge commit.
+ full_title.truncate(81, separator: ' ', omission: '...')
end
# Returns the full commits title
@@ -298,7 +300,7 @@ class Commit
end
def pipelines
- project.pipelines.where(sha: sha)
+ project.ci_pipelines.where(sha: sha)
end
def last_pipeline
@@ -312,7 +314,7 @@ class Commit
end
def status_for_project(ref, pipeline_project)
- pipeline_project.pipelines.latest_status_per_commit(id, ref)[id]
+ pipeline_project.ci_pipelines.latest_status_per_commit(id, ref)[id]
end
def set_status_for_ref(ref, status)
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index dd93af9df64..e349f0fe971 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -24,7 +24,7 @@ class CommitCollection
# Setting this status ahead of time removes the need for running a query for
# every commit we're displaying.
def with_pipeline_status
- statuses = project.pipelines.latest_status_per_commit(map(&:id), ref)
+ statuses = project.ci_pipelines.latest_status_per_commit(map(&:id), ref)
each do |commit|
commit.set_status_for_ref(ref, statuses[commit.id])
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index b42236c1fa2..4687ec7d166 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -43,7 +43,18 @@ module Avatarable
end
def avatar_path(only_path: true, size: nil)
- return unless self[:avatar].present?
+ unless self.try(:id)
+ return uncached_avatar_path(only_path: only_path, size: size)
+ end
+
+ # Cache this avatar path only within the request because avatars in
+ # object storage may be generated with time-limited, signed URLs.
+ key = "#{self.class.name}:#{self.id}:#{only_path}:#{size}"
+ Gitlab::SafeRequestStore[key] ||= uncached_avatar_path(only_path: only_path, size: size)
+ end
+
+ def uncached_avatar_path(only_path: true, size: nil)
+ return unless self.try(:avatar).present?
asset_host = ActionController::Base.asset_host
use_asset_host = asset_host.present?
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index 60b7ec2815c..14bc56f0eee 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -43,14 +43,19 @@ module Awardable
end
def order_upvotes_desc
- order_votes_desc(AwardEmoji::UPVOTE_NAME)
+ order_votes(AwardEmoji::UPVOTE_NAME, 'DESC')
+ end
+
+ def order_upvotes_asc
+ order_votes(AwardEmoji::UPVOTE_NAME, 'ASC')
end
def order_downvotes_desc
- order_votes_desc(AwardEmoji::DOWNVOTE_NAME)
+ order_votes(AwardEmoji::DOWNVOTE_NAME, 'DESC')
end
- def order_votes_desc(emoji_name)
+ # Order votes by emoji, optional sort order param `descending` defaults to true
+ def order_votes(emoji_name, direction)
awardable_table = self.arel_table
awards_table = AwardEmoji.arel_table
@@ -62,7 +67,7 @@ module Awardable
)
).join_sources
- joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) DESC")
+ joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) #{direction}")
end
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 6e2adc76ec6..a8c9e54f00c 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -15,7 +15,7 @@ module CacheMarkdownField
# Increment this number every time the renderer changes its output
CACHE_REDCARPET_VERSION = 3
CACHE_COMMONMARK_VERSION_START = 10
- CACHE_COMMONMARK_VERSION = 11
+ CACHE_COMMONMARK_VERSION = 12
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb
index edf6ac96730..af4905115b1 100644
--- a/app/models/concerns/chronic_duration_attribute.rb
+++ b/app/models/concerns/chronic_duration_attribute.rb
@@ -24,7 +24,7 @@ module ChronicDurationAttribute
end
end
- validates virtual_attribute, allow_nil: true, duration: true
+ validates virtual_attribute, allow_nil: true, duration: { message: parameters[:error_message] }
end
alias_method :chronic_duration_attr, :chronic_duration_attr_writer
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index e57a3383544..0107af5f8ec 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -13,6 +13,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_kubernetes_service_integration ||
build_cluster_and_deployment_platform
end
@@ -23,6 +24,18 @@ module DeploymentPlatform
.last&.platform_kubernetes
end
+ def find_group_cluster_platform_kubernetes_with_feature_guard(environment: nil)
+ return unless group_clusters_enabled?
+
+ find_group_cluster_platform_kubernetes(environment: environment)
+ end
+
+ # EE would override this and utilize environment argument
+ def find_group_cluster_platform_kubernetes(environment: nil)
+ Clusters::Cluster.enabled.default_environment.ancestor_clusters_for_clusterable(self)
+ .first&.platform_kubernetes
+ end
+
def find_kubernetes_service_integration
services.deployment.reorder(nil).find_by(active: true)
end
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index c180d7b7c9a..266c37fa3a1 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -38,12 +38,13 @@ module DiscussionOnDiff
end
# Returns an array of at most 16 highlighted lines above a diff note
- def truncated_diff_lines(highlight: true)
+ def truncated_diff_lines(highlight: true, diff_limit: nil)
return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote)
+ diff_limit = [diff_limit, NUMBER_OF_TRUNCATED_DIFF_LINES].compact.min
lines = highlight ? highlighted_diff_lines : diff_lines
- initial_line_index = [diff_line.index - NUMBER_OF_TRUNCATED_DIFF_LINES + 1, 0].max
+ initial_line_index = [diff_line.index - diff_limit + 1, 0].max
prev_lines = []
diff --git a/app/models/concerns/enum_with_nil.rb b/app/models/concerns/enum_with_nil.rb
index 23acfe9a55f..6d0a21cf070 100644
--- a/app/models/concerns/enum_with_nil.rb
+++ b/app/models/concerns/enum_with_nil.rb
@@ -16,7 +16,7 @@ module EnumWithNil
# E.g. for enum_with_nil failure_reason: { unknown_failure: nil }
# this overrides auto-generated method `unknown_failure?`
define_method("#{key_with_nil}?") do
- Gitlab.rails5? ? self[name].nil? : super()
+ self[name].nil?
end
# E.g. for enum_with_nil failure_reason: { unknown_failure: nil }
@@ -24,7 +24,6 @@ module EnumWithNil
define_method(name) do
orig = super()
- return orig unless Gitlab.rails5?
return orig unless orig.nil?
self.class.public_send(name.to_s.pluralize).key(nil) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb
index 2bfa7da6c1c..1e3afd641ed 100644
--- a/app/models/concerns/fast_destroy_all.rb
+++ b/app/models/concerns/fast_destroy_all.rb
@@ -70,13 +70,14 @@ module FastDestroyAll
module Helpers
extend ActiveSupport::Concern
+ include AfterCommitQueue
class_methods do
##
# This method is to be defined on models which have fast destroyable models as children,
# and let us avoid to use `dependent: :destroy` hook
- def use_fast_destroy(relation)
- before_destroy(prepend: true) do
+ def use_fast_destroy(relation, opts = {})
+ set_callback :destroy, :before, opts.merge(prepend: true) do
perform_fast_destroy(public_send(relation)) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 5080fe03cc8..0d363ec68b7 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -145,14 +145,16 @@ module Issuable
def sort_by_attribute(method, excluded_labels: [])
sorted =
case method.to_s
- when 'downvotes_desc' then order_downvotes_desc
- when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels)
- when 'milestone' then order_milestone_due_asc
- when 'milestone_due_asc' then order_milestone_due_asc
- when 'milestone_due_desc' then order_milestone_due_desc
- when 'popularity' then order_upvotes_desc
- when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
- when 'upvotes_desc' then order_upvotes_desc
+ when 'downvotes_desc' then order_downvotes_desc
+ when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels)
+ when 'label_priority_desc' then order_labels_priority('DESC', excluded_labels: excluded_labels)
+ when 'milestone', 'milestone_due_asc' then order_milestone_due_asc
+ when 'milestone_due_desc' then order_milestone_due_desc
+ when 'popularity', 'popularity_desc' then order_upvotes_desc
+ when 'popularity_asc' then order_upvotes_asc
+ when 'priority', 'priority_asc' then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
+ when 'priority_desc' then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels)
+ when 'upvotes_desc' then order_upvotes_desc
else order_by(method)
end
@@ -160,7 +162,7 @@ module Issuable
sorted.with_order_id_desc
end
- def order_due_date_and_labels_priority(excluded_labels: [])
+ def order_due_date_and_labels_priority(direction = 'ASC', excluded_labels: [])
# The order_ methods also modify the query in other ways:
#
# - For milestones, we add a JOIN.
@@ -177,11 +179,11 @@ module Issuable
order_milestone_due_asc
.order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date])
- .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'),
- Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
+ .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, direction),
+ Gitlab::Database.nulls_last_order('highest_priority', direction))
end
- def order_labels_priority(excluded_labels: [], extra_select_columns: [])
+ def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [])
params = {
target_type: name,
target_column: "#{table_name}.id",
@@ -198,7 +200,7 @@ module Issuable
select(select_columns.join(', '))
.group(arel_table[:id])
- .reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
+ .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
end
def with_label(title, sort = nil)
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index eb315058c3a..f2cad09e779 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -26,6 +26,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
+ def supports_suggestion?
+ false
+ end
+
def discussions_rendered_on_frontend?
false
end
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
index 69554f18ea2..4bb4ffe2a8e 100644
--- a/app/models/concerns/redis_cacheable.rb
+++ b/app/models/concerns/redis_cacheable.rb
@@ -49,10 +49,6 @@ module RedisCacheable
end
def cast_value_from_cache(attribute, value)
- if Gitlab.rails5?
- self.class.type_for_attribute(attribute.to_s).cast(value)
- else
- self.class.column_for_attribute(attribute).type_cast_from_database(value)
- end
+ self.class.type_for_attribute(attribute.to_s).cast(value)
end
end
diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb
new file mode 100644
index 00000000000..57cd77b44b4
--- /dev/null
+++ b/app/models/concerns/shardable.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Shardable
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :shard
+ validates :shard, presence: true
+ end
+
+ def shard_name
+ shard&.name
+ end
+
+ def shard_name=(name)
+ self.shard = Shard.by_name(name)
+ end
+end
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index af699eeebce..498996f4f80 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -4,6 +4,8 @@ module Storage
module LegacyNamespace
extend ActiveSupport::Concern
+ include Gitlab::ShellAdapter
+
def move_dir
proj_with_tags = first_project_with_container_registry_tags
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index 23a43aec677..f5bb559ceda 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -9,24 +9,18 @@ module TokenAuthenticatable
private # rubocop:disable Lint/UselessAccessModifier
def add_authentication_token_field(token_field, options = {})
- @token_fields = [] unless @token_fields
- unique = options.fetch(:unique, true)
-
- if @token_fields.include?(token_field)
+ if token_authenticatable_fields.include?(token_field)
raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field")
end
- @token_fields << token_field
+ token_authenticatable_fields.push(token_field)
attr_accessor :cleartext_tokens
- strategy = if options[:digest]
- TokenAuthenticatableStrategies::Digest.new(self, token_field, options)
- else
- TokenAuthenticatableStrategies::Insecure.new(self, token_field, options)
- end
+ strategy = TokenAuthenticatableStrategies::Base
+ .fabricate(self, token_field, options)
- if unique
+ if options.fetch(:unique, true)
define_singleton_method("find_by_#{token_field}") do |token|
strategy.find_token_authenticatable(token)
end
@@ -53,6 +47,15 @@ module TokenAuthenticatable
define_method("reset_#{token_field}!") do
strategy.reset_token!(self)
end
+
+ 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_fields
+ @token_authenticatable_fields ||= []
end
end
end
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index 413721d3e6c..01fb194281a 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -2,6 +2,8 @@
module TokenAuthenticatableStrategies
class Base
+ attr_reader :klass, :token_field, :options
+
def initialize(klass, token_field, options)
@klass = klass
@token_field = token_field
@@ -22,6 +24,7 @@ module TokenAuthenticatableStrategies
def ensure_token(instance)
write_new_token(instance) unless token_set?(instance)
+ get_token(instance)
end
# Returns a token, but only saves when the database is in read & write mode
@@ -36,6 +39,36 @@ 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!'
+ end
+
+ if options[:digest]
+ TokenAuthenticatableStrategies::Digest.new(model, field, options)
+ elsif options[:encrypted]
+ TokenAuthenticatableStrategies::Encrypted.new(model, field, options)
+ else
+ TokenAuthenticatableStrategies::Insecure.new(model, field, options)
+ end
+ end
+
protected
def write_new_token(instance)
@@ -65,9 +98,5 @@ module TokenAuthenticatableStrategies
def token_set?(instance)
raise NotImplementedError
end
-
- def token_field_name
- @token_field
- end
end
end
diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
new file mode 100644
index 00000000000..152491aa6e9
--- /dev/null
+++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+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?
+ 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!'
+ end
+ end
+
+ def ensure_token(instance)
+ # TODO, tech debt, because some specs are testing migrations, but are still
+ # using factory bot to create resources, it might happen that a database
+ # schema does not have "#{token_name}_encrypted" field yet, however a bunch
+ # of models call `ensure_#{token_name}` in `before_save`.
+ #
+ # In that case we are using insecure strategy, but this should only happen
+ # in tests, because otherwise `encrypted_field` is going to exist.
+ #
+ # Another use case is when we are caching resources / columns, like we do
+ # in case of ApplicationSetting.
+
+ return super if instance.has_attribute?(encrypted_field)
+
+ if fully_encrypted?
+ raise ArgumentError, 'Using encrypted strategy when encrypted field is missing!'
+ else
+ insecure_strategy.ensure_token(instance)
+ end
+ end
+
+ def get_token(instance)
+ return insecure_strategy.get_token(instance) if migrating?
+
+ encrypted_token = instance.read_attribute(encrypted_field)
+ token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
+
+ token || (insecure_strategy.get_token(instance) if fallback?)
+ end
+
+ def set_token(instance, token)
+ raise ArgumentError unless token.present?
+
+ instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)
+ instance[token_field] = token if migrating?
+ instance[token_field] = nil if fallback?
+ token
+ end
+
+ def fully_encrypted?
+ !migrating? && !fallback?
+ end
+
+ protected
+
+ def find_by_plaintext_token(token, unscoped)
+ insecure_strategy.find_token_authenticatable(token, unscoped)
+ end
+
+ def find_by_encrypted_token(token, unscoped)
+ encrypted_value = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)
+ relation(unscoped).find_by(encrypted_field => encrypted_value)
+ end
+
+ def insecure_strategy
+ @insecure_strategy ||= TokenAuthenticatableStrategies::Insecure
+ .new(klass, token_field, options)
+ end
+
+ def token_set?(instance)
+ raw_token = instance.read_attribute(encrypted_field)
+
+ unless fully_encrypted?
+ raw_token ||= insecure_strategy.get_token(instance)
+ end
+
+ raw_token.present?
+ end
+
+ def encrypted_field
+ @encrypted_field ||= "#{@token_field}_encrypted"
+ end
+ end
+end
diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb
index 2bdef2a40e4..d79c0eae77e 100644
--- a/app/models/concerns/with_uploads.rb
+++ b/app/models/concerns/with_uploads.rb
@@ -17,6 +17,8 @@
module WithUploads
extend ActiveSupport::Concern
+ include FastDestroyAll::Helpers
+ include FeatureGate
# Currently there is no simple way how to select only not-mounted
# uploads, it should be all FileUploaders so we select them by
@@ -25,21 +27,40 @@ module WithUploads
included do
has_many :uploads, as: :model
+ has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model
- before_destroy :destroy_file_uploads
+ # TODO: when feature flag is removed, we can use just dependent: destroy
+ # option on :file_uploads
+ before_destroy :remove_file_uploads
+
+ use_fast_destroy :file_uploads, if: :fast_destroy_enabled?
+ end
+
+ def retrieve_upload(_identifier, paths)
+ uploads.find_by(path: paths)
end
+ private
+
# mounted uploads are deleted in carrierwave's after_commit hook,
# but FileUploaders which are not mounted must be deleted explicitly and
# it can not be done in after_commit because FileUploader requires loads
# associated model on destroy (which is already deleted in after_commit)
- def destroy_file_uploads
- self.uploads.where(uploader: FILE_UPLOADERS).find_each do |upload|
+ def remove_file_uploads
+ fast_destroy_enabled? ? delete_uploads : destroy_uploads
+ end
+
+ def delete_uploads
+ file_uploads.delete_all(:delete_all)
+ end
+
+ def destroy_uploads
+ file_uploads.find_each do |upload|
upload.destroy
end
end
- def retrieve_upload(_identifier, paths)
- uploads.find_by(path: paths)
+ def fast_destroy_enabled?
+ Feature.enabled?(:fast_destroy_uploads, self)
end
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index c32008aa9c7..279603496b0 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -66,10 +66,23 @@ class DiffNote < Note
self.original_position.diff_refs == diff_refs
end
+ def supports_suggestion?
+ 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)
+
+ line&.suggestible?
+ end
+
def discussion_first_note?
self == discussion.first_note
end
+ def banzai_render_context(field)
+ super.merge(suggestions_filter_enabled: supports_suggestion?)
+ end
+
private
def enqueue_diff_file_creation_job
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 934828946b9..cdfe3b7c023 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Environment < ActiveRecord::Base
+ include Gitlab::Utils::StrongMemoize
# Used to generate random suffixes for the slug
LETTERS = 'a'..'z'
NUMBERS = '0'..'9'
@@ -231,7 +232,9 @@ class Environment < ActiveRecord::Base
end
def deployment_platform
- project.deployment_platform(environment: self.name)
+ strong_memoize(:deployment_platform) do
+ project.deployment_platform(environment: self.name)
+ end
end
private
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index 7078496ff52..2fb6cadc8cd 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -8,16 +8,17 @@ class EnvironmentStatus
delegate :id, to: :environment
delegate :name, to: :environment
delegate :project, to: :environment
+ delegate :status, to: :deployment, allow_nil: true
delegate :deployed_at, to: :deployment, allow_nil: true
def self.for_merge_request(mr, user)
- build_environments_status(mr, user, mr.diff_head_sha)
+ build_environments_status(mr, user, mr.actual_head_pipeline)
end
def self.after_merge_request(mr, user)
return [] unless mr.merged?
- build_environments_status(mr, user, mr.merge_commit_sha)
+ build_environments_status(mr, user, mr.merge_pipeline)
end
def initialize(environment, merge_request, sha)
@@ -43,22 +44,6 @@ class EnvironmentStatus
.merge_request_diff_files.where(deleted_file: false)
end
- ##
- # Since frontend has not supported all statuses yet, BE has to
- # proxy some status to a supported status.
- def status
- return unless deployment
-
- case deployment.status
- when 'created'
- 'running'
- when 'canceled'
- 'failed'
- else
- deployment.status
- end
- end
-
private
PAGE_EXTENSIONS = /\A\.(s?html?|php|asp|cgi|pl)\z/i.freeze
@@ -76,13 +61,13 @@ class EnvironmentStatus
}
end
- def self.build_environments_status(mr, user, sha)
- Environment.where(project_id: [mr.source_project_id, mr.target_project_id])
- .available
- .with_deployment(sha).map do |environment|
+ def self.build_environments_status(mr, user, pipeline)
+ return [] unless pipeline
+
+ pipeline.environments.available.map do |environment|
next unless Ability.allowed?(user, :read_environment, environment)
- EnvironmentStatus.new(environment, mr, sha)
+ EnvironmentStatus.new(environment, mr, pipeline.sha)
end.compact
end
private_class_method :build_environments_status
diff --git a/app/models/event.rb b/app/models/event.rb
index 2e690f8c013..6a35bca72c5 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -87,7 +87,7 @@ class Event < ActiveRecord::Base
scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association
# is not always available (depending on the query being built).
- includes(:author, :project, project: :namespace)
+ includes(:author, :project, project: [:project_feature, :import_data, :namespace])
.preload(:target, :push_event_payload)
end
@@ -114,19 +114,6 @@ class Event < ActiveRecord::Base
end
end
- # Remove this method when removing Gitlab.rails5? code.
- def subclass_from_attributes(attrs)
- return super if Gitlab.rails5?
-
- # Without this Rails will keep calling this method on the returned class,
- # resulting in an infinite loop.
- return unless self == Event
-
- action = attrs.with_indifferent_access[inheritance_column].to_i
-
- PushEvent if action == PUSHED
- end
-
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
diff --git a/app/models/group.rb b/app/models/group.rb
index adb9169cfcd..233747cc2c2 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -55,7 +55,7 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
- add_authentication_token_field :runners_token
+ add_authentication_token_field :runners_token, encrypted: true, migrating: true
after_create :post_create_hook
after_destroy :post_destroy_hook
@@ -400,6 +400,10 @@ class Group < Namespace
ensure_runners_token!
end
+ def group_clusters_enabled?
+ Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true)
+ end
+
private
def update_two_factor_requirement
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index b2fb79bc7ed..1a8662db9fb 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -6,12 +6,12 @@ class WebHook < ActiveRecord::Base
attr_encrypted :token,
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_truncated
+ key: Settings.attr_encrypted_db_key_base_32
attr_encrypted :url,
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_truncated
+ key: Settings.attr_encrypted_db_key_base_32
has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 780035c77e2..b7e13bcbccf 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -235,20 +235,6 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- if options.key?(:issue_endpoints) && project
- url_helper = Gitlab::Routing.url_helpers
-
- issue_reference = options[:include_full_project_path] ? to_reference(full: true) : to_reference
-
- json.merge!(
- reference_path: issue_reference,
- real_path: url_helper.project_issue_path(project, self),
- issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'),
- toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self),
- assignable_labels_endpoint: url_helper.project_labels_path(project, format: :json, include_ancestor_groups: true)
- )
- end
-
if options.key?(:labels)
json[:labels] = labels.as_json(
project: project,
diff --git a/app/models/member.rb b/app/models/member.rb
index bc8ac14d148..9fc95ea00c3 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -7,6 +7,7 @@ class Member < ActiveRecord::Base
include Expirable
include Gitlab::Access
include Presentable
+ include Gitlab::Utils::StrongMemoize
attr_accessor :raw_invite_token
@@ -22,6 +23,7 @@ class Member < ActiveRecord::Base
message: "already exists in source",
allow_nil: true }
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
+ validate :higher_access_level_than_group, unless: :importing?
validates :invite_email,
presence: {
if: :invite?
@@ -364,6 +366,15 @@ class Member < ActiveRecord::Base
end
# rubocop: enable CodeReuse/ServiceClass
+ # Find the user's group member with a highest access level
+ def highest_group_member
+ strong_memoize(:highest_group_member) do
+ next unless user_id && source&.ancestors&.any?
+
+ GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last
+ end
+ end
+
private
def send_invite
@@ -430,4 +441,12 @@ class Member < ActiveRecord::Base
def notifiable_options
{}
end
+
+ def higher_access_level_than_group
+ 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)
+ end
+ end
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 537f2a3a231..016c18ce6c8 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -3,8 +3,6 @@
class ProjectMember < Member
SOURCE_TYPE = 'Project'.freeze
- include Gitlab::ShellAdapter
-
belongs_to :project, foreign_key: 'source_id'
# Make sure project member points only to project as it source
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 92add079a02..944b9f72396 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -48,8 +48,8 @@ class MergeRequest < ActiveRecord::Base
# is the inverse of MergeRequest#merge_request_diff, which means it may not be
# the latest diff, because we could have loaded any diff from this particular
# MR. If we haven't already loaded a diff, then it's fine to load the latest.
- def merge_request_diff(*args)
- fallback = latest_merge_request_diff if args.empty? && !association(:merge_request_diff).loaded?
+ def merge_request_diff
+ fallback = latest_merge_request_diff unless association(:merge_request_diff).loaded?
fallback || super
end
@@ -63,6 +63,7 @@ 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'
belongs_to :assignee, class_name: "User"
@@ -362,6 +363,10 @@ class MergeRequest < ActiveRecord::Base
end
end
+ def supports_suggestion?
+ true
+ end
+
# Calls `MergeWorker` to proceed with the merge process and
# updates `merge_jid` with the MergeWorker#jid.
# This helps tracking enqueued and ongoing merge jobs.
@@ -538,15 +543,26 @@ class MergeRequest < ActiveRecord::Base
def validate_branches
if target_project == source_project && target_branch == source_branch
- errors.add :branch_conflict, "You can not use same project/branch for source and target"
+ errors.add :branch_conflict, "You can't use same project/branch for source and target"
+ return
end
if opened?
- similar_mrs = self.target_project.merge_requests.where(source_branch: source_branch, target_branch: target_branch, source_project_id: source_project.try(:id)).opened
- similar_mrs = similar_mrs.where('id not in (?)', self.id) if self.id
- if similar_mrs.any?
- errors.add :validate_branches,
- "Cannot Create: This merge request already exists: #{similar_mrs.pluck(:title)}"
+ similar_mrs = target_project
+ .merge_requests
+ .where(source_branch: source_branch, target_branch: target_branch)
+ .where(source_project_id: source_project&.id)
+ .opened
+
+ similar_mrs = similar_mrs.where.not(id: id) if persisted?
+
+ conflict = similar_mrs.first
+
+ if conflict.present?
+ errors.add(
+ :validate_branches,
+ "Another open merge request already exists for this source branch: #{conflict.to_reference}"
+ )
end
end
end
@@ -603,10 +619,6 @@ class MergeRequest < ActiveRecord::Base
end
end
- def reload_merge_request_diff
- merge_request_diff(true)
- end
-
def viewable_diffs
@viewable_diffs ||= merge_request_diffs.viewable.to_a
end
@@ -966,6 +978,7 @@ class MergeRequest < ActiveRecord::Base
def mergeable_ci_state?
return true unless project.only_allow_merge_if_pipeline_succeeds?
+ return true unless head_pipeline
actual_head_pipeline&.success? || actual_head_pipeline&.skipped?
end
@@ -1052,26 +1065,70 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
- def all_pipelines
+ def all_pipelines(shas: all_commit_shas)
return Ci::Pipeline.none unless source_project
- @all_pipelines ||= source_project.pipelines
- .where(sha: all_commit_shas, ref: source_branch)
- .order(id: :desc)
+ @all_pipelines ||= source_project.ci_pipelines
+ .where(sha: shas, ref: source_branch)
+ .where(merge_request: [nil, self])
+ .sort_by_merge_request_pipelines
+ 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?
end
- # rubocop: disable CodeReuse/ServiceClass
+ 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
+ end
+ end
+
def compare_test_reports
unless has_test_reports?
return { status: :error, status_reason: 'This merge request does not have test reports' }
end
- with_reactive_cache(:compare_test_results) do |data|
- unless Ci::CompareTestReportsService.new(project)
+ compare_reports(Ci::CompareTestReportsService)
+ end
+
+ def compare_reports(service_class)
+ with_reactive_cache(service_class.name) do |data|
+ unless service_class.new(project)
.latest?(base_pipeline, actual_head_pipeline, data)
raise InvalidateReactiveCache
end
@@ -1079,19 +1136,14 @@ class MergeRequest < ActiveRecord::Base
data
end || { status: :parsing }
end
- # rubocop: enable CodeReuse/ServiceClass
- # rubocop: disable CodeReuse/ServiceClass
def calculate_reactive_cache(identifier, *args)
- case identifier.to_sym
- when :compare_test_results
- Ci::CompareTestReportsService.new(project).execute(
- base_pipeline, actual_head_pipeline)
- else
- raise NotImplementedError, "Unknown identifier: #{identifier}"
- end
+ service_class = identifier.constantize
+
+ raise NameError, service_class unless service_class < Ci::CompareReportsBaseService
+
+ service_class.new(project).execute(base_pipeline, actual_head_pipeline)
end
- # rubocop: enable CodeReuse/ServiceClass
def all_commits
# MySQL doesn't support LIMIT in a subquery.
@@ -1214,7 +1266,7 @@ class MergeRequest < ActiveRecord::Base
end
def base_pipeline
- @base_pipeline ||= project.pipelines
+ @base_pipeline ||= project.ci_pipelines
.order(id: :desc)
.find_by(sha: diff_base_sha)
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 11b03846f0b..3c9b1d32a53 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -3,7 +3,6 @@
class Namespace < ActiveRecord::Base
include CacheMarkdownField
include Sortable
- include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
include Routable
include AfterCommitQueue
@@ -192,9 +191,9 @@ class Namespace < ActiveRecord::Base
# returns all ancestors upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
- def ancestors_upto(top = nil)
+ def ancestors_upto(top = nil, hierarchy_order: nil)
Gitlab::GroupHierarchy.new(self.class.where(id: id))
- .ancestors(upto: top)
+ .ancestors(upto: top, hierarchy_order: hierarchy_order)
end
def self_and_ancestors
@@ -243,7 +242,7 @@ class Namespace < ActiveRecord::Base
end
def root_ancestor
- ancestors.reorder(nil).find_by(parent_id: nil)
+ self_and_ancestors.reorder(nil).find_by(parent_id: nil)
end
def subgroup?
diff --git a/app/models/note.rb b/app/models/note.rb
index 592efb714f3..becf14e9785 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -69,6 +69,12 @@ class Note < ActiveRecord::Base
belongs_to :last_edited_by, class_name: 'User'
has_many :todos
+
+ # The delete_all definition is required here in order
+ # to generate the correct DELETE sql for
+ # suggestions.delete_all calls
+ has_many :suggestions, -> { order(:relative_order) },
+ inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
@@ -110,7 +116,7 @@ class Note < ActiveRecord::Base
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
- :system_note_metadata, :note_diff_file)
+ :system_note_metadata, :note_diff_file, :suggestions)
end
scope :with_notes_filter, -> (notes_filter) do
@@ -131,7 +137,7 @@ class Note < ActiveRecord::Base
scope :with_associations, -> do
# FYI noteable cannot be loaded for LegacyDiffNote for commits
includes(:author, :noteable, :updated_by,
- project: [:project_members, { group: [:group_members] }])
+ project: [:project_members, :namespace, { group: [:group_members] }])
end
scope :with_metadata, -> { includes(:system_note_metadata) }
@@ -226,6 +232,10 @@ class Note < ActiveRecord::Base
Gitlab::HookData::NoteBuilder.new(self).build
end
+ def supports_suggestion?
+ false
+ end
+
def for_commit?
noteable_type == "Commit"
end
@@ -324,7 +334,7 @@ class Note < ActiveRecord::Base
end
def to_ability_name
- for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore
+ for_snippet? ? noteable.class.name.underscore : noteable_type.underscore
end
def can_be_discussion_note?
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 1600acfc575..e82eaf4e069 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -5,7 +5,7 @@ class NotificationSetting < ActiveRecord::Base
ignore_column :events
- enum level: { global: 3, watch: 2, mention: 4, participating: 1, disabled: 0, custom: 5 }
+ enum level: { global: 3, watch: 2, participating: 1, mention: 4, disabled: 0, custom: 5 }
default_value_for :level, NotificationSetting.levels[:global]
diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb
index 8ef74539209..ad6a008dee8 100644
--- a/app/models/pool_repository.rb
+++ b/app/models/pool_repository.rb
@@ -1,22 +1,112 @@
# frozen_string_literal: true
+# 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
- POOL_PREFIX = '@pools'
+ include Shardable
+ include AfterCommitQueue
- belongs_to :shard
- validates :shard, presence: true
+ has_one :source_project, class_name: 'Project'
+ validates :source_project, presence: true
- # For now, only pool repositories are tracked in the database. However, we may
- # want to add other repository types in the future
- self.table_name = 'repositories'
+ has_many :member_projects, class_name: 'Project'
- has_many :pool_member_projects, class_name: 'Project', foreign_key: :pool_repository_id
+ after_create :correct_disk_path
- def shard_name
- shard&.name
+ state_machine :state, initial: :none do
+ state :scheduled
+ state :ready
+ state :failed
+ state :obsolete
+
+ event :schedule do
+ transition none: :scheduled
+ end
+
+ event :mark_ready do
+ transition [:scheduled, :failed] => :ready
+ end
+
+ event :mark_failed do
+ transition all => :failed
+ end
+
+ event :mark_obsolete do
+ transition all => :obsolete
+ end
+
+ state all - [:ready] do
+ def joinable?
+ false
+ end
+ end
+
+ state :ready do
+ def joinable?
+ true
+ end
+ end
+
+ after_transition none: :scheduled do |pool, _|
+ pool.run_after_commit do
+ ::ObjectPool::CreateWorker.perform_async(pool.id)
+ end
+ end
+
+ after_transition scheduled: :ready do |pool, _|
+ pool.run_after_commit do
+ ::ObjectPool::ScheduleJoinWorker.perform_async(pool.id)
+ end
+ end
+
+ after_transition any => :obsolete do |pool, _|
+ pool.run_after_commit do
+ ::ObjectPool::DestroyWorker.perform_async(pool.id)
+ end
+ end
+ end
+
+ def create_object_pool
+ object_pool.create
+ end
+
+ # The members of the pool should have fetched the missing objects to their own
+ # objects directory. If the caller fails to do so, data loss might occur
+ def delete_object_pool
+ object_pool.delete
+ end
+
+ def link_repository(repository)
+ 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)
+
+ mark_obsolete unless member_projects.where.not(id: repository.project.id).exists?
+ end
+
+ def object_pool
+ @object_pool ||= Gitlab::Git::ObjectPool.new(
+ shard.name,
+ disk_path + '.git',
+ source_project.repository.raw)
+ end
+
+ def inspect
+ "#<#{self.class.name} id:#{id} state:#{state} disk_path:#{disk_path} source_project: #{source_project.full_path}>"
+ end
+
+ private
+
+ def correct_disk_path
+ update!(disk_path: storage.disk_path)
end
- def shard_name=(name)
- self.shard = Shard.by_name(name)
+ def storage
+ Storage::HashedProject
+ .new(self, prefix: Storage::HashedProject::POOL_PATH_PREFIX)
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 39978d8a4c4..e4b8db860a4 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -30,6 +30,7 @@ class Project < ActiveRecord::Base
include FeatureGate
include OptionallySearch
include FromUnion
+ include IgnorableColumn
extend Gitlab::Cache::RequestCache
extend Gitlab::ConfigHelper
@@ -55,6 +56,8 @@ class Project < ActiveRecord::Base
VALID_MIRROR_PORTS = [22, 80, 443].freeze
VALID_MIRROR_PROTOCOLS = %w(http https ssh git).freeze
+ ignore_column :import_status, :import_jid, :import_error
+
cache_markdown_field :description, pipeline: :description
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
@@ -63,6 +66,12 @@ class Project < ActiveRecord::Base
delegate :base_dir, :disk_path, :ensure_storage_path_exists, to: :storage
+ delegate :scheduled?, :started?, :in_progress?,
+ :failed?, :finished?,
+ prefix: :import, to: :import_state, allow_nil: true
+
+ delegate :no_import?, to: :import_state, allow_nil: true
+
default_value_for :archived, false
default_value_for :visibility_level, gitlab_config_features.visibility_level
default_value_for :resolve_outdated_diff_discussions, false
@@ -76,7 +85,7 @@ 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
+ add_authentication_token_field :runners_token, encrypted: true, migrating: true
before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? }
@@ -177,6 +186,7 @@ class Project < ActiveRecord::Base
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :project_repository, inverse_of: :project
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
@@ -228,6 +238,7 @@ class Project < ActiveRecord::Base
has_one :cluster_project, class_name: 'Clusters::Project'
has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
has_many :cluster_ingresses, through: :clusters, source: :application_ingress, class_name: 'Clusters::Applications::Ingress'
+ has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace'
has_many :prometheus_metrics
@@ -237,7 +248,17 @@ class Project < ActiveRecord::Base
has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :commit_statuses
- has_many :pipelines, class_name: 'Ci::Pipeline', inverse_of: :project
+ # The relation :all_pipelines is intented to be used when we want to get the
+ # whole list of pipelines associated to the project
+ has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project
+ # The relation :ci_pipelines is intented to be used when we want to get only
+ # those pipeline which are directly related to CI. There are
+ # other pipelines, like webide ones, that we won't retrieve
+ # if we use this relation.
+ has_many :ci_pipelines,
+ -> { ci_sources },
+ class_name: 'Ci::Pipeline',
+ inverse_of: :project
has_many :stages, class_name: 'Ci::Stage', inverse_of: :project
# Ci::Build objects store data on the file system such as artifact files and
@@ -280,6 +301,8 @@ class Project < ActiveRecord::Base
delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team
delegate :add_master, to: :team # @deprecated
delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings
+ delegate :group_clusters_enabled?, to: :group, allow_nil: true
+ delegate :root_ancestor, to: :namespace, allow_nil: true
# Validations
validates :creator, presence: true, on: :create
@@ -316,6 +339,7 @@ class Project < ActiveRecord::Base
presence: true,
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
validates :variables, variable_duplicates: { scope: :environment_scope }
+ validates :bfg_object_map, file_size: { maximum: :max_attachment_size }
# Scopes
scope :pending_delete, -> { where(pending_delete: true) }
@@ -372,9 +396,16 @@ class Project < ActiveRecord::Base
.where(project_ci_cd_settings: { group_runners_enabled: true })
end
+ scope :missing_kubernetes_namespace, -> (kubernetes_namespaces) do
+ subquery = kubernetes_namespaces.select('1').where('clusters_kubernetes_namespaces.project_id = projects.id')
+
+ where('NOT EXISTS (?)', subquery)
+ end
+
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
- chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600
+ chronic_duration_attr :build_timeout_human_readable, :build_timeout,
+ 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,
@@ -382,6 +413,9 @@ class Project < ActiveRecord::Base
only_integer: true,
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
+
# Returns a project, if it is not about to be removed.
#
# id - The ID of the project to retrieve.
@@ -451,8 +485,8 @@ class Project < ActiveRecord::Base
scope :excluding_project, ->(project) { where.not(id: project) }
- scope :joins_import_state, -> { joins("LEFT JOIN project_mirror_data import_state ON import_state.project_id = projects.id") }
- scope :import_started, -> { joins_import_state.where("import_state.status = 'started' OR projects.import_status = 'started'") }
+ # We require an alias to the project_mirror_data_table in order to use import_state in our queries
+ scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") }
scope :for_group, -> (group) { where(group: group) }
class << self
@@ -535,11 +569,13 @@ class Project < ActiveRecord::Base
# returns all ancestor-groups upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
- def ancestors_upto(top = nil)
+ def ancestors_upto(top = nil, hierarchy_order: nil)
Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
- .base_and_ancestors(upto: top)
+ .base_and_ancestors(upto: top, hierarchy_order: hierarchy_order)
end
+ alias_method :ancestors, :ancestors_upto
+
def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
@@ -610,7 +646,7 @@ class Project < ActiveRecord::Base
# ref can't be HEAD, can only be branch/tag name or SHA
def latest_successful_builds_for(ref = default_branch)
- latest_pipeline = pipelines.latest_successful_for(ref)
+ latest_pipeline = ci_pipelines.latest_successful_for(ref)
if latest_pipeline
latest_pipeline.builds.latest.with_artifacts_archive
@@ -619,6 +655,11 @@ class Project < ActiveRecord::Base
end
end
+ def latest_successful_build_for(job_name, ref = default_branch)
+ builds = latest_successful_builds_for(ref)
+ builds.find_by!(name: job_name)
+ end
+
def merge_base_commit(first_commit_id, second_commit_id)
sha = repository.merge_base(first_commit_id, second_commit_id)
commit_by(oid: sha) if sha
@@ -628,6 +669,14 @@ class Project < ActiveRecord::Base
id && persisted?
end
+ def import_status
+ import_state&.status || 'none'
+ end
+
+ def human_import_status_name
+ import_state&.human_status_name || 'none'
+ end
+
def add_import_job
job_id =
if forked?
@@ -659,7 +708,7 @@ class Project < ActiveRecord::Base
ProjectCacheWorker.perform_async(self.id)
end
- update(import_error: nil)
+ import_state.update(last_error: nil)
remove_import_data
end
@@ -700,15 +749,9 @@ class Project < ActiveRecord::Base
return if data.nil? && credentials.nil?
project_import_data = import_data || build_import_data
- if data
- project_import_data.data ||= {}
- project_import_data.data = project_import_data.data.merge(data)
- end
- if credentials
- project_import_data.credentials ||= {}
- project_import_data.credentials = project_import_data.credentials.merge(credentials)
- end
+ project_import_data.merge_data(data.to_h)
+ project_import_data.merge_credentials(credentials.to_h)
project_import_data
end
@@ -721,130 +764,6 @@ class Project < ActiveRecord::Base
import_url.present?
end
- def imported?
- import_finished?
- end
-
- def import_in_progress?
- import_started? || import_scheduled?
- end
-
- def import_state_args
- {
- status: self[:import_status],
- jid: self[:import_jid],
- last_error: self[:import_error]
- }
- end
-
- def ensure_import_state(force: false)
- return if !force && (self[:import_status] == 'none' || self[:import_status].nil?)
- return unless import_state.nil?
-
- if persisted?
- create_import_state(import_state_args)
-
- update_column(:import_status, 'none')
- else
- build_import_state(import_state_args)
-
- self[:import_status] = 'none'
- end
- end
-
- def human_import_status_name
- ensure_import_state
-
- import_state.human_status_name
- end
-
- def import_schedule
- ensure_import_state(force: true)
-
- import_state.schedule
- end
-
- def force_import_start
- ensure_import_state(force: true)
-
- import_state.force_start
- end
-
- def import_start
- ensure_import_state(force: true)
-
- import_state.start
- end
-
- def import_fail
- ensure_import_state(force: true)
-
- import_state.fail_op
- end
-
- def import_finish
- ensure_import_state(force: true)
-
- import_state.finish
- end
-
- def import_jid=(new_jid)
- ensure_import_state(force: true)
-
- import_state.jid = new_jid
- end
-
- def import_jid
- ensure_import_state
-
- import_state&.jid
- end
-
- def import_error=(new_error)
- ensure_import_state(force: true)
-
- import_state.last_error = new_error
- end
-
- def import_error
- ensure_import_state
-
- import_state&.last_error
- end
-
- def import_status=(new_status)
- ensure_import_state(force: true)
-
- import_state.status = new_status
- end
-
- def import_status
- ensure_import_state
-
- import_state&.status || 'none'
- end
-
- def no_import?
- import_status == 'none'
- end
-
- def import_started?
- # import? does SQL work so only run it if it looks like there's an import running
- import_status == 'started' && import?
- end
-
- def import_scheduled?
- import_status == 'scheduled'
- end
-
- def import_failed?
- import_status == 'failed'
- end
-
- def import_finished?
- import_status == 'finished'
- end
-
def safe_import_url
Gitlab::UrlSanitizer.new(import_url).masked_url
end
@@ -985,9 +904,9 @@ class Project < ActiveRecord::Base
end
def readme_url
- readme = repository.readme
- if readme
- Gitlab::Routing.url_helpers.project_blob_url(self, File.join(default_branch, readme.path))
+ readme_path = repository.readme_path
+ if readme_path
+ Gitlab::Routing.url_helpers.project_blob_url(self, File.join(default_branch, readme_path))
end
end
@@ -1166,6 +1085,12 @@ class Project < ActiveRecord::Base
path
end
+ def all_clusters
+ group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto } )
+
+ Clusters::Cluster.from_union([clusters, group_clusters])
+ end
+
def items_for(entity)
case entity
when 'issue' then
@@ -1246,6 +1171,11 @@ class Project < ActiveRecord::Base
"#{web_url}.git"
end
+ # Is overriden in EE
+ def lfs_http_url_to_repo(_)
+ http_url_to_repo
+ end
+
def forked?
fork_network && fork_network.root_project != self
end
@@ -1313,6 +1243,13 @@ class Project < ActiveRecord::Base
false
end
+ def track_project_repository
+ return unless hashed_storage?(:repository)
+
+ project_repo = project_repository || build_project_repository
+ project_repo.update!(shard_name: repository_storage, disk_path: disk_path)
+ end
+
def create_repository(force: false)
# Forked import is handled asynchronously
return if forked? && !force
@@ -1469,7 +1406,7 @@ class Project < ActiveRecord::Base
return unless sha
- pipelines.order(id: :desc).find_by(sha: sha, ref: ref)
+ ci_pipelines.order(id: :desc).find_by(sha: sha, ref: ref)
end
def latest_successful_pipeline_for_default_branch
@@ -1478,12 +1415,12 @@ class Project < ActiveRecord::Base
end
@latest_successful_pipeline_for_default_branch =
- pipelines.latest_successful_for(default_branch)
+ ci_pipelines.latest_successful_for(default_branch)
end
def latest_successful_pipeline_for(ref = nil)
if ref && ref != default_branch
- pipelines.latest_successful_for(ref)
+ ci_pipelines.latest_successful_for(ref)
else
latest_successful_pipeline_for_default_branch
end
@@ -1643,10 +1580,11 @@ class Project < ActiveRecord::Base
def after_import
repository.after_import
wiki.repository.after_import
- import_finish
- remove_import_jid
+ import_state.finish
+ import_state.remove_jid
update_project_counter_caches
after_create_default_branch
+ join_pool_repository
refresh_markdown_cache!
end
@@ -1684,32 +1622,11 @@ class Project < ActiveRecord::Base
end
# rubocop: enable CodeReuse/ServiceClass
- def remove_import_jid
- return unless import_jid
-
- Gitlab::SidekiqStatus.unset(import_jid)
-
- import_state.update_column(:jid, nil)
- end
-
# Lazy loading of the `pipeline_status` attribute
def pipeline_status
@pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self)
end
- def mark_import_as_failed(error_message)
- original_errors = errors.dup
- sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message)
-
- import_fail
-
- import_state.update_column(:last_error, sanitized_message)
- rescue ActiveRecord::ActiveRecordError => e
- Rails.logger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}")
- ensure
- @errors = original_errors
- end
-
def add_export_job(current_user:, after_export_strategy: nil, params: {})
job_id = ProjectExportWorker.perform_async(current_user.id, self.id, after_export_strategy, params)
@@ -1986,17 +1903,6 @@ class Project < ActiveRecord::Base
Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki))
end
- # Refreshes the expiration time of the associated import job ID.
- #
- # This method can be used by asynchronous importers to refresh the status,
- # preventing the StuckImportJobsWorker from marking the import as failed.
- def refresh_import_jid_expiration
- return unless import_jid
-
- Gitlab::SidekiqStatus
- .set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
- end
-
def badges
return project_badges unless group
@@ -2071,8 +1977,56 @@ class Project < ActiveRecord::Base
Ability.allowed?(user, :read_project_snippet, self)
end
+ def max_attachment_size
+ Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
+ end
+
+ def object_pool_params
+ return {} unless !forked? && git_objects_poolable?
+
+ {
+ repository_storage: repository_storage,
+ pool_repository: pool_repository || create_new_pool_repository
+ }
+ end
+
+ # Git objects are only poolable when the project is or has:
+ # - Hashed storage -> The object pool will have a remote to its members, using relative paths.
+ # If the repository path changes we would have to update the remote.
+ # - Public -> User will be able to fetch Git objects that might not exist
+ # in their own repository.
+ # - Repository -> Else the disk path will be empty, and there's nothing to pool
+ def git_objects_poolable?
+ hashed_storage?(:repository) &&
+ public? &&
+ repository_exists? &&
+ Gitlab::CurrentSettings.hashed_storage_enabled &&
+ Feature.enabled?(:object_pools, self)
+ end
+
+ def leave_pool_repository
+ pool_repository&.unlink_repository(repository)
+ end
+
private
+ 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.schedule unless pool.scheduled?
+ pool
+ end
+
+ def join_pool_repository
+ return unless pool_repository
+
+ ObjectPool::JoinWorker.perform_async(pool_repository.id, self.id)
+ end
+
def use_hashed_storage
if self.new_record? && Gitlab::CurrentSettings.hashed_storage_enabled
self.storage_version = LATEST_STORAGE_VERSION
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index 2c3080c6d8d..525725034a5 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -22,4 +22,12 @@ class ProjectImportData < ActiveRecord::Base
# bang doesn't work here - attr_encrypted makes it not to work
self.credentials = self.credentials.deep_symbolize_keys unless self.credentials.blank?
end
+
+ def merge_data(hash)
+ self.data = data.to_h.merge(hash) unless hash.empty?
+ end
+
+ def merge_credentials(hash)
+ self.credentials = credentials.to_h.merge(hash) unless hash.empty?
+ end
end
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index 7126bb66d80..488f0cb5971 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -69,4 +69,33 @@ class ProjectImportState < ActiveRecord::Base
ensure
@errors = original_errors
end
+
+ alias_method :no_import?, :none?
+
+ def in_progress?
+ scheduled? || started?
+ end
+
+ def started?
+ # import? does SQL work so only run it if it looks like there's an import running
+ status == 'started' && project.import?
+ end
+
+ def remove_jid
+ return unless jid
+
+ Gitlab::SidekiqStatus.unset(jid)
+
+ update_column(:jid, nil)
+ end
+
+ # Refreshes the expiration time of the associated import job ID.
+ #
+ # This method can be used by asynchronous importers to refresh the status,
+ # preventing the StuckImportJobsWorker from marking the import as failed.
+ def refresh_jid_expiration
+ return unless jid
+
+ Gitlab::SidekiqStatus.set(jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
+ end
end
diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb
new file mode 100644
index 00000000000..38913f3f2f5
--- /dev/null
+++ b/app/models/project_repository.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ProjectRepository < ActiveRecord::Base
+ include Shardable
+
+ belongs_to :project, inverse_of: :project_repository
+
+ class << self
+ def find_project(disk_path)
+ find_by(disk_path: disk_path)&.project
+ end
+ end
+end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index c52a531e5fe..b801fd84a07 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -110,14 +110,12 @@ class KubernetesService < DeploymentService
# Clusters::Platforms::Kubernetes, it won't be used on this method
# as it's only needed for Clusters::Cluster.
def predefined_variables(project:)
- config = YAML.dump(kubeconfig)
-
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: 'KUBECONFIG', value: config, public: false, file: true)
+ .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
if ca_pem.present?
variables
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index 6f39a5e6e83..d60a6a7efa3 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -38,11 +38,11 @@ class PipelinesEmailService < Service
end
def can_test?
- project.pipelines.any?
+ project.ci_pipelines.any?
end
def test_data(project, user)
- data = Gitlab::DataBuilder::Pipeline.build(project.pipelines.last)
+ data = Gitlab::DataBuilder::Pipeline.build(project.ci_pipelines.last)
data[:user] = user.hook_attrs
data
end
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 211e5c3fcbf..60cb2d380d5 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) if api_url && manual_configuration? && active?
+ RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active?
end
def prometheus_available?
diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb
index ce2db9cb44c..defbade1ed6 100644
--- a/app/models/prometheus_metric.rb
+++ b/app/models/prometheus_metric.rb
@@ -5,11 +5,12 @@ class PrometheusMetric < ActiveRecord::Base
enum group: {
# built-in groups
- nginx_ingress: -1,
+ nginx_ingress_vts: -1,
ha_proxy: -2,
aws_elb: -3,
nginx: -4,
kubernetes: -5,
+ nginx_ingress: -6,
# custom/user groups
business: 0,
@@ -30,6 +31,7 @@ class PrometheusMetric < ActiveRecord::Base
GROUP_TITLES = {
# built-in groups
+ nginx_ingress_vts: _('Response metrics (NGINX Ingress VTS)'),
nginx_ingress: _('Response metrics (NGINX Ingress)'),
ha_proxy: _('Response metrics (HA Proxy)'),
aws_elb: _('Response metrics (AWS ELB)'),
@@ -43,7 +45,8 @@ class PrometheusMetric < ActiveRecord::Base
}.freeze
REQUIRED_METRICS = {
- nginx_ingress: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg),
+ nginx_ingress_vts: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg),
+ nginx_ingress: %w(nginx_ingress_controller_requests nginx_ingress_controller_ingress_upstream_latency_seconds_sum),
ha_proxy: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total),
aws_elb: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum),
nginx: %w(nginx_server_requests nginx_server_requestMsec),
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 6c1073265a1..d075440b147 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ProtectedBranch < ActiveRecord::Base
- include Gitlab::ShellAdapter
include ProtectedRef
protected_ref_access_levels :merge, :push
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index 94746141945..d28ebabfe49 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ProtectedTag < ActiveRecord::Base
- include Gitlab::ShellAdapter
include ProtectedRef
validates :name, uniqueness: { scope: :project_id }
diff --git a/app/models/release.rb b/app/models/release.rb
index cba80ad30ca..7a09ee459a6 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -6,6 +6,7 @@ class Release < ActiveRecord::Base
cache_markdown_field :description
belongs_to :project
+ belongs_to :author, class_name: 'User'
validates :description, :project, :tag, presence: true
end
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index a3415a4a14c..5a6895aefab 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -15,8 +15,6 @@ class RemoteMirror < ActiveRecord::Base
insecure_mode: true,
algorithm: 'aes-256-cbc'
- default_value_for :only_protected_branches, true
-
belongs_to :project, inverse_of: :remote_mirrors
validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true }
@@ -67,10 +65,14 @@ class RemoteMirror < ActiveRecord::Base
)
end
- after_transition started: :failed do |remote_mirror, _|
+ after_transition started: :failed do |remote_mirror|
Gitlab::Metrics.add_event(:remote_mirrors_failed)
remote_mirror.update(last_update_at: Time.now)
+
+ remote_mirror.run_after_commit do
+ RemoteMirrorNotificationWorker.perform_async(remote_mirror.id)
+ end
end
end
@@ -137,8 +139,8 @@ class RemoteMirror < ActiveRecord::Base
end
def mark_as_failed(error_message)
- update_fail
update_column(:last_error, Gitlab::UrlSanitizer.sanitize(error_message))
+ update_fail
end
def url=(value)
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 427dac99b79..015a179f374 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -17,7 +17,6 @@ class Repository
#{REF_ENVIRONMENTS}
].freeze
- include Gitlab::ShellAdapter
include Gitlab::RepositoryCacheAdapter
attr_accessor :full_path, :disk_path, :project, :is_wiki
@@ -35,7 +34,7 @@ class Repository
#
# For example, for entry `:commit_count` there's a method called `commit_count` which
# stores its data in the `commit_count` cache key.
- CACHED_METHODS = %i(size commit_count rendered_readme contribution_guide
+ CACHED_METHODS = %i(size commit_count rendered_readme readme_path contribution_guide
changelog license_blob license_key gitignore
gitlab_ci_yml branch_names tag_names branch_count
tag_count avatar exists? root_ref has_visible_content?
@@ -48,7 +47,7 @@ class Repository
# changed. This Hash maps file types (as returned by Gitlab::FileDetector) to
# the corresponding methods to call for refreshing caches.
METHOD_CACHES_FOR_FILE_TYPES = {
- readme: :rendered_readme,
+ readme: %i(rendered_readme readme_path),
changelog: :changelog,
license: %i(license_blob license_key license),
contributing: :contribution_guide,
@@ -591,6 +590,11 @@ class Repository
head_tree&.readme
end
+ def readme_path
+ readme&.path
+ end
+ cache_method :readme_path
+
def rendered_readme
return unless readme
diff --git a/app/models/service.rb b/app/models/service.rb
index 5b8bf6e7cf0..9dcb0aab0a3 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -210,11 +210,7 @@ class Service < ActiveRecord::Base
class_eval %{
def #{arg}?
# '!!' is used because nil or empty string is converted to nil
- if Gitlab.rails5?
- !!ActiveRecord::Type::Boolean.new.cast(#{arg})
- else
- !!ActiveRecord::Type::Boolean.new.type_cast_from_database(#{arg})
- end
+ !!ActiveRecord::Type::Boolean.new.cast(#{arg})
end
}
end
diff --git a/app/models/shard.rb b/app/models/shard.rb
index 2e75bc91df0..e39d4232486 100644
--- a/app/models/shard.rb
+++ b/app/models/shard.rb
@@ -18,7 +18,9 @@ class Shard < ActiveRecord::Base
end
def self.by_name(name)
- find_or_create_by(name: name)
+ transaction(requires_new: true) do
+ find_or_create_by(name: name)
+ end
rescue ActiveRecord::RecordNotUnique
retry
end
diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb
index 90710f73fd3..911fb7e9ce9 100644
--- a/app/models/storage/hashed_project.rb
+++ b/app/models/storage/hashed_project.rb
@@ -5,17 +5,19 @@ module Storage
attr_accessor :project
delegate :gitlab_shell, :repository_storage, to: :project
- ROOT_PATH_PREFIX = '@hashed'.freeze
+ REPOSITORY_PATH_PREFIX = '@hashed'
+ POOL_PATH_PREFIX = '@pools'
- def initialize(project)
+ def initialize(project, prefix: REPOSITORY_PATH_PREFIX)
@project = project
+ @prefix = prefix
end
# Base directory
#
# @return [String] directory where repository is stored
def base_dir
- "#{ROOT_PATH_PREFIX}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash
+ "#{@prefix}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash
end
# Disk path is used to build repository and project's wiki path on disk
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
new file mode 100644
index 00000000000..c76b8e71507
--- /dev/null
+++ b/app/models/suggestion.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+class Suggestion < ApplicationRecord
+ belongs_to :note, inverse_of: :suggestions
+ validates :note, presence: true
+ validates :commit_id, presence: true, if: :applied?
+
+ delegate :original_position, :position, :diff_file,
+ :noteable, to: :note
+
+ def project
+ noteable.source_project
+ end
+
+ def branch
+ noteable.source_branch
+ 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?
+
+ !applied? &&
+ noteable.opened? &&
+ different_content? &&
+ note.active?
+ end
+
+ private
+
+ def different_content?
+ from_content != to_content
+ end
+end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index e01e9c6a4f0..20860f14b83 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -25,6 +25,25 @@ class Upload < ActiveRecord::Base
Digest::SHA256.file(path).hexdigest
end
+ class << self
+ ##
+ # FastDestroyAll concerns
+ def begin_fast_destroy
+ {
+ Uploads::Local => Uploads::Local.new.keys(with_files_stored_locally),
+ Uploads::Fog => Uploads::Fog.new.keys(with_files_stored_remotely)
+ }
+ end
+
+ ##
+ # FastDestroyAll concerns
+ def finalize_fast_destroy(keys)
+ keys.each do |store_class, paths|
+ store_class.new.delete_keys_async(paths)
+ end
+ end
+ end
+
def absolute_path
raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local?
return path unless relative_path?
diff --git a/app/models/uploads/base.rb b/app/models/uploads/base.rb
new file mode 100644
index 00000000000..f9814159958
--- /dev/null
+++ b/app/models/uploads/base.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Uploads
+ class Base
+ BATCH_SIZE = 100
+
+ attr_reader :logger
+
+ def initialize(logger: nil)
+ @logger ||= Rails.logger
+ end
+
+ def delete_keys_async(keys_to_delete)
+ keys_to_delete.each_slice(BATCH_SIZE) do |batch|
+ DeleteStoredFilesWorker.perform_async(self.class, batch)
+ end
+ end
+ end
+end
diff --git a/app/models/uploads/fog.rb b/app/models/uploads/fog.rb
new file mode 100644
index 00000000000..b44e273e9ab
--- /dev/null
+++ b/app/models/uploads/fog.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Uploads
+ class Fog < Base
+ include ::Gitlab::Utils::StrongMemoize
+
+ def available?
+ object_store.enabled
+ end
+
+ def keys(relation)
+ return [] unless available?
+
+ relation.pluck(:path)
+ end
+
+ def delete_keys(keys)
+ keys.each do |key|
+ connection.delete_object(bucket_name, key)
+ end
+ end
+
+ private
+
+ def object_store
+ Gitlab.config.uploads.object_store
+ end
+
+ def bucket_name
+ return unless available?
+
+ object_store.remote_directory
+ end
+
+ def connection
+ return unless available?
+
+ strong_memoize(:connection) do
+ ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys)
+ end
+ end
+ end
+end
diff --git a/app/models/uploads/local.rb b/app/models/uploads/local.rb
new file mode 100644
index 00000000000..2901c33c359
--- /dev/null
+++ b/app/models/uploads/local.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Uploads
+ class Local < Base
+ def keys(relation)
+ relation.includes(:model).find_each.map(&:absolute_path)
+ end
+
+ def delete_keys(keys)
+ keys.each do |path|
+ delete_file(path)
+ end
+ end
+
+ private
+
+ def delete_file(path)
+ unless exists?(path)
+ logger.warn("File '#{path}' doesn't exist, skipping")
+ return
+ end
+
+ unless in_uploads?(path)
+ message = "Path '#{path}' is not in uploads dir, skipping"
+ logger.warn(message)
+ Gitlab::Sentry.track_exception(RuntimeError.new(message), extra: { uploads_dir: storage_dir })
+ return
+ end
+
+ FileUtils.rm(path)
+ delete_dir!(File.dirname(path))
+ end
+
+ def exists?(path)
+ path.present? && File.exist?(path)
+ end
+
+ def in_uploads?(path)
+ path.start_with?(storage_dir)
+ end
+
+ def delete_dir!(path)
+ Dir.rmdir(path)
+ rescue Errno::ENOENT
+ # Ignore: path does not exist
+ rescue Errno::ENOTDIR
+ # Ignore: path is not a dir
+ rescue Errno::ENOTEMPTY, Errno::EEXIST
+ # Ignore: dir is not empty
+ end
+
+ def storage_dir
+ @storage_dir ||= File.realpath(Gitlab.config.uploads.storage_path)
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index dbd754dd25a..f20756d1cc3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -130,6 +130,7 @@ class User < ActiveRecord::Base
has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :events, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 7769c3d71c0..b1d6d461928 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -85,6 +85,12 @@ class WikiPage
alias_method :to_param, :slug
+ def human_title
+ return 'Home' if title == 'home'
+
+ title
+ end
+
# The formatted title of this page.
def title
if @attributes[:title]
diff --git a/app/policies/commit_policy.rb b/app/policies/commit_policy.rb
index 67e9bc12804..4d4f0ba9267 100644
--- a/app/policies/commit_policy.rb
+++ b/app/policies/commit_policy.rb
@@ -2,4 +2,6 @@
class CommitPolicy < BasePolicy
delegate { @subject.project }
+
+ rule { can?(:download_code) }.enable :read_commit
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 6b4e56ef5e4..d1264559438 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -40,6 +40,7 @@ class GroupPolicy < BasePolicy
rule { guest }.policy do
enable :read_group
+ enable :read_list
enable :upload_file
enable :read_label
end
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index bbc2b48b856..f22843b6463 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -9,8 +9,17 @@ class NotePolicy < BasePolicy
condition(:editable, scope: :subject) { @subject.editable? }
+ condition(:can_read_noteable) { can?(:"read_#{@subject.to_ability_name}") }
+
rule { ~editable }.prevent :admin_note
+ # If user can't read the issue/MR/etc then they should not be allowed to do anything to their own notes
+ rule { ~can_read_noteable }.policy do
+ prevent :read_note
+ prevent :admin_note
+ prevent :resolve_note
+ end
+
rule { is_author }.policy do
enable :read_note
enable :admin_note
diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb
new file mode 100644
index 00000000000..301b7d965f5
--- /dev/null
+++ b/app/policies/suggestion_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class SuggestionPolicy < BasePolicy
+ delegate { @subject.project }
+
+ condition(:can_push_to_branch) do
+ Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch)
+ end
+
+ rule { can_push_to_branch }.enable :apply_suggestion
+end
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index 7e6eccb648c..7a5b68f9a4b 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -2,8 +2,22 @@
module Clusters
class ClusterPresenter < Gitlab::View::Presenter::Delegated
+ include ActionView::Helpers::SanitizeHelper
+ include ActionView::Helpers::UrlHelper
+ include IconsHelper
+
presents :cluster
+ # We do not want to show the group path for clusters belonging to the
+ # clusterable, only for the ancestor clusters.
+ def item_link(clusterable_presenter)
+ if cluster.group_type? && clusterable != clusterable_presenter.subject
+ contracted_group_name(cluster.group) + ' / ' + link_to_cluster
+ else
+ link_to_cluster
+ end
+ end
+
def gke_cluster_url
"https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp?
end
@@ -12,6 +26,18 @@ module Clusters
can?(current_user, :update_cluster, cluster) && created?
end
+ def can_read_cluster?
+ can?(current_user, :read_cluster, cluster)
+ end
+
+ def cluster_type_description
+ if cluster.project_type?
+ s_("ClusterIntegration|Project cluster")
+ elsif cluster.group_type?
+ s_("ClusterIntegration|Group cluster")
+ end
+ end
+
def show_path
if cluster.project_type?
project_cluster_path(project, cluster)
@@ -21,5 +47,29 @@ module Clusters
raise NotImplementedError
end
end
+
+ private
+
+ def clusterable
+ if cluster.group_type?
+ cluster.group
+ elsif cluster.project_type?
+ cluster.project
+ end
+ end
+
+ def contracted_group_name(group)
+ sanitize(group.full_name)
+ .sub(%r{\/.*\/}, "/ #{contracted_icon} /")
+ .html_safe
+ end
+
+ def contracted_icon
+ sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle')
+ end
+
+ def link_to_cluster
+ link_to_if(can_read_cluster?, cluster.name, show_path)
+ end
end
end
diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb
index d963c188559..ef6bbc0d109 100644
--- a/app/presenters/group_clusterable_presenter.rb
+++ b/app/presenters/group_clusterable_presenter.rb
@@ -31,6 +31,6 @@ class GroupClusterablePresenter < ClusterablePresenter
override :learn_more_link
def learn_more_link
- link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
end
diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb
index 2497bea4aff..9e9b6973b8e 100644
--- a/app/presenters/member_presenter.rb
+++ b/app/presenters/member_presenter.rb
@@ -7,6 +7,14 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated
member.class.access_level_roles
end
+ def valid_level_roles
+ return access_level_roles unless member.highest_group_member
+
+ access_level_roles.reject do |_name, level|
+ member.highest_group_member.access_level > level
+ end
+ end
+
def can_resend_invite?
invite? &&
can?(current_user, admin_member_permission, source)
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index d61124fa787..9bd64ea217e 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -6,27 +6,27 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
include GitlabRoutingHelper
include StorageHelper
include TreeHelper
+ include IconsHelper
include ChecksCollaboration
include Gitlab::Utils::StrongMemoize
presents :project
- AnchorData = Struct.new(:enabled, :label, :link, :class_modifier)
+ AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon)
MAX_TAGS_TO_SHOW = 3
+ def statistic_icon(icon_name = 'plus-square-o')
+ sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4')
+ end
+
def statistics_anchors(show_auto_devops_callout:)
[
- readme_anchor_data,
- changelog_anchor_data,
- contribution_guide_anchor_data,
- files_anchor_data,
+ license_anchor_data,
commits_anchor_data,
branches_anchor_data,
tags_anchor_data,
- gitlab_ci_anchor_data,
- autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
- kubernetes_cluster_anchor_data
- ].compact.select { |item| item.enabled }
+ files_anchor_data
+ ].compact.select(&:is_link)
end
def statistics_buttons(show_auto_devops_callout:)
@@ -37,27 +37,28 @@ 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 { |item| item.enabled }
+ ].compact.reject(&:is_link)
end
def empty_repo_statistics_anchors
[
- files_anchor_data,
+ license_anchor_data,
commits_anchor_data,
branches_anchor_data,
tags_anchor_data,
- autodevops_anchor_data,
- kubernetes_cluster_anchor_data
- ].compact.select { |item| item.enabled }
+ files_anchor_data
+ ].compact.select { |item| item.is_link }
end
def empty_repo_statistics_buttons
[
new_file_anchor_data,
readme_anchor_data,
+ changelog_anchor_data,
+ contribution_guide_anchor_data,
autodevops_anchor_data,
kubernetes_cluster_anchor_data
- ].compact.reject { |item| item.enabled }
+ ].compact.reject { |item| item.is_link }
end
def default_view
@@ -113,7 +114,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def add_contribution_guide_path
- add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide')
+ add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add CONTRIBUTING')
end
def add_ci_yml_path
@@ -149,32 +150,52 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def files_anchor_data
AnchorData.new(true,
- _('Files (%{human_size})') % { human_size: storage_counter(statistics.total_repository_size) },
+ statistic_icon('doc-code') +
+ _('%{strong_start}%{human_size}%{strong_end} Files').html_safe % {
+ human_size: storage_counter(statistics.total_repository_size),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
empty_repo? ? nil : project_tree_path(project))
end
def commits_anchor_data
AnchorData.new(true,
- n_('Commit (%{commit_count})', 'Commits (%{commit_count})', statistics.commit_count) % { commit_count: number_with_delimiter(statistics.commit_count) },
+ statistic_icon('commit') +
+ n_('%{strong_start}%{commit_count}%{strong_end} Commit', '%{strong_start}%{commit_count}%{strong_end} Commits', statistics.commit_count).html_safe % {
+ commit_count: number_with_delimiter(statistics.commit_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
empty_repo? ? nil : project_commits_path(project, repository.root_ref))
end
def branches_anchor_data
AnchorData.new(true,
- n_('Branch (%{branch_count})', 'Branches (%{branch_count})', repository.branch_count) % { branch_count: number_with_delimiter(repository.branch_count) },
+ statistic_icon('branch') +
+ n_('%{strong_start}%{branch_count}%{strong_end} Branch', '%{strong_start}%{branch_count}%{strong_end} Branches', repository.branch_count).html_safe % {
+ branch_count: number_with_delimiter(repository.branch_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
empty_repo? ? nil : project_branches_path(project))
end
def tags_anchor_data
AnchorData.new(true,
- n_('Tag (%{tag_count})', 'Tags (%{tag_count})', repository.tag_count) % { tag_count: number_with_delimiter(repository.tag_count) },
+ statistic_icon('label') +
+ n_('%{strong_start}%{tag_count}%{strong_end} Tag', '%{strong_start}%{tag_count}%{strong_end} Tags', repository.tag_count).html_safe % {
+ tag_count: number_with_delimiter(repository.tag_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
empty_repo? ? nil : project_tags_path(project))
end
def new_file_anchor_data
if current_user && can_current_user_push_to_default_branch?
AnchorData.new(false,
- _('New file'),
+ statistic_icon + _('New file'),
project_new_blob_path(project, default_branch || 'master'),
'success')
end
@@ -183,40 +204,45 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def readme_anchor_data
if current_user && can_current_user_push_to_default_branch? && repository.readme.nil?
AnchorData.new(false,
- _('Add Readme'),
+ statistic_icon + _('Add README'),
add_readme_path)
elsif repository.readme
- AnchorData.new(true,
- _('Readme'),
- default_view != 'readme' ? readme_path : '#readme')
+ AnchorData.new(false,
+ statistic_icon('doc-text') + _('README'),
+ default_view != 'readme' ? readme_path : '#readme',
+ 'default',
+ 'doc-text')
end
end
def changelog_anchor_data
if current_user && can_current_user_push_to_default_branch? && repository.changelog.blank?
AnchorData.new(false,
- _('Add Changelog'),
+ statistic_icon + _('Add CHANGELOG'),
add_changelog_path)
elsif repository.changelog.present?
- AnchorData.new(true,
- _('Changelog'),
- changelog_path)
+ AnchorData.new(false,
+ statistic_icon('doc-text') + _('CHANGELOG'),
+ changelog_path,
+ 'default')
end
end
def license_anchor_data
+ icon = statistic_icon('scale')
+
if repository.license_blob.present?
AnchorData.new(true,
- license_short_name,
+ icon + content_tag(:strong, license_short_name, class: 'project-stat-value'),
license_path)
else
if current_user && can_current_user_push_to_default_branch?
- AnchorData.new(false,
- _('Add license'),
+ AnchorData.new(true,
+ content_tag(:span, icon + _('Add license'), class: 'add-license-link d-flex'),
add_license_path)
else
- AnchorData.new(false,
- _('No license. All rights reserved'),
+ AnchorData.new(true,
+ icon + content_tag(:strong, _('No license. All rights reserved'), class: 'project-stat-value'),
nil)
end
end
@@ -225,22 +251,29 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def contribution_guide_anchor_data
if current_user && can_current_user_push_to_default_branch? && repository.contribution_guide.blank?
AnchorData.new(false,
- _('Add Contribution guide'),
+ statistic_icon + _('Add CONTRIBUTING'),
add_contribution_guide_path)
elsif repository.contribution_guide.present?
- AnchorData.new(true,
- _('Contribution guide'),
+ AnchorData.new(false,
+ statistic_icon('doc-text') + _('CONTRIBUTING'),
contribution_guide_path)
end
end
def autodevops_anchor_data(show_auto_devops_callout: false)
if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout
- AnchorData.new(auto_devops_enabled?,
- auto_devops_enabled? ? _('Auto DevOps enabled') : _('Enable Auto DevOps'),
- project_settings_ci_cd_path(project, anchor: 'autodevops-settings'))
+ if auto_devops_enabled?
+ AnchorData.new(false,
+ statistic_icon('doc-text') + _('Auto DevOps enabled'),
+ project_settings_ci_cd_path(project, anchor: 'autodevops-settings'),
+ 'default')
+ else
+ AnchorData.new(false,
+ statistic_icon + _('Enable Auto DevOps'),
+ project_settings_ci_cd_path(project, anchor: 'autodevops-settings'))
+ end
elsif auto_devops_enabled?
- AnchorData.new(true,
+ AnchorData.new(false,
_('Auto DevOps enabled'),
nil)
end
@@ -248,27 +281,32 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def kubernetes_cluster_anchor_data
if current_user && can?(current_user, :create_cluster, project)
- cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project)
if clusters.empty?
- cluster_link = new_project_cluster_path(project)
- end
+ AnchorData.new(false,
+ statistic_icon + _('Add Kubernetes cluster'),
+ new_project_cluster_path(project))
+ else
+ cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project)
- AnchorData.new(!clusters.empty?,
- clusters.empty? ? _('Add Kubernetes cluster') : _('Kubernetes configured'),
- cluster_link)
+ AnchorData.new(false,
+ _('Kubernetes configured'),
+ cluster_link,
+ 'default')
+ end
end
end
def gitlab_ci_anchor_data
if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled?
AnchorData.new(false,
- _('Set up CI/CD'),
+ statistic_icon + _('Set up CI/CD'),
add_ci_yml_path)
elsif repository.gitlab_ci_yml.present?
- AnchorData.new(true,
- _('CI/CD configuration'),
- ci_configuration_path)
+ AnchorData.new(false,
+ statistic_icon('doc-text') + _('CI/CD configuration'),
+ ci_configuration_path,
+ 'default')
end
end
diff --git a/app/serializers/README.md b/app/serializers/README.md
index 0337f88db5f..bb94745b0b5 100644
--- a/app/serializers/README.md
+++ b/app/serializers/README.md
@@ -180,7 +180,7 @@ def index
render json: MyResourceSerializer
.new(current_user: @current_user)
.represent_details(@project.resources)
- nd
+ end
end
```
@@ -196,7 +196,7 @@ def index
.represent_details(@project.resources),
count: @project.resources.count
}
- nd
+ end
end
```
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 2bd17e58086..7b1a0be75ca 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -6,4 +6,5 @@ class ClusterApplicationEntity < Grape::Entity
expose :status_reason
expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
+ expose :email, if: -> (e, _) { e.respond_to?(:email) }
end
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
new file mode 100644
index 00000000000..06a8db78476
--- /dev/null
+++ b/app/serializers/diff_file_base_entity.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+class DiffFileBaseEntity < Grape::Entity
+ include RequestAwareEntity
+ include BlobHelper
+ include SubmoduleHelper
+ include DiffHelper
+ include TreeHelper
+ include ChecksCollaboration
+ include Gitlab::Utils::StrongMemoize
+
+ expose :content_sha
+ expose :submodule?, as: :submodule
+
+ expose :submodule_link do |diff_file|
+ memoized_submodule_links(diff_file).first
+ end
+
+ expose :submodule_tree_url do |diff_file|
+ memoized_submodule_links(diff_file).last
+ end
+
+ expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
+ merge_request = options[:merge_request]
+
+ options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {}
+
+ 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)
+ end
+
+ expose :old_path_html do |diff_file|
+ old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
+ old_path
+ end
+
+ expose :new_path_html do |diff_file|
+ _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
+ new_path
+ end
+
+ expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file|
+ options[:environment].formatted_external_url
+ end
+
+ expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file|
+ options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha)
+ end
+
+ expose :blob, using: BlobEntity
+
+ expose :can_modify_blob do |diff_file|
+ merge_request = options[:merge_request]
+
+ next unless diff_file.blob
+
+ if merge_request&.source_project && current_user
+ can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch)
+ else
+ false
+ end
+ end
+
+ expose :file_hash do |diff_file|
+ Digest::SHA1.hexdigest(diff_file.file_path)
+ end
+
+ expose :file_path
+ expose :old_path
+ expose :new_path
+ expose :new_file?, as: :new_file
+ expose :collapsed?, as: :collapsed
+ expose :text?, as: :text
+ expose :diff_refs
+ expose :stored_externally?, as: :stored_externally
+ expose :external_storage
+ expose :renamed_file?, as: :renamed_file
+ expose :deleted_file?, as: :deleted_file
+ expose :mode_changed?, as: :mode_changed
+ expose :a_mode
+ expose :b_mode
+
+ private
+
+ def memoized_submodule_links(diff_file)
+ strong_memoize(:submodule_links) do
+ if diff_file.submodule?
+ submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository)
+ else
+ []
+ end
+ end
+ end
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index 63ea8e8f95f..b0aaec3326d 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -1,63 +1,13 @@
# frozen_string_literal: true
-class DiffFileEntity < Grape::Entity
- include RequestAwareEntity
+class DiffFileEntity < DiffFileBaseEntity
include CommitsHelper
- include DiffHelper
- include SubmoduleHelper
- include BlobHelper
include IconsHelper
- include TreeHelper
- include ChecksCollaboration
- include Gitlab::Utils::StrongMemoize
- expose :submodule?, as: :submodule
-
- expose :submodule_link do |diff_file|
- memoized_submodule_links(diff_file).first
- end
-
- expose :submodule_tree_url do |diff_file|
- memoized_submodule_links(diff_file).last
- end
-
- expose :blob, using: BlobEntity
-
- expose :can_modify_blob do |diff_file|
- merge_request = options[:merge_request]
-
- next unless diff_file.blob
-
- if merge_request&.source_project && current_user
- can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch)
- else
- false
- end
- end
-
- expose :file_hash do |diff_file|
- Digest::SHA1.hexdigest(diff_file.file_path)
- end
-
- expose :file_path
expose :too_large?, as: :too_large
- expose :collapsed?, as: :collapsed
- expose :new_file?, as: :new_file
-
- expose :deleted_file?, as: :deleted_file
- expose :renamed_file?, as: :renamed_file
- expose :old_path
- expose :new_path
- expose :mode_changed?, as: :mode_changed
- expose :a_mode
- expose :b_mode
- expose :text?, as: :text
+ expose :empty?, as: :empty
expose :added_lines
expose :removed_lines
- expose :diff_refs
- expose :content_sha
- expose :stored_externally?, as: :stored_externally
- expose :external_storage
expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file|
merge_request = options[:merge_request]
@@ -75,36 +25,6 @@ class DiffFileEntity < Grape::Entity
)
end
- expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file|
- options[:environment].formatted_external_url
- end
-
- expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file|
- options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha)
- end
-
- expose :old_path_html do |diff_file|
- old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
- old_path
- end
-
- expose :new_path_html do |diff_file|
- _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
- new_path
- end
-
- expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
- merge_request = options[:merge_request]
-
- options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {}
-
- 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)
- end
-
expose :view_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
merge_request = options[:merge_request]
@@ -145,18 +65,4 @@ class DiffFileEntity < Grape::Entity
# Used for parallel diffs
expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? }
-
- def current_user
- request.current_user
- end
-
- def memoized_submodule_links(diff_file)
- strong_memoize(:submodule_links) do
- if diff_file.submodule?
- submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository)
- else
- []
- end
- end
- end
end
diff --git a/app/serializers/diff_line_entity.rb b/app/serializers/diff_line_entity.rb
index 942714b7787..bfef6d3bde8 100644
--- a/app/serializers/diff_line_entity.rb
+++ b/app/serializers/diff_line_entity.rb
@@ -11,4 +11,6 @@ class DiffLineEntity < Grape::Entity
expose :rich_text do |line|
ERB::Util.html_escape(line.rich_text || line.text)
end
+
+ expose :suggestible?, as: :can_receive_suggestion
end
diff --git a/app/serializers/discussion_diff_file_entity.rb b/app/serializers/discussion_diff_file_entity.rb
new file mode 100644
index 00000000000..419e7edf94f
--- /dev/null
+++ b/app/serializers/discussion_diff_file_entity.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class DiscussionDiffFileEntity < DiffFileBaseEntity
+end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index b6786a0d597..b2d9d52bd22 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -36,7 +36,7 @@ class DiscussionEntity < Grape::Entity
new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
end
- expose :diff_file, using: DiffFileEntity, if: -> (d, _) { d.diff_discussion? }
+ expose :diff_file, using: DiscussionDiffFileEntity, if: -> (d, _) { d.diff_discussion? }
expose :diff_discussion?, as: :diff_discussion
@@ -46,19 +46,6 @@ class DiscussionEntity < Grape::Entity
expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) }
- expose :image_diff_html, if: -> (d, _) { d.diff_discussion? && d.on_image? } do |discussion|
- diff_file = discussion.diff_file
- partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff'
- options[:context].render_to_string(
- partial: "projects/diffs/#{partial}",
- locals: { diff_file: diff_file,
- position: discussion.position.to_json,
- click_to_comment: false },
- layout: false,
- formats: [:html]
- )
- end
-
expose :for_commit?, as: :for_commit
expose :commit_id
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 07a13c33b89..4a7d13915dd 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -23,6 +23,10 @@ class EnvironmentEntity < Grape::Entity
stop_project_environment_path(environment.project, environment)
end
+ expose :cluster_type, if: ->(environment, _) { cluster_platform_kubernetes? } do |environment|
+ cluster.cluster_type
+ end
+
expose :terminal_path, if: ->(*) { environment.has_terminals? && can_access_terminal? } do |environment|
terminal_project_environment_path(environment.project, environment)
end
@@ -48,4 +52,16 @@ class EnvironmentEntity < Grape::Entity
def can_access_terminal?
can?(request.current_user, :create_environment_terminal, environment)
end
+
+ def cluster_platform_kubernetes?
+ deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes)
+ end
+
+ def deployment_platform
+ environment.deployment_platform
+ end
+
+ def cluster
+ deployment_platform.cluster
+ end
end
diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb
new file mode 100644
index 00000000000..e3dc43240c6
--- /dev/null
+++ b/app/serializers/issue_board_entity.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class IssueBoardEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :iid
+ expose :title
+
+ expose :confidential
+ expose :due_date
+ expose :project_id
+ expose :relative_position
+
+ expose :project do |issue|
+ API::Entities::Project.represent issue.project, only: [:id, :path]
+ end
+
+ expose :milestone, expose_nil: false do |issue|
+ API::Entities::Milestone.represent issue.milestone, only: [:id, :title]
+ end
+
+ expose :assignees do |issue|
+ API::Entities::UserBasic.represent issue.assignees, only: [:id, :name, :username, :avatar_url]
+ end
+
+ expose :labels do |issue|
+ LabelEntity.represent issue.labels, project: issue.project, only: [:id, :title, :description, :color, :priority, :text_color]
+ end
+
+ expose :reference_path, if: -> (issue) { issue.project } do |issue, options|
+ options[:include_full_project_path] ? issue.to_reference(full: true) : issue.to_reference
+ end
+
+ expose :real_path, if: -> (issue) { issue.project } do |issue|
+ project_issue_path(issue.project, issue)
+ end
+
+ expose :issue_sidebar_endpoint, if: -> (issue) { issue.project } do |issue|
+ project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar')
+ end
+
+ expose :toggle_subscription_endpoint, if: -> (issue) { issue.project } do |issue|
+ toggle_subscription_project_issue_path(issue.project, issue)
+ end
+
+ expose :assignable_labels_endpoint, if: -> (issue) { issue.project } do |issue|
+ project_labels_path(issue.project, format: :json, include_ancestor_groups: true)
+ end
+end
diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb
index 37cf5e28396..d66f0a5acb7 100644
--- a/app/serializers/issue_serializer.rb
+++ b/app/serializers/issue_serializer.rb
@@ -4,15 +4,17 @@ class IssueSerializer < BaseSerializer
# This overrided method takes care of which entity should be used
# to serialize the `issue` based on `basic` key in `opts` param.
# Hence, `entity` doesn't need to be declared on the class scope.
- def represent(merge_request, opts = {})
+ def represent(issue, opts = {})
entity =
case opts[:serializer]
when 'sidebar'
IssueSidebarEntity
+ when 'board'
+ IssueBoardEntity
else
IssueEntity
end
- super(merge_request, opts, entity)
+ super(issue, opts, entity)
end
end
diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb
index 98743d62b50..5082245dda9 100644
--- a/app/serializers/label_entity.rb
+++ b/app/serializers/label_entity.rb
@@ -12,4 +12,8 @@ class LabelEntity < Grape::Entity
expose :text_color
expose :created_at
expose :updated_at
+
+ expose :priority, if: -> (*) { options.key?(:project) } do |label|
+ label.priority(options[:project])
+ end
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index f33a1654d5e..9731b52f1ad 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -238,6 +238,8 @@ class MergeRequestWidgetEntity < IssuableEntity
end
end
+ expose :supports_suggestion?, as: :can_receive_suggestion
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index c6d27817411..1d3b59eb1b7 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -36,6 +36,7 @@ class NoteEntity < API::Entities::Note
end
end
+ expose :suggestions, using: SuggestionEntity
expose :resolved?, as: :resolved
expose :resolvable?, as: :resolvable
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index aef838409e0..c9669e59199 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -23,6 +23,7 @@ class PipelineEntity < Grape::Entity
expose :latest?, as: :latest
expose :stuck?, as: :stuck
expose :auto_devops_source?, as: :auto_devops
+ expose :merge_request?, as: :merge_request
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
@@ -48,6 +49,7 @@ class PipelineEntity < Grape::Entity
expose :tag?, as: :tag
expose :branch?, as: :branch
+ expose :merge_request?, as: :merge_request
end
expose :commit, using: CommitEntity
diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb
new file mode 100644
index 00000000000..4f1f62d145b
--- /dev/null
+++ b/app/serializers/projects/serverless/service_entity.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Projects
+ module Serverless
+ class ServiceEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :name do |service|
+ service.dig('metadata', 'name')
+ end
+
+ expose :namespace do |service|
+ service.dig('metadata', 'namespace')
+ end
+
+ expose :created_at do |service|
+ service.dig('metadata', 'creationTimestamp')
+ end
+
+ expose :url do |service|
+ "http://#{service.dig('status', 'domain')}"
+ end
+
+ expose :description do |service|
+ service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description')
+ end
+
+ expose :image do |service|
+ service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name')
+ end
+ end
+ end
+end
diff --git a/app/serializers/projects/serverless/service_serializer.rb b/app/serializers/projects/serverless/service_serializer.rb
new file mode 100644
index 00000000000..adfd48a8c7d
--- /dev/null
+++ b/app/serializers/projects/serverless/service_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Projects
+ module Serverless
+ class ServiceSerializer < BaseSerializer
+ entity Projects::Serverless::ServiceEntity
+ end
+ end
+end
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
new file mode 100644
index 00000000000..4d0d4da10be
--- /dev/null
+++ b/app/serializers/suggestion_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class SuggestionEntity < API::Entities::Suggestion
+ include RequestAwareEntity
+
+ expose :current_user do
+ expose :can_apply do |suggestion|
+ Ability.allowed?(current_user, :apply_suggestion, suggestion)
+ end
+ end
+
+ private
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/serializers/trigger_variable_entity.rb b/app/serializers/trigger_variable_entity.rb
index 56203113631..4b28db42e76 100644
--- a/app/serializers/trigger_variable_entity.rb
+++ b/app/serializers/trigger_variable_entity.rb
@@ -3,5 +3,6 @@
class TriggerVariableEntity < Grape::Entity
include RequestAwareEntity
- expose :key, :value, :public
+ expose :key, :public
+ expose :value, if: ->(_, _) { can?(request.current_user, :admin_build, request.project) }
end
diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb
index 2a337918d21..40aa9250885 100644
--- a/app/services/access_token_validation_service.rb
+++ b/app/services/access_token_validation_service.rb
@@ -6,6 +6,7 @@ class AccessTokenValidationService
EXPIRED = :expired
REVOKED = :revoked
INSUFFICIENT_SCOPE = :insufficient_scope
+ IMPERSONATION_DISABLED = :impersonation_disabled
attr_reader :token, :request
@@ -24,6 +25,11 @@ class AccessTokenValidationService
elsif !self.include_any_scope?(scopes)
return INSUFFICIENT_SCOPE
+ elsif token.respond_to?(:impersonation) &&
+ token.impersonation &&
+ !Gitlab.config.gitlab.impersonation_enabled
+ return IMPERSONATION_DISABLED
+
else
return VALID
end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
new file mode 100644
index 00000000000..a1dd00721b5
--- /dev/null
+++ b/app/services/ci/archive_trace_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Ci
+ class ArchiveTraceService
+ def execute(job)
+ job.trace.archive!
+ rescue ::Gitlab::Ci::Trace::AlreadyArchivedError
+ # It's already archived, thus we can safely ignore this exception.
+ rescue => e
+ # Tracks this error with application logs, Sentry, and Prometheus.
+ # If `archive!` keeps failing for over a week, that could incur data loss.
+ # (See more https://docs.gitlab.com/ee/administration/job_traces.html#new-live-trace-architecture)
+ # In order to avoid interrupting the system, we do not raise an exception here.
+ archive_error(e, job)
+ end
+
+ private
+
+ def failed_archive_counter
+ @failed_archive_counter ||=
+ Gitlab::Metrics.counter(:job_trace_archive_failed_total,
+ "Counter of failed attempts of trace archiving")
+ end
+
+ def archive_error(error, job)
+ failed_archive_counter.increment
+ Rails.logger.error "Failed to archive trace. id: #{job.id} message: #{error.message}"
+
+ Gitlab::Sentry
+ .track_exception(error,
+ issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/51502',
+ extra: { job_id: job.id })
+ end
+ end
+end
diff --git a/app/services/ci/compare_reports_base_service.rb b/app/services/ci/compare_reports_base_service.rb
new file mode 100644
index 00000000000..d5625857599
--- /dev/null
+++ b/app/services/ci/compare_reports_base_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Ci
+ class CompareReportsBaseService < ::BaseService
+ def execute(base_pipeline, head_pipeline)
+ comparer = comparer_class.new(get_report(base_pipeline), get_report(head_pipeline))
+ {
+ status: :parsed,
+ key: key(base_pipeline, head_pipeline),
+ data: serializer_class
+ .new(project: project)
+ .represent(comparer).as_json
+ }
+ rescue Gitlab::Ci::Parsers::ParserError => e
+ {
+ status: :error,
+ key: key(base_pipeline, head_pipeline),
+ status_reason: e.message
+ }
+ end
+
+ def latest?(base_pipeline, head_pipeline, data)
+ data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
+ end
+
+ private
+
+ def key(base_pipeline, head_pipeline)
+ [
+ base_pipeline&.id, base_pipeline&.updated_at,
+ head_pipeline&.id, head_pipeline&.updated_at
+ ]
+ end
+
+ def comparer_class
+ raise NotImplementedError
+ end
+
+ def serializer_class
+ raise NotImplementedError
+ end
+
+ def get_report(pipeline)
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/services/ci/compare_test_reports_service.rb b/app/services/ci/compare_test_reports_service.rb
index 2293f95f56b..382d5b8995f 100644
--- a/app/services/ci/compare_test_reports_service.rb
+++ b/app/services/ci/compare_test_reports_service.rb
@@ -1,39 +1,17 @@
# frozen_string_literal: true
module Ci
- class CompareTestReportsService < ::BaseService
- def execute(base_pipeline, head_pipeline)
- # rubocop: disable CodeReuse/Serializer
- comparer = Gitlab::Ci::Reports::TestReportsComparer
- .new(base_pipeline&.test_reports, head_pipeline.test_reports)
-
- {
- status: :parsed,
- key: key(base_pipeline, head_pipeline),
- data: TestReportsComparerSerializer
- .new(project: project)
- .represent(comparer).as_json
- }
- rescue => e
- {
- status: :error,
- key: key(base_pipeline, head_pipeline),
- status_reason: e.message
- }
- # rubocop: enable CodeReuse/Serializer
+ class CompareTestReportsService < CompareReportsBaseService
+ def comparer_class
+ Gitlab::Ci::Reports::TestReportsComparer
end
- def latest?(base_pipeline, head_pipeline, data)
- data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
+ def serializer_class
+ TestReportsComparerSerializer
end
- private
-
- def key(base_pipeline, head_pipeline)
- [
- base_pipeline&.id, base_pipeline&.updated_at,
- head_pipeline&.id, head_pipeline&.updated_at
- ]
+ def get_report(pipeline)
+ pipeline&.test_reports
end
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 92a8438ab2f..19b5552887f 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -4,6 +4,8 @@ module Ci
class CreatePipelineService < BaseService
attr_reader :pipeline
+ CreateError = Class.new(StandardError)
+
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
Gitlab::Ci::Pipeline::Chain::Validate::Repository,
@@ -12,7 +14,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Populate,
Gitlab::Ci::Pipeline::Chain::Create].freeze
- def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block)
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block)
@pipeline = Ci::Pipeline.new
command = Gitlab::Ci::Pipeline::Chain::Command.new(
@@ -23,6 +25,7 @@ module Ci
before_sha: params[:before],
trigger_request: trigger_request,
schedule: schedule,
+ merge_request: merge_request,
ignore_skip_ci: ignore_skip_ci,
save_incompleted: save_on_errors,
seeds_block: block,
@@ -47,6 +50,14 @@ module Ci
pipeline
end
+ def execute!(*args, &block)
+ execute(*args, &block).tap do |pipeline|
+ unless pipeline.persisted?
+ raise CreateError, pipeline.errors.full_messages.join(',')
+ end
+ end
+ end
+
private
def commit
@@ -67,7 +78,7 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def auto_cancelable_pipelines
- project.pipelines
+ project.ci_pipelines
.where(ref: pipeline.ref)
.where.not(id: pipeline.id)
.where.not(sha: project.commit(pipeline.ref).try(:id))
diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb
index a89772e82dc..92c2c1b9834 100644
--- a/app/services/clusters/applications/create_service.rb
+++ b/app/services/clusters/applications/create_service.rb
@@ -20,7 +20,7 @@ module Clusters
end
if application.has_attribute?(:email)
- application.email = current_user.email
+ application.email = params[:email]
end
if application.respond_to?(:oauth_application)
diff --git a/app/services/clusters/build_service.rb b/app/services/clusters/build_service.rb
new file mode 100644
index 00000000000..8de73831164
--- /dev/null
+++ b/app/services/clusters/build_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+module Clusters
+ class BuildService
+ def initialize(subject)
+ @subject = subject
+ end
+
+ def execute
+ ::Clusters::Cluster.new.tap do |cluster|
+ case @subject
+ when ::Project
+ cluster.cluster_type = :project_type
+ when ::Group
+ cluster.cluster_type = :group_type
+ else
+ raise NotImplementedError
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/gcp/fetch_operation_service.rb b/app/services/clusters/gcp/fetch_operation_service.rb
index 02c96a1e286..6c648b443a0 100644
--- a/app/services/clusters/gcp/fetch_operation_service.rb
+++ b/app/services/clusters/gcp/fetch_operation_service.rb
@@ -11,8 +11,21 @@ module Clusters
yield(operation) if block_given?
rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
+ logger.error(
+ exception: e.class.name,
+ service: self.class.name,
+ provider_id: provider.id,
+ message: e.message
+ )
+
provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
end
+
+ private
+
+ def logger
+ @logger ||= Gitlab::Kubernetes::Logger.build
+ end
end
end
end
diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb
index 3df43657fa0..a4e44d009c0 100644
--- a/app/services/clusters/gcp/finalize_creation_service.rb
+++ b/app/services/clusters/gcp/finalize_creation_service.rb
@@ -12,20 +12,24 @@ module Clusters
create_gitlab_service_account!
configure_kubernetes
cluster.save!
- configure_project_service_account
+
+ ClusterPlatformConfigureWorker.perform_async(cluster.id)
rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
- provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ log_service_error(e.class.name, provider.id, e.message)
+ provider.make_errored!(s_('ClusterIntegration|Failed to request to Google Cloud Platform: %{message}') % { message: e.message })
rescue Kubeclient::HttpError => e
- provider.make_errored!("Failed to run Kubeclient: #{e.message}")
+ log_service_error(e.class.name, provider.id, e.message)
+ provider.make_errored!(s_('ClusterIntegration|Failed to run Kubeclient: %{message}') % { message: e.message })
rescue ActiveRecord::RecordInvalid => e
- provider.make_errored!("Failed to configure Google Kubernetes Engine Cluster: #{e.message}")
+ log_service_error(e.class.name, provider.id, e.message)
+ provider.make_errored!(s_('ClusterIntegration|Failed to configure Google Kubernetes Engine Cluster: %{message}') % { message: e.message })
end
private
def create_gitlab_service_account!
- Clusters::Gcp::Kubernetes::CreateServiceAccountService.gitlab_creator(
+ Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService.gitlab_creator(
kube_client,
rbac: create_rbac_cluster?
).execute
@@ -55,15 +59,6 @@ module Clusters
).execute
end
- def configure_project_service_account
- kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace(cluster.cluster_project)
-
- Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new(
- cluster: cluster,
- kubernetes_namespace: kubernetes_namespace
- ).execute
- end
-
def authorization_type
create_rbac_cluster? ? 'rbac' : 'abac'
end
@@ -113,6 +108,19 @@ module Clusters
def cluster
@cluster ||= provider.cluster
end
+
+ def logger
+ @logger ||= Gitlab::Kubernetes::Logger.build
+ end
+
+ def log_service_error(exception, provider_id, message)
+ logger.error(
+ exception: exception.class.name,
+ service: self.class.name,
+ provider_id: provider_id,
+ message: message
+ )
+ end
end
end
end
diff --git a/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb b/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb
index b31426556f6..806f320381d 100644
--- a/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb
+++ b/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb
@@ -27,7 +27,7 @@ module Clusters
end
def create_project_service_account
- Clusters::Gcp::Kubernetes::CreateServiceAccountService.namespace_creator(
+ Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService.namespace_creator(
platform.kubeclient,
service_account_name: kubernetes_namespace.service_account_name,
service_account_namespace: kubernetes_namespace.namespace,
diff --git a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb b/app/services/clusters/gcp/kubernetes/create_or_update_service_account_service.rb
index dfc4bf7a358..49e766cbf13 100644
--- a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb
+++ b/app/services/clusters/gcp/kubernetes/create_or_update_service_account_service.rb
@@ -3,7 +3,7 @@
module Clusters
module Gcp
module Kubernetes
- class CreateServiceAccountService
+ class CreateOrUpdateServiceAccountService
def initialize(kubeclient, service_account_name:, service_account_namespace:, token_name:, rbac:, namespace_creator: false, role_binding_name: nil)
@kubeclient = kubeclient
@service_account_name = service_account_name
@@ -38,8 +38,9 @@ module Clusters
def execute
ensure_project_namespace_exists if namespace_creator
- kubeclient.create_service_account(service_account_resource)
- kubeclient.create_secret(service_account_token_resource)
+
+ kubeclient.create_or_update_service_account(service_account_resource)
+ kubeclient.create_or_update_secret(service_account_token_resource)
create_role_or_cluster_role_binding if rbac
end
@@ -56,9 +57,9 @@ module Clusters
def create_role_or_cluster_role_binding
if namespace_creator
- kubeclient.create_role_binding(role_binding_resource)
+ kubeclient.create_or_update_role_binding(role_binding_resource)
else
- kubeclient.create_cluster_role_binding(cluster_role_binding_resource)
+ kubeclient.create_or_update_cluster_role_binding(cluster_role_binding_resource)
end
end
diff --git a/app/services/clusters/refresh_service.rb b/app/services/clusters/refresh_service.rb
new file mode 100644
index 00000000000..7c82b98a33f
--- /dev/null
+++ b/app/services/clusters/refresh_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Clusters
+ class RefreshService
+ def self.create_or_update_namespaces_for_cluster(cluster)
+ projects_with_missing_kubernetes_namespaces_for_cluster(cluster).each do |project|
+ create_or_update_namespace(cluster, project)
+ end
+ end
+
+ def self.create_or_update_namespaces_for_project(project)
+ clusters_with_missing_kubernetes_namespaces_for_project(project).each do |cluster|
+ create_or_update_namespace(cluster, project)
+ end
+ end
+
+ def self.projects_with_missing_kubernetes_namespaces_for_cluster(cluster)
+ cluster.all_projects.missing_kubernetes_namespace(cluster.kubernetes_namespaces)
+ end
+
+ 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)
+ end
+
+ private_class_method :clusters_with_missing_kubernetes_namespaces_for_project
+
+ def self.create_or_update_namespace(cluster, project)
+ kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace_for_project(project)
+
+ ::Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new(
+ cluster: cluster,
+ kubernetes_namespace: kubernetes_namespace
+ ).execute
+ end
+
+ private_class_method :create_or_update_namespace
+ end
+end
diff --git a/app/services/create_release_service.rb b/app/services/create_release_service.rb
index 8d1fdbe11c3..ab2dc5337aa 100644
--- a/app/services/create_release_service.rb
+++ b/app/services/create_release_service.rb
@@ -13,8 +13,13 @@ class CreateReleaseService < BaseService
if release
error('Release already exists', 409)
else
- release = project.releases.new({ tag: tag_name, description: release_description })
- release.save
+ release = project.releases.create!(
+ tag: tag_name,
+ name: tag_name,
+ sha: existing_tag.dereferenced_target.sha,
+ author: current_user,
+ description: release_description
+ )
success(release)
end
diff --git a/app/services/deploy_keys/create_service.rb b/app/services/deploy_keys/create_service.rb
index a25e73666f8..0c935285657 100644
--- a/app/services/deploy_keys/create_service.rb
+++ b/app/services/deploy_keys/create_service.rb
@@ -2,7 +2,7 @@
module DeployKeys
class CreateService < Keys::BaseService
- def execute
+ def execute(project: nil)
DeployKey.create(params.merge(user: user))
end
end
diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb
index c9d3ee31d82..927634c2159 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -8,6 +8,7 @@ module Files
transformer = Lfs::FileTransformer.new(project, @branch_name)
actions = actions_after_lfs_transformation(transformer, params[:actions])
+ actions = transform_move_actions(actions)
commit_actions!(actions)
end
@@ -26,6 +27,16 @@ module Files
end
end
+ # When moving a file, `content: nil` means "use the contents of the previous
+ # file", while `content: ''` means "move the file and set it to empty"
+ def transform_move_actions(actions)
+ actions.map do |action|
+ action[:infer_content] = true if action[:content].nil?
+
+ action
+ end
+ end
+
def commit_actions!(actions)
repository.multi_action(
current_user,
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 0bf0e967dcc..31d3c844ad5 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -36,7 +36,7 @@ module Groups
end
def reject_parent_id!
- params.except!(:parent_id)
+ params.delete(:parent_id)
end
def valid_share_with_group_lock_change?
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index 765de9c66b0..885e14bba8f 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -4,20 +4,23 @@ module Issuable
class CommonSystemNotesService < ::BaseService
attr_reader :issuable
- def execute(issuable, old_labels)
+ def execute(issuable, old_labels: [], is_update: true)
@issuable = issuable
- if issuable.previous_changes.include?('title')
- create_title_change_note(issuable.previous_changes['title'].first)
- end
+ if is_update
+ if issuable.previous_changes.include?('title')
+ create_title_change_note(issuable.previous_changes['title'].first)
+ end
- handle_description_change_note
+ handle_description_change_note
+
+ handle_time_tracking_note if issuable.is_a?(TimeTrackable)
+ create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
+ end
- handle_time_tracking_note if issuable.is_a?(TimeTrackable)
- create_labels_note(old_labels) if issuable.labels != old_labels
- create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
- create_milestone_note if issuable.previous_changes.include?('milestone_id')
create_due_date_note if issuable.previous_changes.include?('due_date')
+ create_milestone_note if issuable.previous_changes.include?('milestone_id')
+ create_labels_note(old_labels) if issuable.labels != old_labels
end
private
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index e32e262ac31..c7e7bb55e4b 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -152,6 +152,10 @@ class IssuableBaseService < BaseService
before_create(issuable)
if issuable.save
+ ActiveRecord::Base.no_touching do
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false)
+ end
+
after_create(issuable)
execute_hooks(issuable)
invalidate_cache_counts(issuable, users: issuable.assignees)
@@ -207,7 +211,7 @@ class IssuableBaseService < BaseService
if issuable.with_transaction_returning_status { issuable.save }
# We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do
- Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_associations[:labels])
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
end
handle_changes(issuable, old_associations: old_associations)
diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb
index f30ad706c63..3c0e6196d4f 100644
--- a/app/services/labels/promote_service.rb
+++ b/app/services/labels/promote_service.rb
@@ -57,7 +57,7 @@ module Labels
def update_issuables(new_label, label_ids)
LabelLink
.where(label: label_ids)
- .update_all(label_id: new_label)
+ .update_all(label_id: new_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -65,7 +65,7 @@ module Labels
def update_resource_label_events(new_label, label_ids)
ResourceLabelEvent
.where(label: label_ids)
- .update_all(label_id: new_label)
+ .update_all(label_id: new_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -73,7 +73,7 @@ module Labels
def update_issue_board_lists(new_label, label_ids)
List
.where(label: label_ids)
- .update_all(label_id: new_label)
+ .update_all(label_id: new_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -81,7 +81,7 @@ module Labels
def update_priorities(new_label, label_ids)
LabelPriority
.where(label: label_ids)
- .update_all(label_id: new_label)
+ .update_all(label_id: new_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 28c3219b37b..fe19abf50f6 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -54,6 +54,24 @@ module MergeRequests
merge_request, merge_request.project, current_user, merge_request.assignee)
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?
+
+ 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)
+ end
+
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
# rubocop: disable CodeReuse/ActiveRecord
def merge_requests_for(source_branch, mr_states: [:opened])
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 6081a7d1de0..7bb9fa60515 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -25,6 +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)
update_merge_requests_head_pipeline(issuable)
super
@@ -49,18 +50,14 @@ module MergeRequests
merge_request.update(head_pipeline_id: pipeline.id) if pipeline
end
- # rubocop: disable CodeReuse/ActiveRecord
def head_pipeline_for(merge_request)
return unless merge_request.source_project
sha = merge_request.source_branch_sha
return unless sha
- pipelines = merge_request.source_project.pipelines.where(ref: merge_request.source_branch, sha: sha)
-
- pipelines.order(id: :desc).first
+ merge_request.all_pipelines(shas: sha).first
end
- # rubocop: enable CodeReuse/ActiveRecord
def set_projects!
# @project is used to determine whether the user can set the merge request's
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 5fe48da1cd6..f712b8863cd 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -58,13 +58,27 @@ module MergeRequests
.preload(:latest_merge_request_diff)
.where(target_branch: @push.branch_name).to_a
.select(&:diff_head_commit)
+ .select do |merge_request|
+ commit_ids.include?(merge_request.diff_head_sha) &&
+ merge_request.merge_request_diff.state != 'empty'
+ end
+ merge_requests = filter_merge_requests(merge_requests)
+
+ return if merge_requests.empty?
- merge_requests = merge_requests.select do |merge_request|
- commit_ids.include?(merge_request.diff_head_sha) &&
- merge_request.merge_request_diff.state != 'empty'
+ commit_analyze_enabled = Feature.enabled?(:branch_push_merge_commit_analyze, @project, default_enabled: true)
+ if commit_analyze_enabled
+ analyzer = Gitlab::BranchPushMergeCommitAnalyzer.new(
+ @commits.reverse,
+ relevant_commit_ids: merge_requests.map(&:diff_head_sha)
+ )
end
- filter_merge_requests(merge_requests).each do |merge_request|
+ merge_requests.each do |merge_request|
+ if commit_analyze_enabled
+ merge_request.merge_commit_sha = analyzer.get_merge_commit(merge_request.diff_head_sha)
+ end
+
MergeRequests::PostMergeService
.new(merge_request.target_project, @current_user)
.execute(merge_request)
@@ -92,6 +106,7 @@ module MergeRequests
end
merge_request.mark_as_unchecked
+ create_merge_request_pipeline(merge_request, current_user)
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index aacaf10d09c..33d8299c8b6 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -5,14 +5,15 @@ module MergeRequests
def execute(merge_request)
# We don't allow change of source/target projects and source branch
# after merge request was created
- params.except!(:source_project_id)
- params.except!(:target_project_id)
- params.except!(:source_branch)
+ params.delete(:source_project_id)
+ params.delete(:target_project_id)
+ params.delete(:source_branch)
merge_from_quick_action(merge_request) if params[:merge]
if merge_request.closed_without_fork?
- params.except!(:target_branch, :force_remove_source_branch)
+ params.delete(:target_branch)
+ params.delete(:force_remove_source_branch)
end
if params[:force_remove_source_branch].present?
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index e03789e3ca9..c4546f30235 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -36,6 +36,7 @@ module Notes
if !only_commands && note.save
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
+ Suggestions::CreateService.new(note).execute
end
if command_params.present?
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 35db409eb27..d2052bed646 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -14,6 +14,17 @@ module Notes
TodoService.new.update_note(note, current_user, old_mentioned_users)
end
+ if note.supports_suggestion?
+ Suggestion.transaction do
+ note.suggestions.delete_all
+ Suggestions::CreateService.new(note).execute
+ end
+
+ # We need to refresh the previous suggestions call cache
+ # in order to get the new records.
+ note.reload
+ end
+
note
end
end
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index 9c236d7f41d..68cdc69023a 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -24,6 +24,10 @@ module NotificationRecipientService
Builder::MergeRequestUnmergeable.new(*args).notification_recipients
end
+ def self.build_project_maintainers_recipients(*args)
+ Builder::ProjectMaintainers.new(*args).notification_recipients
+ end
+
module Builder
class Base
def initialize(*)
@@ -380,5 +384,24 @@ module NotificationRecipientService
nil
end
end
+
+ class ProjectMaintainers < Base
+ attr_reader :target
+
+ def initialize(target, action:)
+ @target = target
+ @action = action
+ end
+
+ def build!
+ return [] unless project
+
+ add_recipients(project.team.maintainers, :watch, nil)
+ end
+
+ def acting_user
+ nil
+ end
+ end
end
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 5904bfbf88d..ff035fea216 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -429,26 +429,26 @@ class NotificationService
end
def pages_domain_verification_succeeded(domain)
- recipients_for_pages_domain(domain).each do |user|
- mailer.pages_domain_verification_succeeded_email(domain, user).deliver_later
+ project_maintainers_recipients(domain, action: 'succeeded').each do |recipient|
+ mailer.pages_domain_verification_succeeded_email(domain, recipient.user).deliver_later
end
end
def pages_domain_verification_failed(domain)
- recipients_for_pages_domain(domain).each do |user|
- mailer.pages_domain_verification_failed_email(domain, user).deliver_later
+ project_maintainers_recipients(domain, action: 'failed').each do |recipient|
+ mailer.pages_domain_verification_failed_email(domain, recipient.user).deliver_later
end
end
def pages_domain_enabled(domain)
- recipients_for_pages_domain(domain).each do |user|
- mailer.pages_domain_enabled_email(domain, user).deliver_later
+ project_maintainers_recipients(domain, action: 'enabled').each do |recipient|
+ mailer.pages_domain_enabled_email(domain, recipient.user).deliver_later
end
end
def pages_domain_disabled(domain)
- recipients_for_pages_domain(domain).each do |user|
- mailer.pages_domain_disabled_email(domain, user).deliver_later
+ project_maintainers_recipients(domain, action: 'disabled').each do |recipient|
+ mailer.pages_domain_disabled_email(domain, recipient.user).deliver_later
end
end
@@ -466,6 +466,22 @@ class NotificationService
end
end
+ def repository_cleanup_success(project, user)
+ mailer.send(:repository_cleanup_success_email, project, user).deliver_later
+ end
+
+ def repository_cleanup_failure(project, user, error)
+ mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later
+ end
+
+ def remote_mirror_update_failed(remote_mirror)
+ recipients = project_maintainers_recipients(remote_mirror, action: 'update_failed')
+
+ recipients.each do |recipient|
+ mailer.remote_mirror_update_failed_email(remote_mirror.id, recipient.user.id).deliver_later
+ end
+ end
+
protected
def new_resource_email(target, method)
@@ -561,12 +577,8 @@ class NotificationService
private
- def recipients_for_pages_domain(domain)
- project = domain.project
-
- return [] unless project
-
- notifiable_users(project.team.maintainers, :watch, target: project)
+ def project_maintainers_recipients(target, action:)
+ NotificationRecipientService.build_project_maintainers_recipients(target, action: action)
end
def notifiable?(*args)
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index de8757006f1..a449a5dc3e9 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -4,10 +4,12 @@ class PreviewMarkdownService < BaseService
def execute
text, commands = explain_quick_actions(params[:text])
users = find_user_references(text)
+ suggestions = find_suggestions(text)
success(
text: text,
users: users,
+ suggestions: suggestions,
commands: commands.join(' '),
markdown_engine: markdown_engine
)
@@ -28,6 +30,12 @@ class PreviewMarkdownService < BaseService
extractor.users.map(&:username)
end
+ def find_suggestions(text)
+ return [] unless params[:preview_suggestions]
+
+ Banzai::SuggestionsParser.parse(text)
+ end
+
def find_commands_target
QuickActions::TargetService
.new(project, current_user)
diff --git a/app/services/projects/auto_devops/disable_service.rb b/app/services/projects/auto_devops/disable_service.rb
index 1b578a3c5ce..6608b3da1a8 100644
--- a/app/services/projects/auto_devops/disable_service.rb
+++ b/app/services/projects/auto_devops/disable_service.rb
@@ -34,7 +34,7 @@ module Projects
end
def auto_devops_pipelines
- @auto_devops_pipelines ||= project.pipelines.auto_devops_source
+ @auto_devops_pipelines ||= project.ci_pipelines.auto_devops_source
end
end
end
diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb
new file mode 100644
index 00000000000..12103ea34b5
--- /dev/null
+++ b/app/services/projects/cleanup_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Projects
+ # The CleanupService removes data from the project repository following a
+ # BFG rewrite: https://rtyley.github.io/bfg-repo-cleaner/
+ #
+ # Before executing this service, all refs rewritten by BFG should have been
+ # pushed to the repository
+ class CleanupService < BaseService
+ NoUploadError = StandardError.new("Couldn't find uploaded object map")
+
+ include Gitlab::Utils::StrongMemoize
+
+ # Attempt to clean up the project following the push. Warning: this is
+ # destructive!
+ #
+ # path is the path of an upload of a BFG object map file. It contains a line
+ # 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!
+
+ # Remove older objects that are no longer referenced
+ GitGarbageCollectWorker.new.perform(project.id, :gc)
+
+ # The cache may now be inaccurate, and holding onto it could prevent
+ # bugs assuming the presence of some object from manifesting for some
+ # time. Better to feel the pain immediately.
+ project.repository.expire_all_method_caches
+
+ project.bfg_object_map.remove!
+ end
+
+ private
+
+ def apply_bfg_object_map!
+ raise NoUploadError unless project.bfg_object_map.exists?
+
+ project.bfg_object_map.open do |io|
+ repository_cleaner.apply_bfg_object_map(io)
+ end
+ end
+
+ def repository_cleaner
+ @repository_cleaner ||= Gitlab::Git::RepositoryCleaner.new(repository.raw)
+ end
+ end
+end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 20bfe5af7a1..d03137b63b2 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -9,7 +9,7 @@ module Projects
end
def execute
- if @params[:template_name]&.present?
+ if @params[:template_name].present?
return ::Projects::CreateFromTemplateService.new(current_user, params).execute
end
@@ -86,6 +86,8 @@ module Projects
@project.create_wiki unless skip_wiki?
end
+ @project.track_project_repository
+
event_service.create_project(@project, current_user)
system_hook_service.execute_hooks_for(@project, :create)
@@ -94,6 +96,8 @@ 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
@@ -119,6 +123,10 @@ 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
@@ -148,7 +156,7 @@ module Projects
Rails.logger.error(log_message)
if @project
- @project.mark_import_as_failed(message) if @project.persisted? && @project.import?
+ @project.import_state.mark_as_failed(message) if @project.persisted? && @project.import?
end
@project
@@ -181,7 +189,7 @@ module Projects
def import_schedule
if @project.errors.empty?
- @project.import_schedule if @project.import? && !@project.bare_repository_import?
+ @project.import_state.schedule if @project.import? && !@project.bare_repository_import?
else
fail(error: @project.errors.full_messages.join(', '))
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 210571b6b4e..336d029d330 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -137,6 +137,8 @@ module Projects
raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
end
+ project.leave_pool_repository
+
Project.transaction do
log_destroy_event
trash_repositories!
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 8dc0e044875..91091c4393d 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -54,6 +54,8 @@ module Projects
new_params[:avatar] = @project.avatar
end
+ new_params.merge!(@project.object_pool_params)
+
new_project = CreateService.new(current_user, new_params).execute
return new_project unless new_project.persisted?
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
index 4462d504071..f3e026ba38c 100644
--- a/app/services/projects/hashed_storage/migrate_repository_service.rb
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -30,6 +30,7 @@ module Projects
if result
project.write_repository_config
+ project.track_project_repository
else
rollback_folder_move
project.storage_version = nil
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index 1c4a8d05be6..f9b9781ad5f 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -4,6 +4,8 @@
module Projects
module LfsPointers
class LfsDownloadService < BaseService
+ VALID_PROTOCOLS = %w[http https].freeze
+
# rubocop: disable CodeReuse/ActiveRecord
def execute(oid, url)
return unless project&.lfs_enabled? && oid.present? && url.present?
@@ -11,6 +13,7 @@ module Projects
return if LfsObject.exists?(oid: oid)
sanitized_uri = Gitlab::UrlSanitizer.new(url)
+ Gitlab::UrlBlocker.validate!(sanitized_uri.sanitized_url, protocols: VALID_PROTOCOLS)
with_tmp_file(oid) do |file|
size = download_and_save_file(file, sanitized_uri)
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 9d40ab166ff..9db3fd9cf17 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -54,6 +54,7 @@ module Projects
end
attempt_transfer_transaction
+ configure_group_clusters_for_project
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -162,5 +163,9 @@ 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/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
new file mode 100644
index 00000000000..d931d528c86
--- /dev/null
+++ b/app/services/suggestions/apply_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class ApplyService < ::BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(suggestion)
+ unless suggestion.appliable?
+ return error('Suggestion is not appliable')
+ end
+
+ params = file_update_params(suggestion)
+ result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute
+
+ if result[:status] == :success
+ suggestion.update(commit_id: result[:result], applied: true)
+ end
+
+ result
+ end
+
+ private
+
+ def file_update_params(suggestion)
+ diff_file = suggestion.diff_file
+
+ file_path = diff_file.file_path
+ branch_name = suggestion.noteable.source_branch
+ file_content = new_file_content(suggestion)
+ commit_message = "Apply suggestion to #{file_path}"
+
+ {
+ file_path: file_path,
+ branch_name: branch_name,
+ start_branch: branch_name,
+ commit_message: commit_message,
+ file_content: file_content
+ }
+ end
+
+ def new_file_content(suggestion)
+ range = suggestion.from_line_index..suggestion.to_line_index
+ blob = suggestion.diff_file.new_blob
+
+ blob.load_all_data!
+ content = blob.data.lines
+ content[range] = suggestion.to_content
+
+ content.join
+ end
+ end
+end
diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb
new file mode 100644
index 00000000000..77e958cbe0c
--- /dev/null
+++ b/app/services/suggestions/create_service.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class CreateService
+ def initialize(note)
+ @note = note
+ end
+
+ 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
+
+ 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}"
+
+ {
+ note_id: @note.id,
+ from_content: from_content,
+ to_content: to_content,
+ 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/tags/create_service.rb b/app/services/tags/create_service.rb
index 35390f5082c..6bb9bb3988e 100644
--- a/app/services/tags/create_service.rb
+++ b/app/services/tags/create_service.rb
@@ -20,7 +20,7 @@ module Tags
end
if new_tag
- if release_description
+ if release_description.present?
CreateReleaseService.new(@project, @current_user)
.execute(tag_name, release_description)
end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
index 45e0e61e5c4..7e14ddcd017 100644
--- a/app/services/test_hooks/project_service.rb
+++ b/app/services/test_hooks/project_service.rb
@@ -49,7 +49,7 @@ module TestHooks
end
def pipeline_events_data
- pipeline = project.pipelines.first
+ pipeline = project.ci_pipelines.first
throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present?
Gitlab::DataBuilder::Pipeline.build(pipeline)
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index a897e4bd56a..af4fe1aebb9 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -16,7 +16,7 @@ module Users
user_exists = @user.persisted?
- assign_attributes(&block)
+ assign_attributes
if @user.save(validate: validate) && update_status
notify_success(user_exists)
@@ -48,9 +48,11 @@ module Users
success
end
- def assign_attributes(&block)
- if @user.user_synced_attributes_metadata
- params.except!(*@user.user_synced_attributes_metadata.read_only_attributes)
+ def assign_attributes
+ if (metadata = @user.user_synced_attributes_metadata)
+ read_only = metadata.read_only_attributes
+
+ params.reject! { |key, _| read_only.include?(key.to_sym) }
end
@user.assign_attributes(params) if params.any?
diff --git a/app/validators/duration_validator.rb b/app/validators/duration_validator.rb
index 811828169ca..defd28d7d3b 100644
--- a/app/validators/duration_validator.rb
+++ b/app/validators/duration_validator.rb
@@ -14,6 +14,10 @@ class DurationValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
ChronicDuration.parse(value)
rescue ChronicDuration::DurationParseError
- record.errors.add(attribute, "is not a correct duration")
+ if options[:message]
+ record.errors.add(:base, options[:message])
+ else
+ record.errors.add(attribute, "is not a correct duration")
+ end
end
end
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
index 216acf79cbd..5feb0b0f05b 100644
--- a/app/validators/url_validator.rb
+++ b/app/validators/url_validator.rb
@@ -69,6 +69,7 @@ class UrlValidator < ActiveModel::EachValidator
ports: [],
allow_localhost: true,
allow_local_network: true,
+ ascii_only: false,
enforce_user: false
}
end
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 7ac79cc77f5..6756299cf43 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -174,7 +174,7 @@
%h4 Latest projects
- @projects.each do |project|
%p
- = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
+ = link_to project.full_name, admin_project_path(project), class: 'str-truncated-60'
%span.light.float-right
#{time_ago_with_tooltip(project.created_at)}
.col-md-4
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 5f205d1bcbc..da2ebb08405 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -101,7 +101,7 @@
= _('Add user(s) to the group:')
.card-body.form-holder
%p.light
- - link_to_help = link_to(_("here"), help_page_path("user/permissions"), class: "vlink")
+ - link_to_help = link_to(_("here"), help_page_path("user/permissions"))
= _('Read more about project permissions <strong>%{link_to_help}</strong>').html_safe % { link_to_help: link_to_help }
= form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 486d0477f20..9c6c74ed965 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -4,7 +4,7 @@
Edit System Hook
%p.light
- #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
+ #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks')} can be
used for binding events when GitLab creates a User or Project.
%hr
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index 5d462d7b732..b65bf07160a 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -4,7 +4,7 @@
%h4.prepend-top-0
= page_title
%p
- #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
+ #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks')} can be
used for binding events when GitLab creates a User or Project.
.col-lg-8.append-bottom-default
diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml
index b201e6bf10e..4f4f0a543e0 100644
--- a/app/views/admin/runners/_sort_dropdown.html.haml
+++ b/app/views/admin/runners/_sort_dropdown.html.haml
@@ -1,11 +1,11 @@
- sorted_by = sort_options_hash[@sort]
.dropdown.inline.prepend-left-10
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
= sorted_by
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
- = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by)
- = sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date, label: true), sorted_by)
+ = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sorted_by)
+ = sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date), sorted_by)
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index bfbc16d37a0..a733f420d11 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -8,7 +8,7 @@
%span.cred (Admin)
.float-right
- - if @user != current_user && @user.can?(:log_in)
+ - if impersonation_enabled? && @user != current_user && @user.can?(:log_in)
= link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info"
= link_to edit_admin_user_path(@user), class: "btn btn-nr btn-grouped" do
%i.fa.fa-pencil-square-o
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index f910e90d6ca..600120c4f05 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -9,28 +9,20 @@
.search-holder
.search-field-holder
= search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false
+ - if @sort.present?
+ = hidden_field_tag :sort, @sort
= icon("search", class: "search-icon")
- .dropdown
- - toggle_text = if @sort.present? then sort_options_hash[@sort] else sort_title_name end
+ = button_tag 'Search users' if Rails.env.test?
+ .dropdown.user-sort-dropdown
+ - toggle_text = if @sort.present? then users_sort_options_hash[@sort] else sort_title_name end
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
Sort by
%li
- = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do
- = sort_title_name
- = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do
- = sort_title_recently_signin
- = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do
- = sort_title_oldest_signin
- = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do
- = sort_title_recently_created
- = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do
- = sort_title_oldest_created
- = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do
- = sort_title_recently_updated
- = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do
- = sort_title_oldest_updated
+ - users_sort_options_hash.each do |value, title|
+ = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
+ = title
= link_to 'New user', new_admin_user_path, class: 'btn btn-success btn-search'
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
diff --git a/app/views/clusters/clusters/_buttons.html.haml b/app/views/clusters/clusters/_buttons.html.haml
index db2e247e341..9238903aa10 100644
--- a/app/views/clusters/clusters/_buttons.html.haml
+++ b/app/views/clusters/clusters/_buttons.html.haml
@@ -1,4 +1,7 @@
-# This partial is overridden in EE
.nav-controls
- %span.btn.btn-add-cluster.disabled.js-add-cluster
- = s_("ClusterIntegration|Add Kubernetes cluster")
+ - if clusterable.can_create_cluster? && clusterable.clusters.empty?
+ = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success js-add-cluster'
+ - else
+ %span.btn.btn-add-cluster.disabled.js-add-cluster
+ = s_("ClusterIntegration|Add Kubernetes cluster")
diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml
index adeca013749..b89789e9915 100644
--- a/app/views/clusters/clusters/_cluster.html.haml
+++ b/app/views/clusters/clusters/_cluster.html.haml
@@ -3,7 +3,7 @@
.table-section.section-60
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
.table-mobile-content
- = link_to cluster.name, cluster.show_path
+ = cluster.item_link(clusterable)
- unless cluster.enabled?
%span.badge.badge-danger Connection disabled
.table-section.section-25
@@ -13,4 +13,4 @@
.table-mobile-header{ role: "rowheader" }
.table-mobile-content
%span.badge.badge-light
- = cluster.project_type? ? s_("ClusterIntegration|Project cluster") : s_("ClusterIntegration|Group cluster")
+ = cluster.cluster_type_description
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 85d1002243b..73b11d509d3 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,6 +1,6 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
- %button.close.js-close{ type: "button" } &times;
+.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' }
+ %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } &times;
.gcp-signup-offer--content
.gcp-signup-offer--icon.append-right-8
= sprite_icon("information", size: 16)
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index ad6d1d856d6..58d0a304363 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -11,6 +11,13 @@
.nav-text
= s_("ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project")
= render 'clusters/clusters/buttons'
+
+ - if @has_ancestor_clusters
+ .bs-callout.bs-callout-info
+ = s_("ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.")
+ %strong
+ = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence')
+
.clusters-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
.table-section.section-60{ role: "rowheader" }
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index 4dbda5c754b..31d4b3da4f1 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -4,9 +4,6 @@
= 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"
-
- page_title "Activity"
- header_title "Activity", activity_dashboard_path
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 2f7add600e4..50f39f93283 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -1,8 +1,6 @@
- @hide_top_links = true
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
-
-= render_if_exists "shared/gold_trial_callout"
= 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..fdd5c19d562 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -4,8 +4,6 @@
= 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"
-
.page-title-holder
%h1.page-title= _('Issues')
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 3e5f13b92e3..77cfa1271df 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -2,8 +2,6 @@
- page_title _("Merge Requests")
- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
-= render_if_exists "shared/gold_trial_callout"
-
.page-title-holder
%h1.page-title= _('Merge Requests')
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 446b4715b2d..deed774a4a5 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -4,8 +4,6 @@
= 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"
-
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml
index ad08409c8fe..8933d9e31ff 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -4,8 +4,6 @@
- page_title "Starred Projects"
- header_title "Projects", dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
-
%div{ class: container_class }
= render "projects/last_push"
= render 'dashboard/projects_head'
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 47729321961..d2593179f17 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -2,8 +2,6 @@
- page_title "Todos"
- header_title "Todos", dashboard_todos_path
-= render_if_exists "shared/gold_trial_callout"
-
.page-title-holder
%h1.page-title= _('Todos')
diff --git a/app/views/devise/mailer/email_changed.html.haml b/app/views/devise/mailer/email_changed.html.haml
new file mode 100644
index 00000000000..5398430fdfd
--- /dev/null
+++ b/app/views/devise/mailer/email_changed.html.haml
@@ -0,0 +1,12 @@
+= email_default_heading("Hello, #{@resource.name}!")
+
+- if @resource.try(:unconfirmed_email?)
+ %p
+ We're contacting you to notify you that your email is being changed to #{@resource.reload.unconfirmed_email}.
+- else
+ %p
+ We're contacting you to notify you that your email has been changed to #{@resource.email}.
+
+%p
+ If you did not initiate this change, please contact your administrator
+ immediately.
diff --git a/app/views/devise/mailer/email_changed.text.erb b/app/views/devise/mailer/email_changed.text.erb
new file mode 100644
index 00000000000..18137389e7b
--- /dev/null
+++ b/app/views/devise/mailer/email_changed.text.erb
@@ -0,0 +1,10 @@
+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 %>.
+<% else %>
+We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
+<% end %>
+
+If you did not initiate this change, please contact your administrator
+immediately.
diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml
index 8ae29b9d337..46931b5932d 100644
--- a/app/views/errors/access_denied.html.haml
+++ b/app/views/errors/access_denied.html.haml
@@ -9,7 +9,7 @@
%p
= message
%p
- = s_('403|Please contact your GitLab administrator to get the permission.')
+ = s_('403|Please contact your GitLab administrator to get permission.')
.action-container.js-go-back{ style: 'display: none' }
%a{ href: 'javascript:history.back()', class: 'btn btn-success' }
= s_('Go Back')
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index 869be4e8581..a3eafc61d0a 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,8 +2,6 @@
- page_title _("Groups")
- header_title _("Groups"), dashboard_groups_path
-= render_if_exists "shared/gold_trial_callout"
-
- if current_user
= render 'dashboard/groups_head'
- else
diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml
index b694103ccaf..f518205f14c 100644
--- a/app/views/explore/projects/_filter.html.haml
+++ b/app/views/explore/projects/_filter.html.haml
@@ -1,8 +1,8 @@
- if current_user
.dropdown
- %button.dropdown-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' }
- = icon('globe')
- %span.light= _("Visibility:")
+ %button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' }
+ = 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..452f390695c 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -2,8 +2,6 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
-
- if current_user
= render 'dashboard/projects_head'
- else
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index d18dec7bd8e..452f390695c 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -2,8 +2,6 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
-
- if current_user
= render 'dashboard/projects_head'
- else
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index d18dec7bd8e..452f390695c 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -2,8 +2,6 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
-
- if current_user
= render 'dashboard/projects_head'
- else
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index a0760c2073b..6219da2c715 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -1,4 +1,4 @@
-.group-home-panel.text-center
+.group-home-panel.text-center.border-bottom
%div{ class: container_class }
.avatar-container.s70.group-avatar
= group_icon(@group, class: "avatar s70 avatar-tile")
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 869c54d89ea..39d0f620283 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -37,6 +37,7 @@
.settings-content
= render 'shared/badges/badge_settings'
+= render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
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 04683ec5a9a..c8cdc2cc3e4 100644
--- a/app/views/groups/group_members/_new_group_member.html.haml
+++ b/app/views/groups/group_members/_new_group_member.html.haml
@@ -8,7 +8,7 @@
.col-md-3.col-lg-2
= select_tag :access_level, options_for_select(GroupMember.access_level_roles, @group_member.access_level), class: "form-control project-access-select"
.form-text.text-muted.append-bottom-10
- = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
+ = link_to "Read more", help_page_path("user/permissions")
about role permissions
.col-md-3.col-lg-2
diff --git a/app/views/groups/labels/edit.html.haml b/app/views/groups/labels/edit.html.haml
index 836981fc6fd..586b0f6ebfa 100644
--- a/app/views/groups/labels/edit.html.haml
+++ b/app/views/groups/labels/edit.html.haml
@@ -1,4 +1,6 @@
-- page_title 'Edit', @label.name, 'Labels'
+- add_to_breadcrumbs _("Labels"), group_labels_path(@group)
+- breadcrumb_title _("Edit")
+- page_title "Edit", @label.name, _("Labels")
%h3.page-title
Edit Label
diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml
index 538c353cf2d..bb0b8d2b94d 100644
--- a/app/views/groups/labels/new.html.haml
+++ b/app/views/groups/labels/new.html.haml
@@ -1,5 +1,6 @@
-- breadcrumb_title "Labels"
-- page_title 'New Label'
+- add_to_breadcrumbs _("Labels"), group_labels_path(@group)
+- breadcrumb_title _("New")
+- page_title _("New Label")
%h3.page-title
New Label
diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml
index 5f6d7d209d0..c703d5f7f93 100644
--- a/app/views/groups/milestones/edit.html.haml
+++ b/app/views/groups/milestones/edit.html.haml
@@ -1,7 +1,10 @@
-- page_title "Milestones"
+- breadcrumb_title _("Edit")
+- page_title _("Milestones")
+
- render "header_title"
%h3.page-title
Edit Milestone
+%hr
= render "form"
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index d758e314d41..248cb3b0ba5 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -1,7 +1,12 @@
-- breadcrumb_title "Milestones"
-- page_title "Milestones"
+- @no_container = true
+- add_to_breadcrumbs _("Milestones"), group_milestones_path(@group)
+- breadcrumb_title _("New")
+- page_title _("Milestones"), @milestone.name, _("Milestones")
-%h3.page-title
- New Milestone
+%div{ class: container_class }
+ %h3.page-title
+ New Milestone
-= render "form"
+ %hr
+
+ = render "form"
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
new file mode 100644
index 00000000000..b24d6e27536
--- /dev/null
+++ b/app/views/ide/_show.html.haml
@@ -0,0 +1,10 @@
+- @body_class = 'ide-layout'
+- page_title 'IDE'
+
+- content_for :page_specific_javascripts do
+ = stylesheet_link_tag 'page_bundles/ide'
+
+#ide.ide-loading{ data: ide_data() }
+ .text-center
+ = icon('spinner spin 2x')
+ %h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
index d8bd37fe986..0323f9d093d 100644
--- a/app/views/ide/index.html.haml
+++ b/app/views/ide/index.html.haml
@@ -1,17 +1 @@
-- @body_class = 'ide-layout'
-- page_title 'IDE'
-
-- content_for :page_specific_javascripts do
- = stylesheet_link_tag 'page_bundles/ide'
-
-#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
- "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
- "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
- "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
- "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
- "ci-help-page-path" => help_page_path('ci/quick_start/README'),
- "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
- "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } }
- .text-center
- = icon('spinner spin 2x')
- %h2.clgray= _('Loading the GitLab IDE...')
+= render 'ide/show'
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index 3b1b5e55302..2336e1e83f9 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -37,11 +37,12 @@
%td
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- - if project.import_status == 'finished'
+ - case project.import_status
+ - when 'finished'
%span
%i.fa.fa-check
= _('done')
- - elsif project.import_status == 'started'
+ - when 'started'
%i.fa.fa-spinner.fa-spin
= _('started')
- else
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index 56d4f2ba881..ef69197e453 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -38,9 +38,10 @@
%td
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- - if project.import_status == 'finished'
+ - case project.import_status
+ - when 'finished'
= icon('check', text: 'Done')
- - elsif project.import_status == 'started'
+ - when 'started'
= icon('spin', text: 'started')
- else
= project.human_import_status_name
diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml
index 830d141ebea..eca67582d6f 100644
--- a/app/views/import/fogbugz/status.html.haml
+++ b/app/views/import/fogbugz/status.html.haml
@@ -34,11 +34,12 @@
%td
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- - if project.import_status == 'finished'
+ - case project.import_status
+ - when 'finished'
%span
%i.fa.fa-check
= _("done")
- - elsif project.import_status == 'started'
+ - when 'started'
%i.fa.fa-spinner.fa-spin
= _("started")
- else
diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml
index b7bfbae5edf..a5fa12fe7df 100644
--- a/app/views/import/gitlab/status.html.haml
+++ b/app/views/import/gitlab/status.html.haml
@@ -30,11 +30,12 @@
%td
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- - if project.import_status == 'finished'
+ - case project.import_status
+ - when 'finished'
%span
%i.fa.fa-check
= _('done')
- - elsif project.import_status == 'started'
+ - when 'started'
%i.fa.fa-spinner.fa-spin
= _('started')
- else
diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml
index 347e2820f94..f322b7a956a 100644
--- a/app/views/import/google_code/status.html.haml
+++ b/app/views/import/google_code/status.html.haml
@@ -39,11 +39,12 @@
%td
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- - if project.import_status == 'finished'
+ - case project.import_status
+ - when 'finished'
%span
%i.fa.fa-check
= _("done")
- - elsif project.import_status == 'started'
+ - when 'started'
%i.fa.fa-spinner.fa-spin
= _("started")
- else
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index c2bb1216c5f..30ab5781014 100644
--- a/app/views/invites/show.html.haml
+++ b/app/views/invites/show.html.haml
@@ -1,5 +1,5 @@
-- page_title "Invitation"
-%h3.page-title Invitation
+- page_title _("Invitation")
+%h3.page-title= _("Invitation")
%p
You have been invited
@@ -24,14 +24,17 @@
- if is_member
%p
- However, you are already a member of this #{@member.source.is_a?(Group) ? "group" : "project"}.
- Sign in using a different account to accept the invitation.
+ - member_source = @member.source.is_a?(Group) ? _("group") : _("project")
+ = _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: member_source }
- if @member.invite_email != current_user.email
%p
- Note that this invitation was sent to #{mail_to @member.invite_email}, but you are signed in as #{link_to current_user.to_reference, user_url(current_user)} with email #{mail_to current_user.email}.
+ - mail_to_invite_email = mail_to(@member.invite_email)
+ - mail_to_current_user = mail_to(current_user.email)
+ - link_to_current_user = link_to(current_user.to_reference, user_url(current_user))
+ = _("Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}.").html_safe % { mail_to_invite_email: mail_to_invite_email, mail_to_current_user: mail_to_current_user, link_to_current_user: link_to_current_user }
- unless is_member
.actions
- = link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success"
- = link_to "Decline", decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10"
+ = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success"
+ = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10"
diff --git a/app/views/issues/_issues_calendar.ics.ruby b/app/views/issues/_issues_calendar.ics.ruby
index 73ab8489e0c..94c3099ace2 100644
--- a/app/views/issues/_issues_calendar.ics.ruby
+++ b/app/views/issues/_issues_calendar.ics.ruby
@@ -3,7 +3,7 @@ cal.prodid = '-//GitLab//NONSGML GitLab//EN'
cal.x_wr_calname = 'GitLab Issues'
# rubocop: disable CodeReuse/ActiveRecord
-@issues.includes(project: :namespace).each do |issue|
+@issues.preload(project: :namespace).each do |issue|
cal.event do |event|
event.dtstart = Icalendar::Values::Date.new(issue.due_date)
event.summary = "#{issue.title} (in #{issue.project.full_path})"
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index ac5916d129c..08a6359f777 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -36,6 +36,7 @@
= stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
+ = stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab)
= Gon::Base.render_data
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 43bd07679ba..4f8db74382f 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -9,7 +9,7 @@
.container.navless-container
.content
= render "layouts/flash"
- .row
+ .row.append-bottom-15
.col-sm-7.brand-holder
%h1
= brand_title
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index b7d69539eb7..e8d0d809181 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -15,7 +15,7 @@
= brand_header_logo
- logo_text = brand_header_logo_type
- if logo_text.present?
- %span.logo-text.d-none.d-sm-block
+ %span.logo-text.d-none.d-lg-block.prepend-left-8
= logo_text
- if current_user
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index ea5f2b166b4..7057a5a142f 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -1,3 +1,5 @@
+-# WAIT! Before adding more items to the nav bar, please see
+-# https://gitlab.com/gitlab-org/gitlab-ce/issues/49713 for more information.
%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" }) do
@@ -16,22 +18,22 @@
= render "layouts/nav/groups_dropdown/show"
- if dashboard_nav_link?(:activity)
- = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = 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
= _('Activity')
- if dashboard_nav_link?(:milestones)
- = nav_link(controller: 'dashboard/milestones', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = 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
= _('Milestones')
- if dashboard_nav_link?(:snippets)
- = nav_link(controller: 'dashboard/snippets', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = 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
= _('Snippets')
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
- %li.header-more.dropdown.d-lg-none.d-xl-none
+ %li.header-more.dropdown.d-xl-none{ class: ('d-lg-none' unless has_extra_nav_icons?) }
%a{ href: "#", data: { toggle: "dropdown" } }
= _('More')
= sprite_icon('angle-down', css_class: 'caret-down')
@@ -52,6 +54,21 @@
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
= _('Snippets')
+ = render_if_exists 'dashboard/operations/nav_link'
+ - 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
+ = _('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
+ = _('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
+ = _('Sherlock Transactions')
+
-# Shortcut to Dashboard > Projects
- if dashboard_nav_link?(:projects)
%li.hidden
@@ -64,19 +81,17 @@
= link_to '#', class: 'dashboard-shortcuts-web-ide', title: _('Web IDE') do
= _('Web IDE')
- - if show_separator?
- %li.line-separator.d-none.d-sm-block
= render_if_exists 'dashboard/operations/nav_link'
- if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:conversational_development_index, :cohorts]) do
+ = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do
= link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('chart', size: 18)
- if current_user.admin?
- = nav_link(controller: 'admin/dashboard') 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
+ = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-lg-block d-xl-block"}) do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin', size: 18)
- if Gitlab::Sherlock.enabled?
%li
- = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'),
+ = 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')
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index ab15889a465..59557c70904 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -29,6 +29,11 @@
= link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
%span= _('Activity')
+ - if project_nav_tab?(:releases) && Feature.enabled?(:releases_page)
+ = nav_link(controller: :releases) do
+ = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do
+ %span= _('Releases')
+
= render_if_exists 'projects/sidebar/security_dashboard'
- if can?(current_user, :read_cycle_analytics, @project)
@@ -62,7 +67,7 @@
= link_to project_branches_path(@project) do
= _('Branches')
- = nav_link(controller: [:tags, :releases]) do
+ = nav_link(controller: [:tags]) do
= link_to project_tags_path(@project) do
= _('Tags')
@@ -196,7 +201,7 @@
- if project_nav_tab? :operations
= nav_link(controller: sidebar_operations_paths) do
- = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do
+ = link_to sidebar_operations_link_path, class: 'shortcuts-operations' do
.nav-icon-container
= sprite_icon('cloud-gear')
%span.nav-item-name
@@ -204,7 +209,7 @@
%ul.sidebar-sub-level-items
= nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do
- = link_to metrics_project_environments_path(@project) do
+ = link_to sidebar_operations_link_path do
%strong.fly-out-top-item-name
= _('Operations')
%li.divider.fly-out-top-item
@@ -222,6 +227,12 @@
%span
= _('Environments')
+ - if project_nav_tab? :serverless
+ = nav_link(controller: :functions) do
+ = link_to project_serverless_functions_path(@project), title: _('Serverless') do
+ %span
+ = _('Serverless')
+
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index 94bd6f96dbc..1fbae2f64ed 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -1,13 +1,18 @@
-- discussion = @note.discussion if @note.part_of_discussion?
+- note = local_assigns.fetch(:note, @note)
+- diff_limit = local_assigns.fetch(:diff_limit, nil)
+- target_url = local_assigns.fetch(:target_url, @target_url)
+- note_style = local_assigns.fetch(:note_style, "")
+
+- discussion = note.discussion if note.part_of_discussion?
- diff_discussion = discussion&.diff_discussion?
- on_image = discussion.on_image? if diff_discussion
- if discussion
- phrase_end_char = on_image ? "." : ":"
- %p.details
+ %p{ style: "color: #777777;" }
= succeed phrase_end_char do
- = link_to @note.author_name, user_url(@note.author)
+ = link_to note.author_name, user_url(note.author)
- if diff_discussion
- if discussion.new_discussion?
@@ -15,16 +20,16 @@
- else
commented on a discussion
- on #{link_to discussion.file_path, @target_url}
+ on #{link_to discussion.file_path, target_url}
- else
- if discussion.new_discussion?
started a new discussion
- else
- commented on a #{link_to 'discussion', @target_url}
+ commented on a #{link_to 'discussion', target_url}
- elsif Gitlab::CurrentSettings.email_author_in_body
%p.details
- #{link_to @note.author_name, user_url(@note.author)} commented:
+ #{link_to note.author_name, user_url(note.author)} commented:
- if diff_discussion && !on_image
= content_for :head do
@@ -32,11 +37,11 @@
%table
= render partial: "projects/diffs/line",
- collection: discussion.truncated_diff_lines,
+ collection: discussion.truncated_diff_lines(diff_limit: diff_limit),
as: :line,
locals: { diff_file: discussion.diff_file,
plain: true,
email: true }
-%div
- = markdown(@note.note, pipeline: :email, author: @note.author)
+%div{ style: note_style }
+ = markdown(note.note, pipeline: :email, author: note.author)
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index c319cb55e87..4bf252b6ce1 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -1,6 +1,9 @@
-<% discussion = @note.discussion if @note.part_of_discussion? -%>
+<% note = local_assigns.fetch(:note, @note) -%>
+<% diff_limit = local_assigns.fetch(:diff_limit, nil) -%>
+
+<% discussion = note.discussion if note.part_of_discussion? -%>
<% if discussion && !discussion.individual_note? -%>
-<%= @note.author_name -%>
+<%= note.author_name -%>
<% if discussion.new_discussion? -%>
<%= " started a new discussion" -%>
<% else -%>
@@ -13,14 +16,14 @@
<% elsif Gitlab::CurrentSettings.email_author_in_body -%>
-<%= "#{@note.author_name} commented:" -%>
+<%= "#{note.author_name} commented:" -%>
<% end -%>
<% if discussion&.diff_discussion? -%>
-<% discussion.truncated_diff_lines(highlight: false).each do |line| -%>
+<% discussion.truncated_diff_lines(highlight: false, diff_limit: diff_limit).each do |line| -%>
<%= "> #{line.text}\n" -%>
<% end -%>
<% end -%>
-<%= @note.note -%>
+<%= note.note -%>
diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_project_snippet_email.html.haml
index 5e69f01a486..5e69f01a486 100644
--- a/app/views/notify/note_snippet_email.html.haml
+++ b/app/views/notify/note_project_snippet_email.html.haml
diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_project_snippet_email.text.erb
index 413d9e6e9ac..413d9e6e9ac 100644
--- a/app/views/notify/note_snippet_email.text.erb
+++ b/app/views/notify/note_project_snippet_email.text.erb
diff --git a/app/views/notify/remote_mirror_update_failed_email.html.haml b/app/views/notify/remote_mirror_update_failed_email.html.haml
new file mode 100644
index 00000000000..4fb0a4c5a8a
--- /dev/null
+++ b/app/views/notify/remote_mirror_update_failed_email.html.haml
@@ -0,0 +1,46 @@
+%tr.alert{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %td{ style: "padding:10px;border-radius:3px;font-size:14px;line-height:1.3;text-align:center;overflow:hidden;background-color:#d22f57;color:#ffffff;" }
+ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" }
+ %tbody
+ %tr
+ %td{ style: "vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;line-height:1;" }
+ %img{ alt: "✖", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/
+ %td{ style: "vertical-align:middle;color:#ffffff;text-align:center;" }
+ A remote mirror update has failed.
+%tr.spacer{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %td{ style: "height:18px;font-size:18px;line-height:18px;" }
+ &nbsp;
+%tr.section{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %td{ style: "padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" }
+ %table.table-info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
+ %tbody{ style: "font-size:15px;line-height:1.4;color:#8c8c8c;" }
+ %tr
+ %td{ style: "font-weight:300;padding:14px 0;margin:0;" } Project
+ %td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" }
+ - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner)
+ %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" }
+ = @project.owner_name
+ \/
+ %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" }
+ = @project.name
+ %tr
+ %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Remote mirror
+ %td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
+ = @remote_mirror.safe_url
+ %tr
+ %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Last update at
+ %td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
+ = @remote_mirror.last_update_at
+
+%tr.table-warning{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %td{ style: "border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" }
+ Logs may contain sensitive data. Please consider before forwarding this email.
+%tr.section{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %td{ style: "padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" }
+ %table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" }
+ %tbody
+ %tr.build-log
+ %td{ colspan: "2", style: "padding: 0 0 16px;" }
+ %pre{ style: "font-family: Monaco,'Lucida Console','Courier New',Courier,monospace; background-color: #fafafa; border-radius: 4px; overflow: hidden; white-space: pre-wrap; word-break: break-all; font-size:13px; line-height: 1.4; padding: 16px 8px; color: #333333; margin: 0;" }
+ = @remote_mirror.last_error
+
diff --git a/app/views/notify/remote_mirror_update_failed_email.text.erb b/app/views/notify/remote_mirror_update_failed_email.text.erb
new file mode 100644
index 00000000000..c6f29f0ad1c
--- /dev/null
+++ b/app/views/notify/remote_mirror_update_failed_email.text.erb
@@ -0,0 +1,7 @@
+A remote mirror update has failed.
+
+Project: <%= @project.human_name %> ( <%= project_url(@project) %> )
+Remote mirror: <%= @remote_mirror.safe_url %>
+Last update at: <%= @remote_mirror.last_update_at %>
+Last error:
+<%= @remote_mirror.last_error %>
diff --git a/app/views/notify/repository_cleanup_failure_email.text.erb b/app/views/notify/repository_cleanup_failure_email.text.erb
new file mode 100644
index 00000000000..f5a426a51d1
--- /dev/null
+++ b/app/views/notify/repository_cleanup_failure_email.text.erb
@@ -0,0 +1,3 @@
+Repository cleanup failed on <%= @project.web_url %>
+
+<%= @error %>
diff --git a/app/views/notify/repository_cleanup_success_email.text.erb b/app/views/notify/repository_cleanup_success_email.text.erb
new file mode 100644
index 00000000000..e6e95da2fcc
--- /dev/null
+++ b/app/views/notify/repository_cleanup_success_email.text.erb
@@ -0,0 +1,3 @@
+Repository cleanup succeeded on <%= @project.web_url %>
+
+Repository size is now <%= "%.1f" % (@project.repository.size || 0) %> MiB
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 2220b4eee96..e167e094240 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -21,7 +21,7 @@
= link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success'
%hr
-- if button_based_providers.any?
+- if display_providers_on_profile?
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
@@ -46,6 +46,7 @@
- else
= link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do
Connect
+ = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: local_assigns[:group_saml_identities]
%hr
- if current_user.can_change_username?
.row.prepend-top-default
@@ -66,7 +67,7 @@
%h4.prepend-top-0.danger-title
= s_('Profiles|Delete account')
.col-lg-8
- - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
+ - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user)
%p
= s_('Profiles|Deleting an account has the following effects:')
= render 'users/deletion_guidance', user: current_user
@@ -79,10 +80,10 @@
confirm_with_password: ('true' if current_user.confirm_deletion_with_password?),
username: current_user.username } }
- else
- - if @user.solo_owned_groups.present?
+ - if current_user.solo_owned_groups.present?
%p
= s_('Profiles|Your account is currently an owner in these groups:')
- %strong= @user.solo_owned_groups.map(&:name).join(', ')
+ %strong= current_user.solo_owned_groups.map(&:name).join(', ')
%p
= s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.')
- else
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 2603c558c0f..2629b374e7c 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -71,43 +71,43 @@
%h4.prepend-top-0
= s_("Profiles|Main settings")
%p
- = s_("Profiles|This information will appear on your profile.")
+ = s_("Profiles|This information will appear on your profile")
- if current_user.ldap_user?
= s_("Profiles|Some options are unavailable for LDAP accounts")
.col-lg-8
.row
- if @user.read_only_attribute?(:name)
= f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' },
- 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) }
+ 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, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you."
+ = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, 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' }
- if @user.read_only_attribute?(:email)
- = f.text_field :email, required: true, readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(: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, value: (@user.email unless @user.temp_oauth_email?),
+ = 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'
+ { 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'
+ control_class: 'select2 input-lg'
= f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
- { help: s_("Profiles|This feature is experimental and translations are not complete yet.") },
- control_class: 'select2'
- = f.text_field :skype
- = f.text_field :linkedin
- = f.text_field :twitter
- = f.text_field :website_url, label: s_("Profiles|Website")
+ { help: s_("Profiles|This feature is experimental and translations are not complete yet") },
+ control_class: 'select2 input-lg'
+ = 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")
+ = f.text_field :website_url, class: 'input-lg', placeholder: s_("Profiles|website.com")
- 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) }
+ = 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
- = f.text_field :organization
- = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters.")
+ = 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")
%hr
%h5= ("Private profile")
.checkbox-icon-inline-wrapper
@@ -118,7 +118,7 @@
%h5= s_("Profiles|Private contributions")
= f.check_box :include_private_contributions, label: 'Include private contributions on my profile'
.help-block
- = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
+ = 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
= f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success'
= link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel'
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 94ec0cc5db8..d986c566928 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -25,7 +25,8 @@
- else
%p
- Download the Google Authenticator application from App Store or Google Play Store and scan this code.
+ 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'))}.
.row.append-bottom-10
.col-md-4
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 79530e78154..22a721ee9ad 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -1,7 +1,9 @@
+- is_project_overview = local_assigns.fetch(:is_project_overview, false)
- commit = local_assigns.fetch(:commit) { @repository.commit }
- ref = local_assigns.fetch(:ref) { current_ref }
- project = local_assigns.fetch(:project) { @project }
- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) }
+- show_auto_devops_callout = show_auto_devops_callout?(@project)
#tree-holder.tree-holder.clearfix
.nav-block
@@ -10,4 +12,8 @@
- if commit
= render 'shared/commit_well', commit: commit, ref: ref, project: project
+ - if is_project_overview
+ .project-buttons.append-bottom-default
+ = 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
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index dcef4dd5b69..82b2ab64a5d 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -1,83 +1,75 @@
- empty_repo = @project.empty_repo?
-- license = @project.license_anchor_data
+- show_auto_devops_callout = show_auto_devops_callout?(@project)
.project-home-panel{ class: ("empty-project" if empty_repo) }
- .limit-container-width{ class: container_class }
- .project-header.d-flex.flex-row.flex-wrap.align-items-center.append-bottom-8
- .project-title-row.d-flex.align-items-center
- .avatar-container.project-avatar.float-none
- = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s24', width: 24, height: 24)
- %h1.project-title.d-flex.align-items-baseline.qa-project-name
- = @project.name
- .project-metadata.d-flex.flex-row.flex-wrap.align-items-baseline
- .project-visibility.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
- = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
- = visibility_level_label(@project.visibility_level)
- - if license.present?
- .project-license.d-inline-flex.align-items-baseline
- = link_to_if license.link, sprite_icon('scale', size: 16, css_class: 'icon') + license.label, license.link, class: license.enabled ? 'btn btn-link btn-secondary-hover-link' : 'btn btn-link'
- - if @project.tag_list.present?
- .project-tag-list.d-inline-flex.align-items-baseline.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil }
- = sprite_icon('tag', size: 16, css_class: 'icon')
- = @project.tags_to_show
- - if @project.has_extra_tags?
- = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown }
+ .project-header.row.append-bottom-8
+ .project-title-row.col-md-12.col-lg-6.d-flex
+ .avatar-container.project-avatar.float-none
+ = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64)
+ .d-flex.flex-column.flex-wrap.align-items-baseline
+ .d-inline-flex.align-items-baseline
+ %h1.project-title.qa-project-name
+ = @project.name
+ %span.project-visibility.prepend-left-8.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
+ = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
+ .project-metadata.d-flex.align-items-center
+ - if can?(current_user, :read_project, @project)
+ %span.text-secondary
+ = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
+ - if current_user
+ %span.access-request-links.prepend-left-8
+ = render 'shared/members/access_request_links', source: @project
+ - if @project.tag_list.present?
+ %span.project-tag-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil }
+ = sprite_icon('tag', size: 16, css_class: 'icon append-right-4')
+ = @project.tags_to_show
+ - if @project.has_extra_tags?
+ = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown }
- .project-home-desc
- - if @project.description.present?
- .project-description
- .project-description-markdown.read-more-container
- = markdown_field(@project, :description)
- %button.btn.btn-blank.btn-link.text-secondary.js-read-more-trigger.text-secondary.d-lg-none{ type: "button" }
- = _("Read more")
-
- - if can?(current_user, :read_project, @project)
- .text-secondary.prepend-top-8
- = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
-
- - if @project.forked?
- %p
- - if @project.fork_source
- #{ s_('ForkedFromProjectPath|Forked from') }
- = link_to project_path(@project.fork_source) do
- = fork_source_name(@project)
- - else
- - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
- = deleted_message % { project_name: fork_source_name(@project) }
-
- - if @project.badges.present?
- .project-badges.prepend-top-default.append-bottom-default
- - @project.badges.each do |badge|
- %a.append-right-8{ href: badge.rendered_link_url(@project),
- target: '_blank',
- rel: 'noopener noreferrer' }>
- %img.project-badge{ src: badge.rendered_image_url(@project),
- 'aria-hidden': true,
- alt: 'Project badge' }>
+ .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
+ - if current_user
+ .d-inline-flex
+ = render 'projects/buttons/notifications', notification_setting: @notification_setting, btn_class: 'btn-xs'
- .project-repo-buttons.d-inline-flex.flex-wrap
.count-buttons.d-inline-flex
= render 'projects/buttons/star'
= render 'projects/buttons/fork'
- if can?(current_user, :download_code, @project)
- .project-clone-holder.d-inline-flex.d-sm-none
+ .project-clone-holder.d-inline-flex.d-md-none.btn-block
= render "shared/mobile_clone_panel"
- .project-clone-holder.d-none.d-sm-inline-flex
- = render "shared/clone_panel"
+ .project-clone-holder.d-none.d-md-inline-flex
+ = render "projects/buttons/clone"
- - if show_xcode_link?(@project)
- .project-action-button.project-xcode.inline
- = render "projects/buttons/xcode_link"
+ - if can?(current_user, :download_code, @project)
+ %nav.project-stats
+ .nav-links.quick-links.mt-3
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
- - if current_user
- - if can?(current_user, :download_code, @project)
- .d-none.d-sm-inline-flex
- = render 'projects/buttons/download', project: @project, ref: @ref
- .d-none.d-sm-inline-flex
- = render 'projects/buttons/dropdown'
+ .project-home-desc.mt-1
+ - if @project.description.present?
+ .project-description
+ .project-description-markdown.read-more-container
+ = markdown_field(@project, :description)
+ %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
+ = _("Read more")
+
+ - if @project.forked?
+ %p
+ - if @project.fork_source
+ #{ s_('ForkedFromProjectPath|Forked from') }
+ = link_to project_path(@project.fork_source) do
+ = fork_source_name(@project)
+ - else
+ - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
+ = deleted_message % { project_name: fork_source_name(@project) }
- .d-none.d-sm-inline-flex
- = render 'shared/notifications/button', notification_setting: @notification_setting
- .d-none.d-sm-inline-flex
- = render 'shared/members/access_request_buttons', source: @project
+ - if @project.badges.present?
+ .project-badges.mb-2
+ - @project.badges.each do |badge|
+ %a.append-right-8{ href: badge.rendered_link_url(@project),
+ target: '_blank',
+ rel: 'noopener noreferrer' }>
+ %img.project-badge{ src: badge.rendered_image_url(@project),
+ 'aria-hidden': true,
+ alt: 'Project badge' }>
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
index 4cf49f3cf62..8e3d759b683 100644
--- a/app/views/projects/_stat_anchor_list.html.haml
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -4,5 +4,5 @@
%ul.nav
- anchors.each do |anchor|
%li.nav-item
- = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.enabled ? 'nav-link stat-link' : "nav-link btn btn-#{anchor.class_modifier || 'missing'}" do
- .stat-text= anchor.label
+ = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.is_link ? 'nav-link stat-link d-flex align-items-center' : "nav-link btn btn-#{anchor.class_modifier || 'missing'} d-flex align-items-center" do
+ .stat-text.d-flex.align-items-center= anchor.label
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index cf273aab108..95c5eb32c7f 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -9,6 +9,6 @@
= render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
- %article.file-holder
+ %article.file-holder{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index eb65cd90ea8..ff460a3831c 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -1,7 +1,7 @@
.diff-file.file-holder
.diff-content
- if markup?(@blob.name)
- .file-content.wiki
+ .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(@blob.name, @content, legacy_render_context(params))
- else
.file-content.code.js-syntax-highlight
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index bd12cadf240..6edbfd91b21 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -2,5 +2,5 @@
- context = legacy_render_context(params)
- unless context[:markdown_engine] == :redcarpet
- context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
-.file-content.wiki
+.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
new file mode 100644
index 00000000000..d453a3a9dac
--- /dev/null
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -0,0 +1,31 @@
+- project = project || @project
+
+.git-clone-holder.js-git-clone-holder.input-group
+ - if allowed_protocols_present?
+ .input-group-text.clone-dropdown-btn.btn
+ %span.js-clone-dropdown-label
+ = enabled_project_button(project, enabled_protocol)
+ - else
+ %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+ %span.append-right-4.js-clone-dropdown-label
+ = _('Clone')
+ = sprite_icon("arrow-down", css_class: "icon")
+ %form.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options
+ %li.pb-2
+ %label.label-bold
+ = _('Clone with SSH')
+ .input-group
+ = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' }
+ .input-group-append
+ = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
+ = render_if_exists 'projects/buttons/geo'
+ %li
+ %label.label-bold
+ = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
+ .input-group
+ = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: 'Project clone URL' }
+ .input-group-append
+ = clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
+ = render_if_exists 'projects/buttons/geo'
+
+= 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 f7551434d47..4eb53faa6ff 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -5,8 +5,8 @@
.project-action-button.dropdown.inline>
%button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static' }
= sprite_icon('download')
- = icon("caret-down")
%span.sr-only= _('Select Archive Format')
+ = sprite_icon("arrow-down")
%ul.dropdown-menu.dropdown-menu-right{ role: 'menu' }
%li.dropdown-header
#{ _('Source code') }
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 8da27ca7cb3..bc0a89bea62 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,9 +1,6 @@
- unless @project.empty_repo?
- if current_user && can?(current_user, :fork_project, @project)
.count-badge.d-inline-flex.align-item-stretch.append-right-8
- %span.fork-count.count-badge-count.d-flex.align-items-center
- = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do
- = @project.forks_count
- 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: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do
= sprite_icon('fork', { css_class: 'icon' })
@@ -15,3 +12,6 @@
title: (s_('ProjectOverview|You have reached your project limit') unless can_create_fork) do
= sprite_icon('fork', { css_class: 'icon' })
%span= s_('ProjectOverview|Fork')
+ %span.fork-count.count-badge-count.d-flex.align-items-center
+ = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do
+ = @project.forks_count
diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/projects/buttons/_notifications.html.haml
new file mode 100644
index 00000000000..745983ace7e
--- /dev/null
+++ b/app/views/projects/buttons/_notifications.html.haml
@@ -0,0 +1,27 @@
+- btn_class = local_assigns.fetch(:btn_class, "btn-xs")
+
+- if notification_setting
+ .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline
+ = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f|
+ = hidden_setting_source_input(notification_setting)
+ = hidden_field_tag "hide_label", true
+ = 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 - %{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", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
+ = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon")
+ %span.js-notification-loading.fa.hidden
+ %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ = sprite_icon("arrow-down", css_class: "icon")
+ .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", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon")
+ %span.js-notification-loading.fa.hidden
+ = sprite_icon("arrow-down", css_class: "icon")
+
+ = render "shared/notifications/notification_dropdown", notification_setting: notification_setting
+
+ = content_for :scripts_body do
+ = render "shared/notifications/custom_notifications", notification_setting: notification_setting
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 0d04ecb3a58..090d1549aa7 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -1,19 +1,19 @@
- if current_user
.count-badge.d-inline-flex.align-item-stretch.append-right-8
- %span.star-count.count-badge-count.d-flex.align-items-center
- = @project.star_count
- %button.count-badge-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
+ %button.count-badge-button.btn.btn-default.btn-xs.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
- if current_user.starred?(@project)
= sprite_icon('star', { css_class: 'icon' })
%span.starred= s_('ProjectOverview|Unstar')
- else
= sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star')
+ %span.star-count.count-badge-count.d-flex.align-items-center
+ = @project.star_count
- else
.count-badge.d-inline-flex.align-item-stretch.append-right-8
- %span.star-count.count-badge-count.d-flex.align-items-center
- = @project.star_count
- = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
+ = link_to new_user_session_path, class: 'btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
= sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star')
+ %span.star-count.count-badge-count.d-flex.align-items-center
+ = @project.star_count
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
new file mode 100644
index 00000000000..cecc139b183
--- /dev/null
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -0,0 +1,29 @@
+- expanded = Rails.env.test?
+
+%section.settings.no-animate#cleanup{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4= _('Repository cleanup')
+ %button.btn.js-settings-toggle
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _("Clean up after running %{bfg} on the repository" % { bfg: link_to_bfg }).html_safe
+ = link_to icon('question-circle'),
+ help_page_path('user/project/repository/reducing_the_repo_size_using_git.md'),
+ target: '_blank', rel: 'noopener noreferrer'
+
+ .settings-content
+ - url = cleanup_namespace_project_settings_repository_path(@project.namespace, @project)
+ = form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f|
+ %fieldset.prepend-top-0.append-bottom-10
+ .append-bottom-10
+ %h5.prepend-top-0
+ = _("Upload object map")
+ %button.btn.btn-default.js-choose-file{ type: "button" }
+ = _("Choose a file")
+ %span.prepend-left-default.js-filename
+ = _("No file selected")
+ = f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true
+ .form-text.text-muted
+ = _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size }
+ = f.submit _('Start cleanup'), class: 'btn btn-success'
+
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index aab5712d197..2a919a767c0 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -28,7 +28,7 @@
= link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 d-none d-sm-none d-md-inline" do
#{ _('Browse files') }
.dropdown.inline
- %a.btn.btn-default.dropdown-toggle{ data: { toggle: "dropdown" } }
+ %a.btn.btn-default.dropdown-toggle.qa-options-button{ data: { toggle: "dropdown" } }
%span= _('Options')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
@@ -48,8 +48,8 @@
%li.dropdown-header
#{ _('Download') }
- unless @commit.parents.length > 1
- %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch)
- %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff)
+ %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches"
+ %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff"
.commit-box{ data: { project_path: project_path(@project) } }
%h3.commit-title
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index c6789e32dbe..1a74b120c26 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -8,62 +8,50 @@
- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
- link = commit_path(project, commit, merge_request: merge_request)
-- cache_key = [project.full_path,
- ref,
- commit.id,
- Gitlab::CurrentSettings.current_application_settings,
- @path.presence,
- current_controller?(:commits),
- merge_request&.iid,
- view_details,
- commit.status(ref),
- I18n.locale].compact
-
-= cache(cache_key, expires_in: 1.day) do
- %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)
-
- .commit-detail.flex-list
- .commit-content.qa-commit-content
- - if view_details && merge_request
- = 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
- &middot;
- = commit.short_id
- - if commit.status(ref)
- .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)
+%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)
+
+ .commit-detail.flex-list
+ .commit-content.qa-commit-content
+ - if view_details && merge_request
+ = 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
+ &middot;
+ = commit.short_id
+ - if commit.status(ref)
+ .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)
- .committer
- - commit_author_link = commit_author_link(commit, avatar: false, size: 24)
- - 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 }
+ .committer
+ - commit_author_link = commit_author_link(commit, avatar: false, size: 24)
+ - 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 }
- - if commit.description?
- %pre.commit-row-description.js-toggle-content.append-bottom-8
- = preserve(markdown_field(commit, :description))
+ - 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
- - if request.xhr?
- = render partial: 'projects/commit/signature', object: commit.signature
- - else
- = render partial: 'projects/commit/ajax_signature', locals: { commit: commit }
+ .commit-actions.flex-row.d-none.d-sm-flex
+ - if request.xhr?
+ = render partial: 'projects/commit/signature', object: commit.signature
+ - else
+ = render partial: 'projects/commit/ajax_signature', locals: { commit: commit }
- - if commit.status(ref)
- = render_commit_status(commit, ref: ref)
+ - if commit.status(ref)
+ = render_commit_status(commit, ref: ref)
- .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
+ .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
- .commit-sha-group
- .label.label-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)
+ .commit-sha-group
+ .label.label-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/edit.html.haml b/app/views/projects/edit.html.haml
index f376df29878..1b52821af15 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -53,7 +53,7 @@
= _("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-avatar-filename= _("No file chosen")
+ %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?
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 936900a0087..081990ac9b7 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -4,11 +4,10 @@
= render partial: 'flash_messages', locals: { project: @project }
-= render "home_panel"
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
+ = render "home_panel"
-.project-empty-note-panel
- %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- .prepend-top-20
+ .project-empty-note-panel
%h4.append-bottom-20
= _('The repository for this project is empty')
@@ -32,66 +31,65 @@
= _('Otherwise it is recommended you start with one of the options below.')
.prepend-top-20
-%nav.project-stats{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .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_anchors
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
+ %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
-- if can?(current_user, :push_code, @project)
- %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- .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}"
+ - 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
+ %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 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= _('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 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= _('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
- - 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"
+ - 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"
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 6c0ad34c486..d66de7ab698 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title _("Environments")
-- add_to_breadcrumbs(_("Pipelines"), project_pipelines_path(@project))
#environments-list-view{ data: { environments_data: environments_list_data,
"can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index b44ea89510b..c63c34c4ebb 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -9,7 +9,7 @@
spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' }
.dropdown
- %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.light sort:
- if @sort.present?
= sort_options_hash[@sort]
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index 1c50cfbde85..bd0ab2c19f2 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -10,7 +10,7 @@
.card-body
%pre
:preserve
- #{h(@project.import_error)}
+ #{h(@project.import_state.last_error)}
= form_for @project, url: project_import_path(@project), method: :post do |f|
= render "shared/import_form", f: f
diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml
index 3b0c828ccd1..422a3a22f87 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -1,4 +1,6 @@
- page_title import_in_progress_title
+- @no_container = true
+- @content_class = "limit-container-width" unless fluid_layout
.save-project-loader
.center
diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml
index b8ee4305142..b9d45e83032 100644
--- a/app/views/projects/labels/edit.html.haml
+++ b/app/views/projects/labels/edit.html.haml
@@ -1,4 +1,6 @@
- @no_container = true
+- add_to_breadcrumbs "Labels", project_labels_path(@project)
+- breadcrumb_title "Edit"
- page_title "Edit", @label.name, "Labels"
%div{ class: container_class }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 2c6484c2c99..56b06374d6d 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -5,7 +5,7 @@
- subscribed = params[:subscribed]
- labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present?
-- if @labels.present? && can_admin_label
+- if labels_or_filters && can_admin_label
- content_for(:header_content) do
.nav-controls
= link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new"
diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml
index 02f59f30a39..c6739231e36 100644
--- a/app/views/projects/labels/new.html.haml
+++ b/app/views/projects/labels/new.html.haml
@@ -1,5 +1,6 @@
- @no_container = true
-- breadcrumb_title "Labels"
+- add_to_breadcrumbs "Labels", project_labels_path(@project)
+- breadcrumb_title "New"
- page_title "New Label"
%div{ class: container_class }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 4ebb029e48b..cc9292b54d7 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -5,6 +5,7 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
+- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
= render "projects/merge_requests/mr_title"
@@ -67,6 +68,7 @@
noteable_data: serialize_issuable(@merge_request),
noteable_type: 'MergeRequest',
target_type: 'merge_request',
+ help_page_path: suggest_changes_help_path,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} }
#commits.commits.tab-pane
@@ -76,8 +78,10 @@
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
#js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
+ 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)} }
+ project_path: project_path(@merge_request.project),
+ changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } }
.mr-loading-status
= spinner
diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml
index af3f25c6a30..4006a468792 100644
--- a/app/views/projects/milestones/edit.html.haml
+++ b/app/views/projects/milestones/edit.html.haml
@@ -1,6 +1,9 @@
- @no_container = true
+- breadcrumb_title "Edit"
+- add_to_breadcrumbs "Milestones", project_milestones_path(@project)
- page_title "Edit", @milestone.title, "Milestones"
+
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml
index c301f517013..01cc951e8c2 100644
--- a/app/views/projects/milestones/new.html.haml
+++ b/app/views/projects/milestones/new.html.haml
@@ -1,5 +1,6 @@
- @no_container = true
-- breadcrumb_title "Milestones"
+- add_to_breadcrumbs "Milestones", project_milestones_path(@project)
+- breadcrumb_title "New"
- page_title "New Milestone"
%div{ class: container_class }
diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml
index 8dc042d87d1..293a2e3ebfe 100644
--- a/app/views/projects/mirrors/_authentication_method.html.haml
+++ b/app/views/projects/mirrors/_authentication_method.html.haml
@@ -8,14 +8,14 @@
= 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" }
+ {}, { class: "form-control js-mirror-auth-type qa-authentication-method" }
.form-group
.collapse.js-well-changing-auth
.changing-auth-method= icon('spinner spin lg')
.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'
+ = 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) }
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 2f9bd5b04b6..21b105e6f80 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -1,7 +1,7 @@
- expanded = Rails.env.test?
- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|')
-%section.settings.project-mirror-settings.js-mirror-settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded) }
+%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) }
.settings-header
%h4= _('Mirroring repositories')
%button.btn.js-settings-toggle
@@ -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', 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}):\/\/.+"
= render 'projects/mirrors/instructions'
@@ -32,7 +32,7 @@
= link_to icon('question-circle'), help_page_path('user/project/protected_branches')
.panel-footer
- = f.submit _('Mirror repository'), class: 'btn btn-success', name: :update_remote_mirror
+ = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
.panel.panel-default
.table-responsive
@@ -50,10 +50,10 @@
= render_if_exists 'projects/mirrors/table_pull_row'
- @project.remote_mirrors.each_with_index do |mirror, index|
- if mirror.enabled
- %tr
- %td= mirror.safe_url
+ %tr.qa-mirrored-repository-row
+ %td.qa-mirror-repository-url= mirror.safe_url
%td= _('Push')
- %td= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
+ %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')
diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml
index a2cce83bfab..b49f1d9315e 100644
--- a/app/views/projects/mirrors/_mirror_repos_form.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml
@@ -1,5 +1,5 @@
.form-group
= label_tag :mirror_direction, _('Mirror direction'), class: 'label-light'
- = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction', disabled: true
+ = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction qa-mirror-direction', disabled: true
= render partial: "projects/mirrors/mirror_repos_push", locals: { f: f }
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 10e3b01096a..a760d02c4c3 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -50,7 +50,7 @@
.project-template
.form-group
%div
- = render 'project_templates', f: f
+ = render 'project_templates', f: f, project: @project
.tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' }
- if import_sources_enabled?
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 2575efc0981..0f0114d513c 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -24,6 +24,38 @@
- if @pipeline.queued_duration
= "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
+ .well-segment
+ .icon-container
+ = sprite_icon('flag')
+ - if @pipeline.latest?
+ %span.js-pipeline-url-latest.badge.badge-success.has-tooltip{ title: _("Latest pipeline for this branch") }
+ latest
+ - if @pipeline.has_yaml_errors?
+ %span.js-pipeline-url-yaml.badge.badge-danger.has-tooltip{ title: @pipeline.yaml_errors }
+ yaml invalid
+ - if @pipeline.failure_reason?
+ %span.js-pipeline-url-failure.badge.badge-danger.has-tooltip{ title: @pipeline.failure_reason }
+ error
+ - if @pipeline.auto_devops_source?
+ - popover_title_text = _('This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>').html_safe
+ - popover_content_url = help_page_path('topics/autodevops/index.md')
+ - popover_content_text = _('Learn more about Auto DevOps')
+ %a.js-pipeline-url-autodevops.badge.badge-info.autodevops-badge{ href: "#", tabindex: "0", role: "button", data: { container: "body",
+ toggle: "popover",
+ placement: "top",
+ html: "true",
+ trigger: "focus",
+ title: "<div class='autodevops-title'>#{popover_title_text}</div>",
+ 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.stuck?
+ %span.js-pipeline-url-stuck.badge.badge-warning
+ stuck
+
.well-segment.branch-info
.icon-container.commit-icon
= custom_icon("icon_commit")
diff --git a/app/views/projects/project_members/_new_project_group.html.haml b/app/views/projects/project_members/_new_project_group.html.haml
index 74570769117..88e68f89024 100644
--- a/app/views/projects/project_members/_new_project_group.html.haml
+++ b/app/views/projects/project_members/_new_project_group.html.haml
@@ -10,7 +10,7 @@
= select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
- = link_to _("Read more"), help_page_path("user/permissions"), class: "vlink"
+ = link_to _("Read more"), help_page_path("user/permissions")
about role permissions
.form-group
= label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
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 5e21442bb60..1de7d9c6957 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -10,7 +10,7 @@
= select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
- = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
+ = link_to "Read more", help_page_path("user/permissions")
about role permissions
.form-group
.clearable-input
diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml
index 233c3adba0e..5b4d8927045 100644
--- a/app/views/projects/project_templates/_built_in_templates.html.haml
+++ b/app/views/projects/project_templates/_built_in_templates.html.haml
@@ -9,9 +9,9 @@
.text-muted
= template.description
.controls.d-flex.align-items-center
- %label.btn.btn-success.template-button.choose-template.append-right-10.append-bottom-0{ for: template.name }
+ %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } }
+ = _("Preview")
+ %label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } }
%span
= _("Use template")
- %a.btn.btn-default{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } }
- = _("Preview")
diff --git a/app/views/projects/releases/index.html.haml b/app/views/projects/releases/index.html.haml
new file mode 100644
index 00000000000..f01d4e826b9
--- /dev/null
+++ b/app/views/projects/releases/index.html.haml
@@ -0,0 +1,5 @@
+- @no_container = true
+- page_title _('Releases')
+
+%div{ class: container_class }
+ #js-releases-page{ data: { project_id: @project.id, illustration_path: image_path('illustrations/releases.svg'), documentation_path: help_page_path('user/project/releases') } }
diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml
new file mode 100644
index 00000000000..f650fa0f38f
--- /dev/null
+++ b/app/views/projects/serverless/functions/index.html.haml
@@ -0,0 +1,15 @@
+- @no_container = true
+- @content_class = "limit-container-width" unless fluid_layout
+- breadcrumb_title 'Serverless'
+- page_title 'Serverless'
+- 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') } }
+
+%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
+ .js-serverless-functions-notice
+ .flash-container
+
+ .top-area.adjust
+ .serverless-functions-table#js-serverless-functions
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 621b7922072..bb328f5344c 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -29,7 +29,7 @@
= 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
- = _("Per job. If a job passes this threshold, it will be marked as failed")
+ = _('If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like "1 hour". Values without specification represent seconds.')
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank'
%hr
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index c14e95a382c..cb3a035c49e 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -13,3 +13,4 @@
= render "projects/protected_tags/index"
= render @deploy_keys
= render "projects/deploy_tokens/index"
+= render "projects/cleanup/show"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index f29ce4f5c06..c87a084740b 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,7 +1,6 @@
- @no_container = true
- breadcrumb_title _("Details")
- @content_class = "limit-container-width" unless fluid_layout
-- show_auto_devops_callout = show_auto_devops_callout?(@project)
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
@@ -15,20 +14,11 @@
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
= render "projects/last_push"
-= render "home_panel"
-
-- if can?(current_user, :download_code, @project)
- %nav.project-stats{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
- .nav-links.scrolling-tabs.quick-links
- = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
- = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
+ = render "home_panel"
+ - if can?(current_user, :download_code, @project) && @project.repository_languages.present?
= repository_languages_bar(@project.repository_languages)
-%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- if @project.archived?
.text-warning.center.prepend-top-20
%p
@@ -41,4 +31,4 @@
= render 'shared/auto_devops_callout'
%div{ class: project_child_container_class(view_path) }
- = render view_path
+ = render view_path, is_project_overview: true
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index f495b4eaf30..da48cb207a4 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -6,7 +6,7 @@
= render 'shared/snippets/header'
.project-snippets
- %article.file-holder.snippet-file-content
+ %article.file-holder.snippet-file-content{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 37535370940..026bc44a05f 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -14,7 +14,7 @@
= 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 }
.dropdown
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} }
%span.light
= tags_sort_options_hash[@sort]
= icon('chevron-down')
diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index 52c6c7ec424..52c6c7ec424 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index 5e0523f0b96..889a13339fd 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -1,5 +1,5 @@
.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path }
- .table-holder
+ .table-holder.bordered-box
%table.table#tree-slider{ class: "table_#{@hex_path} tree-table qa-file-tree" }
%thead
%tr
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 601e3f25852..4e9a119ac66 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -20,7 +20,7 @@
- if can_collaborate || can_create_mr_from_fork
%li.breadcrumb-item
- %a.btn.add-to-tree{ addtotree_toggle_attributes }
+ %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?
@@ -30,7 +30,7 @@
%li.dropdown-header
#{ _('This directory') }
%li
- = link_to project_new_blob_path(@project, @id) do
+ = 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
@@ -85,4 +85,8 @@
= link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do
= _('Web IDE')
+ - if show_xcode_link?(@project)
+ .project-action-button.project-xcode.inline
+ = render "projects/buttons/xcode_link"
+
= render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/projects/wikis/_sidebar_wiki_page.html.haml b/app/views/projects/wikis/_sidebar_wiki_page.html.haml
index 2423ac6abce..769d869bd53 100644
--- a/app/views/projects/wikis/_sidebar_wiki_page.html.haml
+++ b/app/views/projects/wikis/_sidebar_wiki_page.html.haml
@@ -1,3 +1,3 @@
%li{ class: active_when(params[:id] == wiki_page.slug) }
= link_to project_wiki_path(@project, wiki_page) do
- = wiki_page.title.capitalize
+ = wiki_page.human_title
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml
index 80aa1500d53..26671a7b7d2 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/projects/wikis/edit.html.haml
@@ -1,5 +1,7 @@
- @content_class = "limit-container-width" unless fluid_layout
-- page_title _("Edit"), @page.title.capitalize, _("Wiki")
+- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, @page)
+- breadcrumb_title @page.persisted? ? _("Edit") : _("New")
+- page_title @page.persisted? ? _("Edit") : _("New"), @page.human_title, _("Wiki")
= wiki_page_errors(@error)
@@ -10,9 +12,9 @@
.nav-text
%h2.wiki-page-title
- if @page.persisted?
- = link_to @page.title.capitalize, project_wiki_path(@project, @page)
+ = link_to @page.human_title, project_wiki_path(@project, @page)
- else
- = @page.title.capitalize
+ = @page.human_title
%span.light
&middot;
- if @page.persisted?
@@ -28,7 +30,7 @@
= link_to project_wiki_history_path(@project, @page), class: "btn" do
= s_("Wiki|Page history")
- if can?(current_user, :admin_wiki, @project)
- #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.title.capitalize } }
+ #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.human_title } }
= render 'form', uploads_path: wiki_attachment_upload_url
diff --git a/app/views/projects/wikis/history.html.haml b/app/views/projects/wikis/history.html.haml
index 969a1677d9a..c5fbeeafa54 100644
--- a/app/views/projects/wikis/history.html.haml
+++ b/app/views/projects/wikis/history.html.haml
@@ -1,4 +1,4 @@
-- page_title _("History"), @page.title.capitalize, _("Wiki")
+- page_title _("History"), @page.human_title, _("Wiki")
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
@@ -6,7 +6,7 @@
.nav-text
%h2.wiki-page-title
- = link_to @page.title.capitalize, project_wiki_path(@project, @page)
+ = link_to @page.human_title, project_wiki_path(@project, @page)
%span.light
&middot;
= _("History")
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index fbf248c2058..4d5fd55364c 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -1,7 +1,7 @@
- @content_class = "limit-container-width" unless fluid_layout
-- breadcrumb_title @page.title.capitalize
+- breadcrumb_title @page.human_title
- wiki_breadcrumb_dropdown_links(@page.slug)
-- page_title @page.title.capitalize, _("Wiki")
+- page_title @page.human_title, _("Wiki")
- add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project)
.wiki-page-header.has-sidebar-toggle
@@ -9,7 +9,7 @@
= icon('angle-double-left')
.nav-text
- %h2.wiki-page-title= @page.title.capitalize
+ %h2.wiki-page-title= @page.human_title
%span.wiki-last-edit-by
- if @page.last_version
= (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe
@@ -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
+ .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render_wiki_content(@page, legacy_render_context(params))
= render 'sidebar'
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index a8d4d4af93a..2a602095845 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,7 +1,7 @@
- project = find_project_for_result_blob(blob)
- return unless project
-- file_name, blob = parse_search_result(blob)
-- blob_link = project_blob_path(project, tree_join(blob.ref, file_name))
+- blob = parse_search_result(blob)
+- blob_link = project_blob_path(project, tree_join(blob.ref, blob.filename))
-= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: file_name, blob_link: blob_link }
+= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: blob.filename, blob_link: blob_link }
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index 4346217c230..389e4cc75b9 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)
-- file_name, wiki_blob = parse_search_result(wiki_blob)
+- wiki_blob = parse_search_result(wiki_blob)
- wiki_blob_link = project_wiki_path(project, wiki_blob.basename)
-= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: file_name, blob_link: wiki_blob_link }
+= 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/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index 6c4607b2f16..0d0a3c1aa64 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -1,6 +1,6 @@
- if show_auto_devops_implicitly_enabled_banner?(project)
.auto-devops-implicitly-enabled-banner.alert.alert-warning
- - more_information_link = link_to _('More information'), 'https://docs.gitlab.com/ee/topics/autodevops/', class: 'alert-link'
+ - more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link'
- auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link }
= auto_devops_message.html_safe
.alert-link-group
diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml
index a6ba3b59365..9a1db831ad3 100644
--- a/app/views/shared/_milestones_sort_dropdown.html.haml
+++ b/app/views/shared/_milestones_sort_dropdown.html.haml
@@ -1,5 +1,5 @@
.dropdown.inline.prepend-left-10
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
%span.light
- if @sort.present?
= milestone_sort_options_hash[@sort]
@@ -8,15 +8,15 @@
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
%li
- = link_to page_filter_path(sort: sort_value_due_date_soon, label: true) do
+ = link_to page_filter_path(sort: sort_value_due_date_soon) do
= sort_title_due_date_soon
- = link_to page_filter_path(sort: sort_value_due_date_later, label: true) do
+ = link_to page_filter_path(sort: sort_value_due_date_later) do
= sort_title_due_date_later
- = link_to page_filter_path(sort: sort_value_start_date_soon, label: true) do
+ = link_to page_filter_path(sort: sort_value_start_date_soon) do
= sort_title_start_date_soon
- = link_to page_filter_path(sort: sort_value_start_date_later, label: true) do
+ = link_to page_filter_path(sort: sort_value_start_date_later) do
= sort_title_start_date_later
- = link_to page_filter_path(sort: sort_value_name, label: true) do
+ = link_to page_filter_path(sort: sort_value_name) do
= sort_title_name_asc
- = link_to page_filter_path(sort: sort_value_name_desc, label: true) do
+ = link_to page_filter_path(sort: sort_value_name_desc) do
= sort_title_name_desc
diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml
index 998985cabe1..b43662947a8 100644
--- a/app/views/shared/_mobile_clone_panel.html.haml
+++ b/app/views/shared/_mobile_clone_panel.html.haml
@@ -1,13 +1,13 @@
- project = project || @project
- ssh_copy_label = _("Copy SSH clone URL")
-- http_copy_label = _("Copy HTTPS clone URL")
+- http_copy_label = _('Copy %{http_label} clone URL') % { http_label: gitlab_config.protocol.upcase }
-.btn-group.mobile-git-clone.js-mobile-git-clone
- = clipboard_button(button_text: default_clone_label, target: '#project_clone', hide_button_icon: true, class: "input-group-text clone-dropdown-btn js-clone-dropdown-label btn btn-default")
- %button.btn.btn-default.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } }
- = icon("caret-down", class: "dropdown-btn-icon")
+.btn-group.mobile-git-clone.js-mobile-git-clone.btn-block
+ = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label")
+ %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } }
+ = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon")
%ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } }
%li
- = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' })
+ = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true)
%li
= dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' })
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index f32cff18fa8..721a2af8069 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -2,5 +2,5 @@
%button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') }
= icon("refresh spin")
- else
- = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
+ = 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/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml
deleted file mode 100644
index be6d4f1c32b..00000000000
--- a/app/views/shared/_sort_dropdown.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- sorted_by = sort_options_hash[@sort]
-- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
-
-.dropdown.inline.prepend-left-10
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
- = sorted_by
- = 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, label: true), sorted_by)
- = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by)
- = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sorted_by)
- = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sorted_by)
- = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sorted_by) if viewing_issues
- = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sorted_by)
- = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sorted_by)
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 2237b93a10b..1ae6d1f5ee3 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -9,7 +9,7 @@
- default_sort_by = sort_value_recently_created
.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
- %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label
= options_hash[default_sort_by]
= icon('chevron-down')
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
new file mode 100644
index 00000000000..2ca4657851c
--- /dev/null
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -0,0 +1,32 @@
+.issues-filters
+ .issues-details-filters.row-content-block.second-block
+ = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
+ - if params[:search].present?
+ = hidden_field_tag :search, params[:search]
+ .issues-other-filters
+ .filter-item.inline
+ - if params[:author_id].present?
+ = hidden_field_tag(:author_id, params[:author_id])
+ = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit",
+ placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user&.username, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:author_id], field_name: "author_id", default_label: "Author" } })
+
+ .filter-item.inline
+ - if params[:assignee_id].present?
+ = hidden_field_tag(:assignee_id, params[:assignee_id])
+ = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
+ placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } })
+
+ .filter-item.inline.milestone-filter
+ = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true
+
+ .filter-item.inline.labels-filter
+ = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
+
+ - unless @no_filters_set
+ .float-right
+ = render 'shared/issuable/sort_dropdown'
+
+ - has_labels = @labels && @labels.any?
+ .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
+ - if has_labels
+ = render 'shared/labels_row', labels: @labels
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 1618655182c..c6a391ae563 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -17,7 +17,7 @@
= render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
-- if Feature.enabled?(:issue_suggestions) && Feature.enabled?(:graphql)
+- if Gitlab::Graphql.enabled?
#js-suggestions{ data: { project_path: @project.full_path } }
= render 'shared/form_elements/description', model: issuable, form: form, project: project
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index 157637dbd11..71123740ee4 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -4,20 +4,20 @@
%ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs
%li{ class: active_when(params[:state] == 'opened') }>
- = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do
+ = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do
#{issuables_state_counter_text(type, :opened, display_count)}
- if type == :merge_requests
%li{ class: active_when(params[:state] == 'merged') }>
- = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do
+ = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do
#{issuables_state_counter_text(type, :merged, display_count)}
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do
+ = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do
#{issuables_state_counter_text(type, :closed, display_count)}
- else
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do
+ = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do
#{issuables_state_counter_text(type, :closed, display_count)}
= render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count)
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 6939aba6896..46634693067 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -2,7 +2,6 @@
- board = local_assigns.fetch(:board, nil)
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
- user_can_admin_list = board && can?(current_user, :admin_list, board.parent)
-- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true)
.issues-filters
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
@@ -95,7 +94,10 @@
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link{ type: 'button' }
- = _('No Label')
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
@@ -139,5 +141,5 @@
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-toggle-focus-btn
- - elsif show_sorting_dropdown
- = render 'shared/sort_dropdown'
+ - elsif type != :boards_modal
+ = render 'shared/issuable/sort_dropdown'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 5295e656ab0..9eecfa39390 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -16,7 +16,7 @@
- if current_user
.block.todo.hide-expanded
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
- .block.assignee
+ .block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
new file mode 100644
index 00000000000..b6ea9185b10
--- /dev/null
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -0,0 +1,21 @@
+- sort_value = @sort
+- sort_title = issuable_sort_option_title(sort_value)
+- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
+
+.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' }
+ = 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)
+ = 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/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
index 3521f71f409..60c34094108 100644
--- a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
@@ -5,4 +5,4 @@
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
- = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
+ = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
diff --git a/app/views/shared/issuable/nav_links/_all.html.haml b/app/views/shared/issuable/nav_links/_all.html.haml
index d7ad7090a45..c92a50bcb70 100644
--- a/app/views/shared/issuable/nav_links/_all.html.haml
+++ b/app/views/shared/issuable/nav_links/_all.html.haml
@@ -2,5 +2,5 @@
- counter = local_assigns.fetch(:counter)
%li{ class: active_when(params[:state] == 'all') }>
- = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do
+ = link_to page_filter_path(state: 'all'), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do
#{counter}
diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml
index 8a7d037e15b..07e96eea062 100644
--- a/app/views/shared/labels/_sort_dropdown.html.haml
+++ b/app/views/shared/labels/_sort_dropdown.html.haml
@@ -1,9 +1,9 @@
- sort_title = label_sort_options_hash[@sort] || sort_title_name_desc
.dropdown.inline
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
= sort_title
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
%li
- label_sort_options_hash.each do |value, title|
- = sortable_item(title, page_filter_path(sort: value, label: true, subscribed: params[:subscribed]), sort_title)
+ = sortable_item(title, page_filter_path(sort: value), sort_title)
diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml
new file mode 100644
index 00000000000..f7227b9101e
--- /dev/null
+++ b/app/views/shared/members/_access_request_links.html.haml
@@ -0,0 +1,17 @@
+- model_name = source.model_name.to_s.downcase
+
+- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord
+ - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
+ = link_to link_text, polymorphic_path([:leave, source, :members]),
+ method: :delete,
+ data: { confirm: leave_confirmation_message(source) },
+ class: 'access-request-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,
+ data: { confirm: remove_member_message(requester) },
+ class: 'access-request-link'
+- elsif source.request_access_enabled && can?(current_user, :request_access, source)
+ = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
+ method: :post,
+ class: 'access-request-link'
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index a7fd75d85d7..6b3841ebbc4 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -75,7 +75,7 @@
= dropdown_title(_("Change permissions"))
.dropdown-content
%ul
- - member.access_level_roles.each do |role, role_id|
+ - member.valid_level_roles.each do |role, role_id|
%li
= link_to role, "javascript:void(0)",
class: ("is-active" if member.access_level == role_id),
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 3dd2842be4f..ed7fefba56d 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -35,8 +35,8 @@
.col-sm-2
.milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end
- if @project
- - if can?(current_user, :admin_milestone, milestone.project) and milestone.active?
- - if @project.group
+ - if can_admin_project_milestones? and milestone.active?
+ - if can_admin_group_milestones?
%button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'),
disabled: true,
type: 'button',
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index bc918430823..e125d7f108a 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -5,7 +5,7 @@
- note_editable = can?(current_user, :admin_note, note)
- note_counter = local_assigns.fetch(:note_counter, 0)
-%li.timeline-entry.note-wrapper.outlined{ id: dom_id(note),
+%li.timeline-entry.note-wrapper{ id: dom_id(note),
class: ["note", "note-row-#{note.id}", ('system-note' if note.system)],
data: { author_id: note.author.id,
editable: note_editable,
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index f6c7ca70ebd..30860988bbb 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -1,3 +1,5 @@
+- btn_class = local_assigns.fetch(:btn_class, nil)
+
- if notification_setting
.js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline
= form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f|
@@ -6,14 +8,14 @@
.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", "aria-label" => "Notification setting: #{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#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", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ %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")
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 06eb3d03e31..15c29e14cc0 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -2,24 +2,29 @@
- avatar = true unless local_assigns[:avatar] == false
- use_creator_avatar = false unless local_assigns[:use_creator_avatar] == true
- stars = true unless local_assigns[:stars] == false
-- forks = false unless local_assigns[:forks] == true
+- forks = true unless local_assigns[:forks] == false
+- merge_requests = true unless local_assigns[:merge_requests] == false
+- issues = true unless local_assigns[:issues] == false
+- pipeline_status = true unless local_assigns[:pipeline_status] == false
- ci = false unless local_assigns[:ci] == true
- skip_namespace = false unless local_assigns[:skip_namespace] == true
- user = local_assigns[:user]
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true
- remote = false unless local_assigns[:remote] == true
- skip_pagination = false unless local_assigns[:skip_pagination] == true
+- compact_mode = false unless local_assigns[:compact_mode] == true
+- css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}"
.js-projects-list-holder
- if any_projects?(projects)
- load_pipeline_status(projects)
-
- %ul.projects-list
+ %ul.projects-list{ class: css_classes }
- projects.each_with_index do |project, i|
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,
avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar,
- forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user
+ forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: merge_requests,
+ issues: issues, pipeline_status: pipeline_status, compact_mode: compact_mode
- if @private_forks_count && @private_forks_count > 0
%li.project-row.private-forks-notice
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index aba790e1217..9dde77fccef 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -1,62 +1,107 @@
- avatar = true unless local_assigns[:avatar] == false
- stars = true unless local_assigns[:stars] == false
-- forks = false unless local_assigns[:forks] == true
+- forks = true unless local_assigns[:forks] == false
+- merge_requests = true unless local_assigns[:merge_requests] == false
+- issues = true unless local_assigns[:issues] == false
+- pipeline_status = true unless local_assigns[:pipeline_status] == false
- skip_namespace = false unless local_assigns[:skip_namespace] == true
- access = max_project_member_access(project)
-- css_class = '' unless local_assigns[:css_class]
+- compact_mode = false unless local_assigns[:compact_mode] == true
- 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)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
+- css_details_class = compact_mode ? "d-flex flex-column flex-sm-row flex-md-row align-items-sm-center" : "align-items-center flex-md-fill flex-lg-column d-sm-flex d-lg-block"
+- css_controls_class = compact_mode ? "" : "align-items-md-end align-items-lg-center flex-lg-row"
-%li.project-row{ class: css_class }
+%li.project-row.d-flex{ class: css_class }
= cache(cache_key) do
- if avatar
- .avatar-container.s40
+ .avatar-container.s64.flex-grow-0.flex-shrink-0
= link_to project_path(project), class: dom_class(project) do
- if project.creator && use_creator_avatar
- = image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:''
+ = image_tag avatar_icon_for_user(project.creator, 64), class: "avatar s65", alt:''
- else
- = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
- .project-details
- %h3.prepend-top-0.append-bottom-0
- = link_to project_path(project), class: 'text-plain' do
- %span.project-full-name><
- %span.namespace-name
- - if project.namespace && !skip_namespace
- = project.namespace.human_name
- \/
- %span.project-name<
- = project.name
-
- - if access&.nonzero?
- -# haml-lint:disable UnnecessaryStringOutput
- = ' ' # prevent haml from eating the space between elements
- %span.user-access-role= Gitlab::Access.human_access(access)
-
- - if show_last_commit_as_description
- .description.prepend-top-5
- = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
- - elsif project.description.present?
- .description.prepend-top-5
- = markdown_field(project, :description)
-
- .controls
- .prepend-top-0
- - if project.archived
- %span.prepend-left-10.badge.badge-warning archived
- - if can?(current_user, :read_cross_project) && project.pipeline_status.has_status?
- %span.prepend-left-10
- = render_project_pipeline_status(project.pipeline_status)
- - if forks
- %span.prepend-left-10
- = sprite_icon('fork', size: 12)
- = number_with_delimiter(project.forks_count)
- - if stars
- %span.prepend-left-10
- = icon('star')
- = number_with_delimiter(project.star_count)
- %span.prepend-left-10.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project) }
- = visibility_level_icon(project.visibility_level, fw: true)
- .prepend-top-0
- updated #{updated_tooltip}
+ = project_icon(project, alt: '', class: 'avatar project-avatar s64', width: 64, height: 64)
+ .project-details.flex-sm-fill{ class: css_details_class }
+ .flex-wrapper.flex-fill
+ .d-flex.align-items-center.flex-wrap
+ %h2.d-flex.prepend-top-8
+ = link_to project_path(project), class: 'text-plain' do
+ %span.project-full-name.append-right-8><
+ %span.namespace-name
+ - if project.namespace && !skip_namespace
+ = project.namespace.human_name
+ \/
+ %span.project-name<
+ = project.name
+
+ %span.metadata-info.visibility-icon.append-right-10.prepend-top-8.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
+ = visibility_level_icon(project.visibility_level, fw: true)
+
+ - if explore_projects_tab? && project.repository.license
+ %span.metadata-info.d-inline-flex.align-items-center.append-right-10.prepend-top-8
+ = sprite_icon('scale', size: 14, css_class: 'append-right-4')
+ = project.repository.license.name
+
+ - if !explore_projects_tab? && access&.nonzero?
+ -# haml-lint:disable UnnecessaryStringOutput
+ = ' ' # prevent haml from eating the space between elements
+ .metadata-info.prepend-top-8
+ %span.user-access-role.d-block= Gitlab::Access.human_access(access)
+
+ - if show_last_commit_as_description
+ .description.d-none.d-sm-block.prepend-top-8.append-right-default
+ = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
+ - elsif project.description.present?
+ .description.d-none.d-sm-block.prepend-top-8.append-right-default
+ = markdown_field(project, :description)
+
+ .controls.d-flex.flex-row.flex-sm-column.flex-md-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class }
+ .icon-container.d-flex.align-items-center
+ - if project.archived
+ %span.d-flex.icon-wrapper.badge.badge-warning archived
+ - if stars
+ %span.d-flex.align-items-center.icon-wrapper.stars.has-tooltip{ data: { container: 'body', placement: 'top' }, title: _('Stars') }
+ = sprite_icon('star', size: 14, css_class: 'append-right-4')
+ = number_with_delimiter(project.star_count)
+ - if forks
+ = link_to project_forks_path(project),
+ class: "align-items-center icon-wrapper forks has-tooltip",
+ title: _('Forks'), data: { container: 'body', placement: 'top' } do
+ = sprite_icon('fork', size: 14, css_class: 'append-right-4')
+ = number_with_delimiter(project.forks_count)
+ - if show_merge_request_count?(merge_requests, compact_mode)
+ = link_to project_merge_requests_path(project),
+ class: "d-none d-lg-flex align-items-center icon-wrapper merge-requests has-tooltip",
+ title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do
+ = sprite_icon('git-merge', size: 14, css_class: 'append-right-4')
+ = number_with_delimiter(project.open_merge_requests_count)
+ - if show_issue_count?(issues, compact_mode)
+ = link_to project_issues_path(project),
+ class: "d-none d-lg-flex align-items-center icon-wrapper issues has-tooltip",
+ title: _('Issues'), data: { container: 'body', placement: 'top' } do
+ = 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?
+ %span.icon-wrapper.pipeline-status
+ = render_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top')
+ .updated-note
+ %span Updated #{updated_tooltip}
+
+ .d-none.d-lg-flex.align-item-stretch
+ - unless compact_mode
+ - if current_user
+ %button.star-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(project, :json) } }
+ - if current_user.starred?(project)
+ = sprite_icon('star', { css_class: 'icon' })
+ %span.starred= s_('ProjectOverview|Unstar')
+ - else
+ = sprite_icon('star-o', { css_class: 'icon' })
+ %span= s_('ProjectOverview|Star')
+
+ - else
+ = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
+ = sprite_icon('star-o', { css_class: 'icon' })
+ %span= s_('ProjectOverview|Star')
diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml
index daf08d9bb2c..559b5aa9c1e 100644
--- a/app/views/shared/runners/_form.html.haml
+++ b/app/views/shared/runners/_form.html.haml
@@ -45,7 +45,7 @@
= _('Maximum job timeout')
.col-sm-10
= f.text_field :maximum_timeout_human_readable, class: 'form-control'
- .form-text.text-muted= _('This timeout will take precedence when lower than Project-defined timeout')
+ .form-text.text-muted= _('This timeout will take precedence when lower than project-defined timeout and accepts a human readable time input language like "1 hour". Values without specification represent seconds.')
.form-group.row
= label_tag :tag_list, class: 'col-form-label col-sm-2' do
= _('Tags')
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index 0ce13ee7a53..ef8664e6f47 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -3,31 +3,31 @@
.d-none.d-sm-block
- if can?(current_user, :update_personal_snippet, @snippet)
= link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do
- Edit
+ = _("Edit")
- if can?(current_user, :admin_personal_snippet, @snippet)
- = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do
- Delete
- = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-success", title: "New snippet" do
- New 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
+ = _("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'
+ = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
.d-block.d-sm-none.dropdown
%button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
- Options
+ = _("Options")
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
%ul
%li
- = link_to new_snippet_path, title: "New snippet" do
- New snippet
+ = link_to new_snippet_path, title: _("New snippet") do
+ = _("New snippet")
- if can?(current_user, :admin_personal_snippet, @snippet)
%li
- = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do
- Delete
+ = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do
+ = _("Delete")
- if can?(current_user, :update_personal_snippet, @snippet)
%li
= link_to edit_snippet_path(@snippet) do
- Edit
+ = _("Edit")
- if @snippet.submittable_as_spam_by?(current_user)
%li
- = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post
+ = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index dfea8b40bd8..69d41f8fe5e 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -5,6 +5,6 @@
= render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project }
- if @snippets.empty?
%li
- .nothing-here-block Nothing here.
+ .nothing-here-block= _("Nothing here.")
= paginate @snippets, theme: 'gitlab'
diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml
index dc4b0fd9ba0..c312226dd6c 100644
--- a/app/views/snippets/_snippets_scope_menu.html.haml
+++ b/app/views/snippets/_snippets_scope_menu.html.haml
@@ -4,7 +4,7 @@
.nav-links.snippet-scope-menu.mobile-separator.nav.nav-tabs
%li{ class: active_when(params[:scope].nil?) }
= link_to subject_snippets_path(subject) do
- All
+ = _("All")
%span.badge.badge-pill
- if include_private
= subject.snippets.count
@@ -14,18 +14,18 @@
- if include_private
%li{ class: active_when(params[:scope] == "are_private") }
= link_to subject_snippets_path(subject, scope: 'are_private') do
- Private
+ = _("Private")
%span.badge.badge-pill
= subject.snippets.are_private.count
%li{ class: active_when(params[:scope] == "are_internal") }
= link_to subject_snippets_path(subject, scope: 'are_internal') do
- Internal
+ = _("Internal")
%span.badge.badge-pill
= subject.snippets.are_internal.count
%li{ class: active_when(params[:scope] == "are_public") }
= link_to subject_snippets_path(subject, scope: 'are_public') do
- Public
+ = _("Public")
%span.badge.badge-pill
= subject.snippets.are_public.count
diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml
index 18ebeb78f87..ebc6c0a2605 100644
--- a/app/views/snippets/edit.html.haml
+++ b/app/views/snippets/edit.html.haml
@@ -1,5 +1,6 @@
-- page_title "Edit", "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
+- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
+
%h3.page-title
- Edit Snippet
+ = _("Edit Snippet")
%hr
= render 'shared/snippets/form', url: snippet_path(@snippet)
diff --git a/app/views/snippets/index.html.haml b/app/views/snippets/index.html.haml
index 9b4a7dbe68d..4f418e2381f 100644
--- a/app/views/snippets/index.html.haml
+++ b/app/views/snippets/index.html.haml
@@ -1,13 +1,13 @@
-- page_title "By #{@user.name}", "Snippets"
+- page_title _("By %{user_name}") % { user_name: @user.name }, _("Snippets")
%ol.breadcrumb
%li.breadcrumb-item
= link_to snippets_path do
- Snippets
+ = _("Snippets")
%li.breadcrumb-item
= @user.name
.float-right.d-none.d-sm-block
= link_to user_path(@user) do
- #{@user.name} profile page
+ = _("%{user_name} profile page") % { user_name: @user.name }
= render 'snippets'
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index 6bc748d346e..114c777bdc2 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -1,6 +1,6 @@
- @hide_top_links = true
- @hide_breadcrumbs = true
-- page_title "New Snippet"
+- page_title _("New Snippet")
.page-title-holder
%h1.page-title= _('New Snippet')
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index 220ba2b49e6..01b95145937 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -1,7 +1,7 @@
- if current_user
- if note.emoji_awardable?
.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
+ = 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')
@@ -9,7 +9,7 @@
- if note_editable
.note-actions-item
- = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
+ = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
%span.link-highlight
= custom_icon('icon_pencil')
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 578327883e5..36b4e00e8d5 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -1,8 +1,8 @@
- @hide_top_links = true
- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
-- add_to_breadcrumbs "Snippets", dashboard_snippets_path
+- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path
- breadcrumb_title @snippet.to_reference
-- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
+- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
= render 'shared/snippets/header'
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index b5bc1180290..d22905ecc93 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -9,22 +9,24 @@
.col-md-12.col-lg-6
- if can?(current_user, :read_cross_project)
.activities-block
+ .append-right-5
+ .prepend-top-16
+ .d-flex.align-items-center.border-bottom
+ %h4.flex-grow
+ = s_('UserProfile|Activity')
+ = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
+ .overview-content-list{ data: { href: user_path } }
+ .center.light.loading
+ = spinner nil, true
+
+ .col-md-12.col-lg-6
+ .projects-block
+ .prepend-left-5
.prepend-top-16
.d-flex.align-items-center.border-bottom
%h4.flex-grow
- = s_('UserProfile|Activity')
- = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
- .overview-content-list{ data: { href: user_path } }
+ = s_('UserProfile|Personal projects')
+ = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
+ .overview-content-list{ data: { href: user_projects_path } }
.center.light.loading
= spinner nil, true
-
- .col-md-12.col-lg-6
- .projects-block
- .prepend-top-16
- .d-flex.align-items-center.border-bottom
- %h4.flex-grow
- = s_('UserProfile|Personal projects')
- = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
- .overview-content-list{ data: { href: user_projects_path } }
- .center.light.loading
- = spinner nil, true
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index 938cb579e9f..01acbf8eadd 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -7,7 +7,7 @@
%li
%span.light
%i.fa.fa-clock-o
- = event.created_at.strftime('%-I:%M%P')
+ = event.created_at.to_time.in_time_zone.strftime('%-I:%M%P')
- if event.visible_to_user?(current_user)
- if event.push?
#{event.action_name} #{event.ref_type}
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index d11476738e4..dd2cd36eac2 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -31,12 +31,12 @@
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('users')
- .profile-header
+ .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
- .user-info.prepend-left-default.append-right-default
+ .user-info
.cover-title
= @user.name
@@ -81,10 +81,10 @@
= icon('briefcase')
= @user.organization
- - if @user.bio.present?
- .cover-desc
- %p.profile-user-bio
- = @user.bio
+ - if @user.bio.present?
+ .cover-desc
+ %p.profile-user-bio
+ = @user.bio
- unless profile_tabs.empty?
.scrolling-tabs-container
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index c0b410472eb..f9928362290 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -10,7 +10,6 @@
- cronjob:prune_old_events
- cronjob:remove_expired_group_links
- cronjob:remove_expired_members
-- cronjob:remove_old_web_hook_logs
- cronjob:remove_unreferenced_lfs_objects
- cronjob:repository_archive_cache
- cronjob:repository_check_dispatch
@@ -29,6 +28,7 @@
- gcp_cluster:wait_for_cluster_creation
- gcp_cluster:cluster_wait_for_ingress_ip_address
- gcp_cluster:cluster_platform_configure
+- gcp_cluster:cluster_project_configure
- github_import_advance_stage
- github_importer:github_import_import_diff_note
@@ -85,6 +85,11 @@
- todos_destroyer:todos_destroyer_project_private
- todos_destroyer:todos_destroyer_private_features
+- object_pool:object_pool_create
+- object_pool:object_pool_schedule_join
+- object_pool:object_pool_join
+- object_pool:object_pool_destroy
+
- default
- mailers # ActionMailer::DeliveryJob.queue_name
@@ -120,6 +125,7 @@
- propagate_service_template
- reactive_caching
- rebase
+- remote_mirror_notification
- repository_fork
- repository_import
- repository_remove_remote
@@ -132,3 +138,5 @@
- create_note_diff_file
- delete_diff_files
- detect_repository_languages
+- repository_cleanup
+- delete_stored_files
diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb
index c1283e9b2fc..4a9becf0ca7 100644
--- a/app/workers/archive_trace_worker.rb
+++ b/app/workers/archive_trace_worker.rb
@@ -7,7 +7,7 @@ class ArchiveTraceWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(job_id)
Ci::Build.without_archived_trace.find_by(id: job_id).try do |job|
- job.trace.archive!
+ Ci::ArchiveTraceService.new.execute(job)
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb
index 7443aad1380..f65ff239866 100644
--- a/app/workers/ci/archive_traces_cron_worker.rb
+++ b/app/workers/ci/archive_traces_cron_worker.rb
@@ -11,21 +11,9 @@ module Ci
# This could happen when ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL
# More details in https://gitlab.com/gitlab-org/gitlab-ce/issues/36791
Ci::Build.finished.with_live_trace.find_each(batch_size: 100) do |build|
- begin
- build.trace.archive!
- rescue ::Gitlab::Ci::Trace::AlreadyArchivedError
- rescue => e
- failed_archive_counter.increment
- Rails.logger.error "Failed to archive stale live trace. id: #{build.id} message: #{e.message}"
- end
+ Ci::ArchiveTraceService.new.execute(build)
end
end
# rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def failed_archive_counter
- @failed_archive_counter ||= Gitlab::Metrics.counter(:job_trace_archive_failed_total, "Counter of failed attempts of traces archiving")
- end
end
end
diff --git a/app/workers/cluster_platform_configure_worker.rb b/app/workers/cluster_platform_configure_worker.rb
index 8f3689f0166..aa7570caa79 100644
--- a/app/workers/cluster_platform_configure_worker.rb
+++ b/app/workers/cluster_platform_configure_worker.rb
@@ -6,17 +6,7 @@ class ClusterPlatformConfigureWorker
def perform(cluster_id)
Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
- next unless cluster.cluster_project
-
- kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace(cluster.cluster_project)
-
- Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new(
- cluster: cluster,
- kubernetes_namespace: kubernetes_namespace
- ).execute
+ Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster)
end
-
- rescue ::Kubeclient::HttpError => err
- Rails.logger.error "Failed to create/update Kubernetes namespace for cluster_id: #{cluster_id} with error: #{err.message}"
end
end
diff --git a/app/workers/cluster_project_configure_worker.rb b/app/workers/cluster_project_configure_worker.rb
new file mode 100644
index 00000000000..497e57c0d0b
--- /dev/null
+++ b/app/workers/cluster_project_configure_worker.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ClusterProjectConfigureWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ def perform(project_id)
+ project = Project.find(project_id)
+
+ ::Clusters::RefreshService.create_or_update_namespaces_for_project(project)
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb
index 59e6bc2c97d..e2dee315cde 100644
--- a/app/workers/concerns/gitlab/github_import/stage_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb
@@ -24,7 +24,7 @@ module Gitlab
def find_project(id)
# If the project has been marked as failed we want to bail out
# automatically.
- Project.import_started.find_by(id: id)
+ Project.joins_import_state.where(import_state: { status: :started }).find_by(id: id)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/concerns/object_pool_queue.rb b/app/workers/concerns/object_pool_queue.rb
new file mode 100644
index 00000000000..5b648df9c72
--- /dev/null
+++ b/app/workers/concerns/object_pool_queue.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+##
+# Concern for setting Sidekiq settings for the various ObjectPool queues
+#
+module ObjectPoolQueue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :object_pool
+ end
+end
diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb
index 22bdf441d6b..2baf768bfd1 100644
--- a/app/workers/concerns/project_import_options.rb
+++ b/app/workers/concerns/project_import_options.rb
@@ -18,7 +18,7 @@ module ProjectImportOptions
"import"
end
- project.mark_import_as_failed("Every #{action} attempt has failed: #{job['error_message']}. Please try again.")
+ project.import_state.mark_as_failed(_("Every %{action} attempt has failed: %{job_error_message}. Please try again.") % { action: action, job_error_message: job['error_message'] })
Sidekiq.logger.warn "Failed #{job['class']} with #{job['args']}: #{job['error_message']}"
end
end
diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb
index 46a133db2a1..4462bc51a24 100644
--- a/app/workers/concerns/project_start_import.rb
+++ b/app/workers/concerns/project_start_import.rb
@@ -2,11 +2,11 @@
# Used in EE by mirroring
module ProjectStartImport
- def start(project)
- if project.import_started? && project.import_jid == self.jid
+ def start(import_state)
+ if import_state.started? && import_state.jid == self.jid
return true
end
- project.import_start
+ import_state.start
end
end
diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb
new file mode 100644
index 00000000000..ff7931849d8
--- /dev/null
+++ b/app/workers/delete_stored_files_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class DeleteStoredFilesWorker
+ include ApplicationWorker
+
+ def perform(class_name, keys)
+ klass = begin
+ class_name.constantize
+ rescue NameError
+ nil
+ end
+
+ unless klass
+ message = "Unknown class '#{class_name}'"
+ logger.error(message)
+ Gitlab::Sentry.track_exception(RuntimeError.new(message))
+ return
+ end
+
+ klass.new(logger: logger).delete_keys(keys)
+ end
+end
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index 2d381c6fd6c..d3628b23189 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -28,6 +28,8 @@ class GitGarbageCollectWorker
# 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
+
# In case pack files are deleted, release libgit2 cache and open file
# descriptors ASAP instead of waiting for Ruby garbage collection
project.cleanup
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index 2b49860025a..0b3437a8a33 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -31,7 +31,7 @@ module Gitlab
# next_stage - The name of the next stage to start when all jobs have been
# completed.
def perform(project_id, waiters, next_stage)
- return unless (project = find_project(project_id))
+ return unless import_state = find_import_state(project_id)
new_waiters = wait_for_jobs(waiters)
@@ -41,7 +41,7 @@ module Gitlab
# the pressure on Redis. We _only_ do this once all jobs are done so
# we don't get stuck forever if one or more jobs failed to notify the
# JobWaiter.
- project.refresh_import_jid_expiration
+ import_state.refresh_jid_expiration
STAGES.fetch(next_stage.to_sym).perform_async(project_id)
else
@@ -64,11 +64,8 @@ module Gitlab
end
# rubocop: disable CodeReuse/ActiveRecord
- def find_project(id)
- # TODO: Only select the JID
- # This is due to the fact that the JID could be present in either the project record or
- # its associated import_state record
- Project.import_started.find_by(id: id)
+ def find_import_state(project_id)
+ ProjectImportState.select(:jid).with_status(:started).find_by(project_id: project_id)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb
index 65473026b4c..76723e4a61f 100644
--- a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb
+++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb
@@ -16,12 +16,13 @@ module Gitlab
# project_id - The ID of the project that is being imported.
# check_job_id - The ID of the job for which to check the status.
def perform(project_id, check_job_id)
- return unless (project = find_project(project_id))
+ import_state = find_import_state(project_id)
+ return unless import_state
if SidekiqStatus.running?(check_job_id)
# As long as the repository is being cloned we want to keep refreshing
# the import JID status.
- project.refresh_import_jid_expiration
+ import_state.refresh_jid_expiration
self.class.perform_in_the_future(project_id, check_job_id)
end
@@ -31,11 +32,10 @@ module Gitlab
end
# rubocop: disable CodeReuse/ActiveRecord
- def find_project(id)
- # TODO: Only select the JID
- # This is due to the fact that the JID could be present in either the project record or
- # its associated import_state record
- Project.import_started.find_by(id: id)
+ def find_import_state(project_id)
+ ProjectImportState.select(:jid)
+ .with_status(:started)
+ .find_by(project_id: project_id)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
index 5726fbb573d..ccfed2ae187 100644
--- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
@@ -23,7 +23,7 @@ module Gitlab
klass.new(project, client).execute
end
- project.refresh_import_jid_expiration
+ project.import_state.refresh_jid_expiration
ImportPullRequestsWorker.perform_async(project.id)
end
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
index 1c5a7139802..37a7a7f4ba0 100644
--- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
@@ -15,7 +15,7 @@ module Gitlab
.new(project, client)
.execute
- project.refresh_import_jid_expiration
+ project.import_state.refresh_jid_expiration
AdvanceStageWorker.perform_async(
project.id,
diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb
index 42f5b945a75..98f9f45e608 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -8,11 +8,18 @@ class NewNoteWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(note_id, _params = {})
if note = Note.find_by(id: note_id)
- NotificationService.new.new_note(note)
+ NotificationService.new.new_note(note) unless skip_notification?(note)
Notes::PostProcessService.new(note).execute
else
Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
end
end
+
+ private
+
+ # EE-only method
+ def skip_notification?(note)
+ false
+ end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/object_pool/create_worker.rb b/app/workers/object_pool/create_worker.rb
new file mode 100644
index 00000000000..135b99886dc
--- /dev/null
+++ b/app/workers/object_pool/create_worker.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module ObjectPool
+ class CreateWorker
+ include ApplicationWorker
+ include ObjectPoolQueue
+ include ExclusiveLeaseGuard
+
+ attr_reader :pool
+
+ def perform(pool_id)
+ @pool = PoolRepository.find_by_id(pool_id)
+ return unless pool
+
+ try_obtain_lease do
+ perform_pool_creation
+ end
+ end
+
+ private
+
+ def perform_pool_creation
+ return unless pool.failed? || pool.scheduled?
+
+ # If this is a retry and the previous execution failed, deletion will
+ # bring the pool back to a pristine state
+ pool.delete_object_pool if pool.failed?
+
+ pool.create_object_pool
+ pool.mark_ready
+ rescue => e
+ pool.mark_failed
+ raise e
+ end
+
+ def lease_key
+ "object_pool:create:#{pool.id}"
+ end
+
+ def lease_timeout
+ 1.hour
+ end
+ end
+end
diff --git a/app/workers/object_pool/destroy_worker.rb b/app/workers/object_pool/destroy_worker.rb
new file mode 100644
index 00000000000..ca00d467d9b
--- /dev/null
+++ b/app/workers/object_pool/destroy_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module ObjectPool
+ class DestroyWorker
+ include ApplicationWorker
+ include ObjectPoolQueue
+
+ def perform(pool_repository_id)
+ pool = PoolRepository.find_by_id(pool_repository_id)
+ return unless pool&.obsolete?
+
+ pool.delete_object_pool
+ pool.destroy
+ end
+ end
+end
diff --git a/app/workers/object_pool/join_worker.rb b/app/workers/object_pool/join_worker.rb
new file mode 100644
index 00000000000..07676011b2a
--- /dev/null
+++ b/app/workers/object_pool/join_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module ObjectPool
+ class JoinWorker
+ include ApplicationWorker
+ include ObjectPoolQueue
+
+ def perform(pool_id, project_id)
+ pool = PoolRepository.find_by_id(pool_id)
+ return unless pool&.joinable?
+
+ project = Project.find_by_id(project_id)
+ return unless project
+
+ pool.link_repository(project.repository)
+
+ Projects::HousekeepingService.new(project).execute
+ end
+ end
+end
diff --git a/app/workers/object_pool/schedule_join_worker.rb b/app/workers/object_pool/schedule_join_worker.rb
new file mode 100644
index 00000000000..647a8b72435
--- /dev/null
+++ b/app/workers/object_pool/schedule_join_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module ObjectPool
+ class ScheduleJoinWorker
+ include ApplicationWorker
+ include ObjectPoolQueue
+
+ def perform(pool_id)
+ pool = PoolRepository.find_by_id(pool_id)
+ return unless pool&.joinable?
+
+ pool.member_projects.find_each do |project|
+ next if project.forked? && !project.import_finished?
+
+ ObjectPool::JoinWorker.perform_async(pool.id, project.id)
+ end
+ end
+ end
+end
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index 85d1ffe0fa9..ac4e9710f33 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -9,18 +9,36 @@ class PipelineScheduleWorker
Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
.preload(:owner, :project).find_each do |schedule|
begin
- pipeline = Ci::CreatePipelineService.new(schedule.project,
- schedule.owner,
- ref: schedule.ref)
- .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
-
- schedule.deactivate! unless pipeline.persisted?
+ Ci::CreatePipelineService.new(schedule.project,
+ schedule.owner,
+ ref: schedule.ref)
+ .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule)
rescue => e
- Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}"
+ error(schedule, e)
ensure
schedule.schedule_next_run!
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/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb
new file mode 100644
index 00000000000..70c2e857d09
--- /dev/null
+++ b/app/workers/remote_mirror_notification_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class RemoteMirrorNotificationWorker
+ include ApplicationWorker
+
+ def perform(remote_mirror_id)
+ remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute
+
+ # We check again if there's an error because a newer run since this job was
+ # fired could've completed successfully.
+ return unless remote_mirror && remote_mirror.last_error.present?
+
+ NotificationService.new.remote_mirror_update_failed(remote_mirror)
+ end
+end
diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb
deleted file mode 100644
index 0f486f8991d..00000000000
--- a/app/workers/remove_old_web_hook_logs_worker.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-class RemoveOldWebHookLogsWorker
- include ApplicationWorker
- include CronjobQueue
-
- WEB_HOOK_LOG_LIFETIME = 2.days
-
- # rubocop: disable DestroyAll
- def perform
- WebHookLog.destroy_all(['created_at < ?', Time.now - WEB_HOOK_LOG_LIFETIME])
- end
- # rubocop: enable DestroyAll
-end
diff --git a/app/workers/repository_cleanup_worker.rb b/app/workers/repository_cleanup_worker.rb
new file mode 100644
index 00000000000..aa26c173a72
--- /dev/null
+++ b/app/workers/repository_cleanup_worker.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class RepositoryCleanupWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: 3
+
+ sidekiq_retries_exhausted do |msg, err|
+ next if err.is_a?(ActiveRecord::RecordNotFound)
+
+ args = msg['args'] + [msg['error_message']]
+
+ new.perform_failure(*args)
+ end
+
+ def perform(project_id, user_id)
+ project = Project.find(project_id)
+ user = User.find(user_id)
+
+ Projects::CleanupService.new(project, user).execute
+
+ notification_service.repository_cleanup_success(project, user)
+ end
+
+ def perform_failure(project_id, user_id, error)
+ project = Project.find(project_id)
+ user = User.find(user_id)
+
+ # Ensure the file is removed
+ project.bfg_object_map.remove!
+ notification_service.repository_cleanup_failure(project, user, error)
+ end
+
+ private
+
+ def notification_service
+ @notification_service ||= NotificationService.new
+ end
+end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index 68ec66e8499..7eae07d3f6b 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -12,7 +12,7 @@ class RepositoryForkWorker
source_project = target_project.forked_from_project
unless source_project
- return target_project.mark_import_as_failed('Source project cannot be found.')
+ return target_project.import_state.mark_as_failed(_('Source project cannot be found.'))
end
fork_repository(target_project, source_project.repository_storage, source_project.disk_path)
@@ -33,7 +33,7 @@ class RepositoryForkWorker
end
def start_fork(project)
- return true if start(project)
+ return true if start(project.import_state)
Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.")
false
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 82189a3c9f5..59691f48a39 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -34,14 +34,14 @@ class RepositoryImportWorker
attr_reader :project
def start_import
- return true if start(project)
+ return true if start(project.import_state)
Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.")
false
end
def fail_import(message)
- project.mark_import_as_failed(message)
+ project.import_state.mark_as_failed(message)
end
def template_import?
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
index 9d4e67deb9c..c0bae08ba85 100644
--- a/app/workers/repository_update_remote_mirror_worker.rb
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -5,7 +5,6 @@ class RepositoryUpdateRemoteMirrorWorker
UpdateError = Class.new(StandardError)
include ApplicationWorker
- include Gitlab::ShellAdapter
sidekiq_options retry: 3, dead: false
@@ -16,7 +15,7 @@ class RepositoryUpdateRemoteMirrorWorker
end
def perform(remote_mirror_id, scheduled_time)
- remote_mirror = RemoteMirror.find(remote_mirror_id)
+ remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute
return if remote_mirror.updated_since?(scheduled_time)
raise UpdateAlreadyInProgressError if remote_mirror.update_in_progress?
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index 667a4121131..c8a186ba4ce 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -63,6 +63,6 @@ class StuckImportJobsWorker
# rubocop: enable CodeReuse/ActiveRecord
def error_message
- "Import timed out. Import took longer than #{IMPORT_JOBS_EXPIRATION} seconds"
+ _("Import timed out. Import took longer than %{import_jobs_expiration} seconds") % { import_jobs_expiration: IMPORT_JOBS_EXPIRATION }
end
end
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
index 98c81956cba..f34ed6c4844 100644
--- a/app/workers/stuck_merge_jobs_worker.rb
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -4,6 +4,10 @@ class StuckMergeJobsWorker
include ApplicationWorker
include CronjobQueue
+ def self.logger
+ Rails.logger
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def perform
stuck_merge_requests.find_in_batches(batch_size: 100) do |group|
@@ -35,7 +39,7 @@ class StuckMergeJobsWorker
# We rely on state machine callbacks to update head_pipeline_id
merge_requests_to_reopen.each(&:unlock_mr)
- Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}")
+ self.class.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}")
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb
index 9ce51662969..e8494ffa002 100644
--- a/app/workers/update_head_pipeline_for_merge_request_worker.rb
+++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb
@@ -6,10 +6,11 @@ class UpdateHeadPipelineForMergeRequestWorker
queue_namespace :pipeline_processing
- # rubocop: disable CodeReuse/ActiveRecord
def perform(merge_request_id)
merge_request = MergeRequest.find(merge_request_id)
- pipeline = Ci::Pipeline.where(project: merge_request.source_project, ref: merge_request.source_branch).last
+
+ sha = merge_request.diff_head_sha
+ pipeline = merge_request.all_pipelines(shas: sha).first
return unless pipeline && pipeline.latest?
@@ -21,7 +22,6 @@ class UpdateHeadPipelineForMergeRequestWorker
merge_request.update_attribute(:head_pipeline_id, pipeline.id)
end
- # rubocop: enable CodeReuse/ActiveRecord
def log_error_message_for(merge_request)
Rails.logger.error(