summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml65
-rw-r--r--.gitlab/issue_templates/Feature proposal.md2
-rw-r--r--.gitlab/issue_templates/Security developer workflow.md7
-rw-r--r--.gitlab/merge_request_templates/Security Release.md11
-rw-r--r--.rubocop_todo.yml5
-rw-r--r--CHANGELOG.md82
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--GITLAB_PAGES_VERSION2
-rw-r--r--GITLAB_WORKHORSE_VERSION2
-rw-r--r--Gemfile8
-rw-r--r--Gemfile.lock23
-rw-r--r--app/assets/javascripts/api.js7
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js19
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js33
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js15
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js42
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js53
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue192
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue3
-rw-r--r--app/assets/javascripts/clusters/constants.js9
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js16
-rw-r--r--app/assets/javascripts/commons/jquery.js1
-rw-r--r--app/assets/javascripts/diffs/components/app.vue4
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue18
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue3
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue52
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue68
-rw-r--r--app/assets/javascripts/diffs/index.js51
-rw-r--r--app/assets/javascripts/diffs/store/actions.js4
-rw-r--r--app/assets/javascripts/diffs/store/getters.js37
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js3
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js3
-rw-r--r--app/assets/javascripts/due_date_select.js2
-rw-r--r--app/assets/javascripts/environments/components/container.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue10
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue5
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue8
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js2
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue5
-rw-r--r--app/assets/javascripts/environments/index.js2
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js2
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js4
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js3
-rw-r--r--app/assets/javascripts/groups_select.js172
-rw-r--r--app/assets/javascripts/ide/components/ide.vue46
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue13
-rw-r--r--app/assets/javascripts/ide/constants.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js2
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js12
-rw-r--r--app/assets/javascripts/issuable_context.js12
-rw-r--r--app/assets/javascripts/issuable_form.js63
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue59
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue24
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue6
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js11
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue4
-rw-r--r--app/assets/javascripts/label_manager.js13
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js42
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js3
-rw-r--r--app/assets/javascripts/lib/utils/grammar.js40
-rw-r--r--app/assets/javascripts/lib/utils/icon_utils.js18
-rw-r--r--app/assets/javascripts/main.js33
-rw-r--r--app/assets/javascripts/member_expiration_date.js1
-rw-r--r--app/assets/javascripts/merge_request.js9
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue153
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue89
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue329
-rw-r--r--app/assets/javascripts/monitoring/components/graph/axis.vue118
-rw-r--r--app/assets/javascripts/monitoring/components/graph/deployment.vue48
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue151
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue62
-rw-r--r--app/assets/javascripts/monitoring/components/graph/path.vue65
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_info.vue28
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_line.vue33
-rw-r--r--app/assets/javascripts/monitoring/event_hub.js3
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js86
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js14
-rw-r--r--app/assets/javascripts/monitoring/utils/date_time_formatters.js42
-rw-r--r--app/assets/javascripts/monitoring/utils/measurements.js44
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js223
-rw-r--r--app/assets/javascripts/notes.js4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue17
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue29
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue40
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue12
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue22
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue18
-rw-r--r--app/assets/javascripts/notes/index.js3
-rw-r--r--app/assets/javascripts/notes/stores/actions.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js5
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index.js2
-rw-r--r--app/assets/javascripts/pages/explore/projects/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js18
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js10
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue2
-rw-r--r--app/assets/javascripts/project_select.js174
-rw-r--r--app/assets/javascripts/project_select_combo_button.js10
-rw-r--r--app/assets/javascripts/projects/project_new.js26
-rw-r--r--app/assets/javascripts/serverless/components/environment_row.vue65
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue25
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue55
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue59
-rw-r--r--app/assets/javascripts/serverless/components/url.vue38
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js11
-rw-r--r--app/assets/javascripts/task_list.js72
-rw-r--r--app/assets/javascripts/users_select.js190
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue40
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue91
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue278
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue (renamed from app/assets/javascripts/ide/components/file_finder/index.vue)136
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue (renamed from app/assets/javascripts/ide/components/file_finder/item.vue)26
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue8
-rw-r--r--app/assets/stylesheets/framework/common.scss24
-rw-r--r--app/assets/stylesheets/framework/files.scss20
-rw-r--r--app/assets/stylesheets/framework/images.scss3
-rw-r--r--app/assets/stylesheets/framework/variables.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss25
-rw-r--r--app/assets/stylesheets/pages/clusters.scss14
-rw-r--r--app/assets/stylesheets/pages/diff.scss52
-rw-r--r--app/assets/stylesheets/pages/environments.scss12
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss49
-rw-r--r--app/assets/stylesheets/pages/projects.scss26
-rw-r--r--app/assets/stylesheets/pages/serverless.scss3
-rw-r--r--app/assets/stylesheets/pages/settings.scss2
-rw-r--r--app/controllers/clusters/clusters_controller.rb2
-rw-r--r--app/controllers/concerns/issuable_actions.rb6
-rw-r--r--app/controllers/concerns/membership_actions.rb4
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/record_user_last_activity.rb27
-rw-r--r--app/controllers/concerns/send_file_upload.rb19
-rw-r--r--app/controllers/concerns/service_params.rb2
-rw-r--r--app/controllers/dashboard/application_controller.rb1
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups_controller.rb1
-rw-r--r--app/controllers/import/bitbucket_controller.rb4
-rw-r--r--app/controllers/import/github_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb8
-rw-r--r--app/controllers/profiles/preferences_controller.rb12
-rw-r--r--app/controllers/projects/environments_controller.rb40
-rw-r--r--app/controllers/projects/error_tracking_controller.rb40
-rw-r--r--app/controllers/projects/issues_controller.rb13
-rw-r--r--app/controllers/projects/lfs_storage_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb12
-rw-r--r--app/controllers/projects/merge_requests_controller.rb3
-rw-r--r--app/controllers/projects/pipelines_controller.rb1
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/projects/triggers_controller.rb7
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/finders/contributed_projects_finder.rb7
-rw-r--r--app/finders/issuable_finder.rb6
-rw-r--r--app/finders/issues_finder.rb1
-rw-r--r--app/finders/merge_requests_finder.rb1
-rw-r--r--app/graphql/resolvers/issues_resolver.rb4
-rw-r--r--app/graphql/types/merge_request_type.rb3
-rw-r--r--app/helpers/application_settings_helper.rb4
-rw-r--r--app/helpers/auth_helper.rb7
-rw-r--r--app/helpers/auto_devops_helper.rb37
-rw-r--r--app/helpers/emails_helper.rb8
-rw-r--r--app/helpers/environments_helper.rb1
-rw-r--r--app/helpers/external_wiki_helper.rb12
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/markup_helper.rb6
-rw-r--r--app/helpers/members_helper.rb15
-rw-r--r--app/helpers/notes_helper.rb1
-rw-r--r--app/helpers/preferences_helper.rb15
-rw-r--r--app/helpers/profiles_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb20
-rw-r--r--app/helpers/sorting_helper.rb20
-rw-r--r--app/helpers/users_helper.rb9
-rw-r--r--app/models/application_record.rb14
-rw-r--r--app/models/application_setting.rb8
-rw-r--r--app/models/ci/build.rb14
-rw-r--r--app/models/ci/trigger.rb3
-rw-r--r--app/models/clusters/applications/prometheus.rb6
-rw-r--r--app/models/clusters/cluster.rb40
-rw-r--r--app/models/clusters/concerns/application_status.rb16
-rw-r--r--app/models/clusters/concerns/application_version.rb8
-rw-r--r--app/models/clusters/platforms/kubernetes.rb2
-rw-r--r--app/models/commit.rb7
-rw-r--r--app/models/commit_collection.rb9
-rw-r--r--app/models/concerns/cache_markdown_field.rb44
-rw-r--r--app/models/concerns/issuable.rb45
-rw-r--r--app/models/concerns/noteable.rb15
-rw-r--r--app/models/concerns/taskable.rb8
-rw-r--r--app/models/discussion.rb6
-rw-r--r--app/models/environment.rb12
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb83
-rw-r--r--app/models/gpg_signature.rb7
-rw-r--r--app/models/individual_note_discussion.rb8
-rw-r--r--app/models/lfs_download_object.rb22
-rw-r--r--app/models/member.rb3
-rw-r--r--app/models/members/group_member.rb2
-rw-r--r--app/models/members/project_member.rb4
-rw-r--r--app/models/merge_request.rb10
-rw-r--r--app/models/merge_request_diff.rb116
-rw-r--r--app/models/merge_request_diff_file.rb14
-rw-r--r--app/models/pool_repository.rb4
-rw-r--r--app/models/programming_language.rb6
-rw-r--r--app/models/project.rb57
-rw-r--r--app/models/project_auto_devops.rb6
-rw-r--r--app/models/project_feature.rb19
-rw-r--r--app/models/project_team.rb12
-rw-r--r--app/models/project_wiki.rb4
-rw-r--r--app/models/repository.rb12
-rw-r--r--app/models/sent_notification.rb25
-rw-r--r--app/models/ssh_host_key.rb3
-rw-r--r--app/models/user.rb15
-rw-r--r--app/policies/ci/pipeline_policy.rb9
-rw-r--r--app/policies/issue_policy.rb1
-rw-r--r--app/policies/note_policy.rb1
-rw-r--r--app/policies/personal_snippet_policy.rb5
-rw-r--r--app/policies/project_policy.rb24
-rw-r--r--app/policies/project_snippet_policy.rb2
-rw-r--r--app/presenters/ci/trigger_presenter.rb19
-rw-r--r--app/presenters/commit_presenter.rb13
-rw-r--r--app/presenters/merge_request_presenter.rb4
-rw-r--r--app/serializers/cluster_application_entity.rb1
-rw-r--r--app/serializers/deployment_entity.rb10
-rw-r--r--app/serializers/error_tracking/project_entity.rb7
-rw-r--r--app/serializers/error_tracking/project_serializer.rb7
-rw-r--r--app/serializers/merge_request_basic_entity.rb1
-rw-r--r--app/serializers/merge_request_widget_commit_entity.rb7
-rw-r--r--app/serializers/merge_request_widget_entity.rb22
-rw-r--r--app/services/auth/container_registry_authentication_service.rb3
-rw-r--r--app/services/ci/create_pipeline_service.rb14
-rw-r--r--app/services/ci/pipeline_trigger_service.rb10
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb23
-rw-r--r--app/services/clusters/applications/schedule_installation_service.rb12
-rw-r--r--app/services/clusters/applications/upgrade_service.rb28
-rw-r--r--app/services/error_tracking/list_projects_service.rb44
-rw-r--r--app/services/groups/create_service.rb6
-rw-r--r--app/services/groups/update_service.rb6
-rw-r--r--app/services/issuable/common_system_notes_service.rb2
-rw-r--r--app/services/issuable_base_service.rb59
-rw-r--r--app/services/issues/update_service.rb9
-rw-r--r--app/services/labels/update_service.rb1
-rw-r--r--app/services/members/create_service.rb14
-rw-r--r--app/services/members/destroy_service.rb28
-rw-r--r--app/services/merge_requests/base_service.rb1
-rw-r--r--app/services/merge_requests/merge_service.rb31
-rw-r--r--app/services/merge_requests/squash_service.rb33
-rw-r--r--app/services/merge_requests/update_service.rb7
-rw-r--r--app/services/notes/build_service.rb17
-rw-r--r--app/services/notes/create_service.rb4
-rw-r--r--app/services/notification_service.rb3
-rw-r--r--app/services/preview_markdown_service.rb11
-rw-r--r--app/services/projects/import_error_filter.rb14
-rw-r--r--app/services/projects/import_service.rb22
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_link_list_service.rb13
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb109
-rw-r--r--app/services/projects/update_pages_configuration_service.rb34
-rw-r--r--app/services/projects/update_pages_service.rb41
-rw-r--r--app/services/protected_branches/api_service.rb8
-rw-r--r--app/services/search/global_service.rb14
-rw-r--r--app/services/task_list_toggle_service.rb72
-rw-r--r--app/uploaders/external_diff_uploader.rb23
-rw-r--r--app/views/admin/appearances/_form.html.haml168
-rw-r--r--app/views/admin/appearances/show.html.haml7
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml4
-rw-r--r--app/views/admin/application_settings/_localization.html.haml11
-rw-r--r--app/views/admin/application_settings/preferences.html.haml11
-rw-r--r--app/views/admin/application_settings/show.html.haml2
-rw-r--r--app/views/admin/runners/_runner.html.haml4
-rw-r--r--app/views/admin/runners/index.html.haml4
-rw-r--r--app/views/admin/users/_user.html.haml55
-rw-r--r--app/views/admin/users/_user_detail.html.haml17
-rw-r--r--app/views/admin/users/index.html.haml96
-rw-r--r--app/views/award_emoji/_awards_block.html.haml4
-rw-r--r--app/views/clusters/clusters/_form.html.haml17
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.html.haml2
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml2
-rw-r--r--app/views/email_rejection_mailer/rejection.html.haml2
-rw-r--r--app/views/email_rejection_mailer/rejection.text.haml2
-rw-r--r--app/views/events/_events.html.haml16
-rw-r--r--app/views/instance_statistics/conversational_development_index/_callout.html.haml6
-rw-r--r--app/views/instance_statistics/conversational_development_index/_card.html.haml4
-rw-r--r--app/views/instance_statistics/conversational_development_index/_no_data.html.haml6
-rw-r--r--app/views/instance_statistics/conversational_development_index/index.html.haml4
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml21
-rw-r--r--app/views/notify/_note_email.text.erb4
-rw-r--r--app/views/notify/autodevops_disabled_email.text.erb2
-rw-r--r--app/views/notify/closed_issue_email.html.haml2
-rw-r--r--app/views/notify/closed_issue_email.text.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.html.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml6
-rw-r--r--app/views/notify/issue_status_changed_email.html.haml2
-rw-r--r--app/views/notify/issue_status_changed_email.text.erb2
-rw-r--r--app/views/notify/member_access_requested_email.text.erb2
-rw-r--r--app/views/notify/member_invite_accepted_email.text.erb2
-rw-r--r--app/views/notify/member_invited_email.text.erb2
-rw-r--r--app/views/notify/merge_request_status_email.html.haml2
-rw-r--r--app/views/notify/merge_request_status_email.text.haml6
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml4
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml4
-rw-r--r--app/views/notify/new_gpg_key_email.html.haml2
-rw-r--r--app/views/notify/new_gpg_key_email.text.erb2
-rw-r--r--app/views/notify/new_issue_email.text.erb2
-rw-r--r--app/views/notify/new_mention_in_issue_email.text.erb4
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb4
-rw-r--r--app/views/notify/new_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_ssh_key_email.html.haml2
-rw-r--r--app/views/notify/new_ssh_key_email.text.erb2
-rw-r--r--app/views/notify/new_user_email.html.haml2
-rw-r--r--app/views/notify/new_user_email.text.erb2
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb6
-rw-r--r--app/views/notify/pipeline_success_email.text.erb6
-rw-r--r--app/views/notify/push_to_merge_request_email.html.haml2
-rw-r--r--app/views/notify/push_to_merge_request_email.text.haml2
-rw-r--r--app/views/notify/reassigned_issue_email.html.haml2
-rw-r--r--app/views/notify/reassigned_issue_email.text.erb2
-rw-r--r--app/views/notify/reassigned_merge_request_email.html.haml4
-rw-r--r--app/views/notify/reassigned_merge_request_email.text.erb4
-rw-r--r--app/views/notify/resolved_all_discussions_email.html.haml2
-rw-r--r--app/views/notify/resolved_all_discussions_email.text.erb2
-rw-r--r--app/views/profiles/preferences/show.html.haml21
-rw-r--r--app/views/profiles/show.html.haml6
-rw-r--r--app/views/projects/_home_panel.html.haml9
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/blob/edit.html.haml2
-rw-r--r--app/views/projects/blob/preview.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_readme.html.haml2
-rw-r--r--app/views/projects/commit/_ci_menu.html.haml4
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/commit/show.html.haml7
-rw-r--r--app/views/projects/commits/_commit.html.haml5
-rw-r--r--app/views/projects/compare/show.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml4
-rw-r--r--app/views/projects/diffs/_file.html.haml4
-rw-r--r--app/views/projects/environments/index.html.haml1
-rw-r--r--app/views/projects/issues/_form.html.haml3
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml3
-rw-r--r--app/views/projects/issues/_related_branches.html.haml2
-rw-r--r--app/views/projects/merge_requests/_form.html.haml3
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml3
-rw-r--r--app/views/projects/milestones/_form.html.haml3
-rw-r--r--app/views/projects/new.html.haml3
-rw-r--r--app/views/projects/pipelines/_info.html.haml33
-rw-r--r--app/views/projects/project_templates/_built_in_templates.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml19
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml3
-rw-r--r--app/views/projects/tags/releases/edit.html.haml3
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--app/views/projects/wikis/_form.html.haml16
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml2
-rw-r--r--app/views/projects/wikis/pages.html.haml2
-rw-r--r--app/views/projects/wikis/show.html.haml4
-rw-r--r--app/views/search/results/_snippet_blob.html.haml2
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml19
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml2
-rw-r--r--app/views/shared/empty_states/_wikis_layout.html.haml2
-rw-r--r--app/views/shared/groups/_list.html.haml14
-rw-r--r--app/views/shared/icons/_express.svg1
-rw-r--r--app/views/shared/icons/_rails.svg1
-rw-r--r--app/views/shared/icons/_spring.svg1
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/projects/_list.html.haml32
-rw-r--r--app/views/shared/projects/_project.html.haml43
-rw-r--r--app/views/shared/snippets/_form.html.haml3
-rw-r--r--app/views/snippets/_snippets.html.haml13
-rw-r--r--app/workers/all_queues.yml1
-rw-r--r--app/workers/cluster_upgrade_app_worker.rb13
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb31
-rw-r--r--app/workers/repository_fork_worker.rb10
-rwxr-xr-xbin/secpick16
-rw-r--r--changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml5
-rw-r--r--changelogs/unreleased/2105-add-setting-for-first-day-of-the-week.yml5
-rw-r--r--changelogs/unreleased/24875-label.yml5
-rw-r--r--changelogs/unreleased/28500-empty-states-for-profile-page.yml5
-rw-r--r--changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml5
-rw-r--r--changelogs/unreleased/44332-add-openid-profile-scopes.yml5
-rw-r--r--changelogs/unreleased/45791-number-of-repositories-usage-ping.yml5
-rw-r--r--changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml5
-rw-r--r--changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml5
-rw-r--r--changelogs/unreleased/51759-filter-by-language.yml5
-rw-r--r--changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml5
-rw-r--r--changelogs/unreleased/52347-lines-changed-statistics-is-not-easily-visible-in-mr-changes-view.yml5
-rw-r--r--changelogs/unreleased/52363-ui-changes-to-cluster-and-ado-pages.yml5
-rw-r--r--changelogs/unreleased/52568-external-mr-diffs.yml5
-rw-r--r--changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml5
-rw-r--r--changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml5
-rw-r--r--changelogs/unreleased/56014-api-merge-request-squash-commit-messages.yml5
-rw-r--r--changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml5
-rw-r--r--changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml5
-rw-r--r--changelogs/unreleased/56543-project-lists-further-iteration-improvements.yml5
-rw-r--r--changelogs/unreleased/56788-unicorn-metric-labels.yml5
-rw-r--r--changelogs/unreleased/56938-diff-file-headers-on-compare-not-quite-right.yml5
-rw-r--r--changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml5
-rw-r--r--changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml5
-rw-r--r--changelogs/unreleased/adriel-remove-feature-flag.yml5
-rw-r--r--changelogs/unreleased/api-group-labels.yml5
-rw-r--r--changelogs/unreleased/bvl-fix-race-condition-creating-signature.yml5
-rw-r--r--changelogs/unreleased/chore-update-js-regex.yml5
-rw-r--r--changelogs/unreleased/cluster_application_version_updated.yml5
-rw-r--r--changelogs/unreleased/diff-file-finder.yml5
-rw-r--r--changelogs/unreleased/fix-39759-new-project-icon-vertical-align.yml5
-rw-r--r--changelogs/unreleased/fix-49388.yml5
-rw-r--r--changelogs/unreleased/fix-repo-settings-file-upload-error.yml5
-rw-r--r--changelogs/unreleased/fix_jira_integration_VCS1019.yml5
-rw-r--r--changelogs/unreleased/gitaly-update-1.18.0.yml5
-rw-r--r--changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml5
-rw-r--r--changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml5
-rw-r--r--changelogs/unreleased/hnk-master-patch-61932.yml5
-rw-r--r--changelogs/unreleased/introduce-environment-search-endpoint.yml5
-rw-r--r--changelogs/unreleased/issue_55744.yml5
-rw-r--r--changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml5
-rw-r--r--changelogs/unreleased/jlenny-AddPagesTemplates.yml5
-rw-r--r--changelogs/unreleased/jlenny-NewAndroidTemplate.yml5
-rw-r--r--changelogs/unreleased/jprovazn-remove-redcarpet.yml5
-rw-r--r--changelogs/unreleased/knative-list.yml5
-rw-r--r--changelogs/unreleased/local-markdown-version-bkp3.yml5
-rw-r--r--changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml5
-rw-r--r--changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml5
-rw-r--r--changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml5
-rw-r--r--changelogs/unreleased/pl-serialize-ac-parameters.yml5
-rw-r--r--changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml5
-rw-r--r--changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml5
-rw-r--r--changelogs/unreleased/search-title.yml5
-rw-r--r--changelogs/unreleased/security-22076-sanitize-url-in-names.yml6
-rw-r--r--changelogs/unreleased/security-55320-stored-xss-in-user-status.yml5
-rw-r--r--changelogs/unreleased/security-stored-xss-via-katex.yml5
-rw-r--r--changelogs/unreleased/sh-encode-content-disposition.yml5
-rw-r--r--changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml5
-rw-r--r--changelogs/unreleased/sh-fix-issue-9357.yml5
-rw-r--r--changelogs/unreleased/sh-fix-pages-zip-constant.yml5
-rw-r--r--changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml5
-rw-r--r--changelogs/unreleased/support-chunking-in-client.yml5
-rw-r--r--changelogs/unreleased/test-permissions.yml5
-rw-r--r--changelogs/unreleased/tooltips-to-top.yml5
-rw-r--r--changelogs/unreleased/update-gitaly.yml5
-rw-r--r--changelogs/unreleased/update-pages-config-only-when-changed.yml5
-rw-r--r--changelogs/unreleased/update-pages-extensionless-urls.yml5
-rw-r--r--changelogs/unreleased/update-ui-admin-appearance.yml5
-rw-r--r--changelogs/unreleased/update-workhorse-8-2-0.yml5
-rw-r--r--changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml5
-rw-r--r--changelogs/unreleased/use_upgrade_install_for_helm_apps.yml5
-rw-r--r--changelogs/unreleased/workhorse-8-3-0.yml5
-rw-r--r--config/gitlab.yml.example29
-rw-r--r--config/initializers/1_settings.rb8
-rw-r--r--config/initializers/doorkeeper_openid_connect.rb23
-rw-r--r--config/initializers/sprockets_base_file_digest_key.rb3
-rw-r--r--config/locales/de.yml1
-rw-r--r--config/locales/doorkeeper.en.yml6
-rw-r--r--config/locales/en.yml1
-rw-r--r--config/locales/es.yml1
-rw-r--r--config/routes/import.rb9
-rw-r--r--config/routes/project.rb7
-rw-r--r--config/webpack.config.js3
-rw-r--r--db/migrate/20140502125220_migrate_repo_size.rb2
-rw-r--r--db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb9
-rw-r--r--db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb16
-rw-r--r--db/migrate/20190109153125_add_merge_request_external_diffs.rb25
-rw-r--r--db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb16
-rw-r--r--db/migrate/20190130091630_add_local_cached_markdown_version.rb11
-rw-r--r--db/post_migrate/20181219130552_update_project_import_visibility_level.rb60
-rw-r--r--db/post_migrate/20190131122559_fix_null_type_labels.rb23
-rw-r--r--db/post_migrate/20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb49
-rw-r--r--db/schema.rb18
-rw-r--r--doc/README.md2
-rw-r--r--doc/administration/git_protocol.md7
-rw-r--r--doc/administration/gitaly/index.md18
-rw-r--r--doc/administration/index.md2
-rw-r--r--doc/administration/invalidate_markdown_cache.md16
-rw-r--r--doc/administration/merge_request_diffs.md154
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_metrics.md2
-rw-r--r--doc/administration/operations/filesystem_benchmarking.md87
-rw-r--r--doc/api/README.md1
-rw-r--r--doc/api/broadcast_messages.md101
-rw-r--r--doc/api/group_labels.md201
-rw-r--r--doc/api/issues.md2
-rw-r--r--doc/api/merge_requests.md5
-rw-r--r--doc/api/project_clusters.md22
-rw-r--r--doc/api/projects.md2
-rw-r--r--doc/api/settings.md10
-rw-r--r--doc/api/snippets.md216
-rw-r--r--doc/api/users.md1
-rw-r--r--doc/ci/examples/browser_performance.md2
-rw-r--r--doc/ci/examples/code_quality.md2
-rw-r--r--doc/ci/examples/container_scanning.md2
-rw-r--r--doc/ci/examples/dast.md2
-rw-r--r--doc/ci/interactive_web_terminal/index.md11
-rw-r--r--doc/ci/pipelines.md2
-rw-r--r--doc/ci/quick_start/README.md2
-rw-r--r--doc/ci/review_apps/index.md2
-rw-r--r--doc/ci/variables/README.md4
-rw-r--r--doc/ci/yaml/README.md4
-rw-r--r--doc/development/README.md4
-rw-r--r--doc/development/contributing/issue_workflow.md2
-rw-r--r--doc/development/contributing/merge_request_workflow.md3
-rw-r--r--doc/development/contributing/style_guides.md1
-rw-r--r--doc/development/documentation/index.md6
-rw-r--r--doc/development/ee_features.md14
-rw-r--r--doc/development/file_storage.md2
-rw-r--r--doc/development/go_guide/index.md216
-rw-r--r--doc/development/i18n/externalization.md6
-rw-r--r--doc/development/i18n/index.md2
-rw-r--r--doc/development/i18n/merging_translations.md2
-rw-r--r--doc/development/sql.md34
-rw-r--r--doc/install/installation.md17
-rw-r--r--doc/integration/bitbucket.md6
-rw-r--r--doc/integration/github.md6
-rw-r--r--doc/ssh/README.md18
-rw-r--r--doc/topics/autodevops/index.md39
-rw-r--r--doc/user/admin_area/broadcast_messages.md2
-rw-r--r--doc/user/gitlab_com/index.md2
-rw-r--r--doc/user/group/clusters/index.md15
-rw-r--r--doc/user/index.md44
-rw-r--r--doc/user/instance_statistics/user_cohorts.md1
-rw-r--r--doc/user/markdown.md25
-rw-r--r--doc/user/profile/preferences.md8
-rw-r--r--doc/user/project/clusters/index.md18
-rw-r--r--doc/user/project/clusters/serverless/img/app-domain.pngbin0 -> 209263 bytes
-rw-r--r--doc/user/project/clusters/serverless/img/serverless-details.pngbin63347 -> 0 bytes
-rw-r--r--doc/user/project/clusters/serverless/index.md50
-rw-r--r--doc/user/project/merge_requests/img/squash_mr_message.pngbin0 -> 150302 bytes
-rw-r--r--doc/user/project/merge_requests/index.md2
-rw-r--r--doc/user/project/merge_requests/squash_and_merge.md23
-rw-r--r--doc/user/project/operations/index.md11
-rw-r--r--doc/user/project/pages/getting_started_part_three.md25
-rw-r--r--doc/user/project/pages/getting_started_part_two.md20
-rw-r--r--doc/user/project/pages/index.md4
-rw-r--r--doc/user/project/pages/introduction.md55
-rw-r--r--doc/user/project/pipelines/job_artifacts.md5
-rw-r--r--doc/user/snippets.md7
-rw-r--r--doc/workflow/repository_mirroring.md18
-rw-r--r--lib/api/api.rb2
-rw-r--r--lib/api/entities.rb22
-rw-r--r--lib/api/group_labels.rb63
-rw-r--r--lib/api/helpers.rb21
-rw-r--r--lib/api/helpers/label_helpers.rb82
-rw-r--r--lib/api/helpers/presentable.rb29
-rw-r--r--lib/api/issues.rb3
-rw-r--r--lib/api/labels.rb81
-rw-r--r--lib/api/merge_requests.rb5
-rw-r--r--lib/api/pipelines.rb6
-rw-r--r--lib/api/projects.rb6
-rw-r--r--lib/api/settings.rb1
-rw-r--r--lib/api/subscriptions.rb87
-rw-r--r--lib/api/triggers.rb10
-rw-r--r--lib/api/users.rb6
-rw-r--r--lib/backup/repository.rb2
-rw-r--r--lib/banzai/filter/autolink_filter.rb17
-rw-r--r--lib/banzai/filter/external_link_filter.rb85
-rw-r--r--lib/banzai/filter/markdown_engines/redcarpet.rb34
-rw-r--r--lib/banzai/filter/spaced_link_filter.rb2
-rw-r--r--lib/banzai/filter/syntax_highlight_filter.rb1
-rw-r--r--lib/banzai/pipeline/email_pipeline.rb1
-rw-r--r--lib/banzai/renderer/redcarpet/html.rb17
-rw-r--r--lib/gitlab/auth.rb5
-rw-r--r--lib/gitlab/auth/ldap/adapter.rb19
-rw-r--r--lib/gitlab/background_migration.rb15
-rw-r--r--lib/gitlab/bitbucket_import/importer.rb6
-rw-r--r--lib/gitlab/bitbucket_import/wiki_formatter.rb25
-rw-r--r--lib/gitlab/ci/pipeline/chain/limit/activity.rb21
-rw-r--r--lib/gitlab/ci/pipeline/chain/limit/size.rb21
-rw-r--r--lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb19
-rw-r--r--lib/gitlab/ci/templates/Android-Fastlane.gitlab-ci.yml121
-rw-r--r--lib/gitlab/ci/templates/Android.gitlab-ci.yml2
-rw-r--r--lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml41
-rw-r--r--lib/gitlab/ci/templates/dotNET.gitlab-ci.yml1
-rw-r--r--lib/gitlab/content_disposition.rb54
-rw-r--r--lib/gitlab/data_builder/push.rb2
-rw-r--r--lib/gitlab/email/handler/reply_processing.rb2
-rw-r--r--lib/gitlab/error_tracking/project.rb16
-rw-r--r--lib/gitlab/git/object_pool.rb9
-rw-r--r--lib/gitlab/git/repository.rb7
-rw-r--r--lib/gitlab/gitaly_client/repository_service.rb33
-rw-r--r--lib/gitlab/gitaly_client/util.rb8
-rw-r--r--lib/gitlab/github_import/importer/lfs_object_importer.rb8
-rw-r--r--lib/gitlab/github_import/importer/repository_importer.rb9
-rw-r--r--lib/gitlab/github_import/representation/lfs_object.rb4
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--lib/gitlab/gpg/commit.rb7
-rw-r--r--lib/gitlab/import_export/import_export.yml6
-rw-r--r--lib/gitlab/import_export/project_tree_restorer.rb9
-rw-r--r--lib/gitlab/import_export/shared.rb39
-rw-r--r--lib/gitlab/kubernetes/helm/api.rb11
-rw-r--r--lib/gitlab/kubernetes/helm/install_command.rb34
-rw-r--r--lib/gitlab/kubernetes/helm/upgrade_command.rb65
-rw-r--r--lib/gitlab/legacy_github_import/importer.rb2
-rw-r--r--lib/gitlab/legacy_github_import/wiki_formatter.rb4
-rw-r--r--lib/gitlab/metrics/samplers/unicorn_sampler.rb8
-rw-r--r--lib/gitlab/patch/sprockets_base_file_digest_key.rb22
-rw-r--r--lib/gitlab/path_regex.rb3
-rw-r--r--lib/gitlab/project_template.rb19
-rw-r--r--lib/gitlab/shell.rb67
-rw-r--r--lib/gitlab/task_helpers.rb8
-rw-r--r--lib/gitlab/usage_data.rb1
-rw-r--r--lib/gitlab/utils/merge_hash.rb2
-rw-r--r--lib/gitlab/utils/override.rb2
-rw-r--r--lib/gitlab/utils/strong_memoize.rb2
-rw-r--r--lib/safe_zip/entry.rb97
-rw-r--r--lib/safe_zip/extract.rb73
-rw-r--r--lib/safe_zip/extract_params.rb36
-rw-r--r--lib/sentry/client.rb58
-rw-r--r--lib/tasks/gitlab/assets.rake18
-rw-r--r--lib/tasks/gitlab/backup.rake92
-rw-r--r--lib/tasks/gitlab/db.rake5
-rw-r--r--locale/gitlab.pot390
-rw-r--r--package.json9
-rw-r--r--qa/qa.rb11
-rw-r--r--qa/qa/git/repository.rb46
-rw-r--r--qa/qa/page/admin/menu.rb9
-rw-r--r--qa/qa/page/admin/settings/component/account_and_limit.rb26
-rw-r--r--qa/qa/page/admin/settings/general.rb23
-rw-r--r--qa/qa/page/component/lazy_loader.rb15
-rw-r--r--qa/qa/page/label/index.rb8
-rw-r--r--qa/qa/page/main/login.rb32
-rw-r--r--qa/qa/page/project/job/show.rb10
-rw-r--r--qa/qa/page/project/pipeline/show.rb6
-rw-r--r--qa/qa/page/project/settings/ci_cd.rb5
-rw-r--r--qa/qa/page/project/wiki/new.rb40
-rw-r--r--qa/qa/resource/kubernetes_cluster.rb6
-rw-r--r--qa/qa/resource/repository/push.rb2
-rw-r--r--qa/qa/runtime/env.rb8
-rw-r--r--qa/qa/scenario/test/integration/oauth.rb13
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb16
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/project/view_project_activity_spec.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/merge_request/squash_merge_request_spec.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb70
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb17
-rw-r--r--qa/qa/specs/features/browser_ui/4_verify/ci_variable/add_ci_variable_spec.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb9
-rw-r--r--qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb60
-rw-r--r--qa/qa/vendor/github/page/base.rb14
-rw-r--r--qa/qa/vendor/github/page/login.rb23
-rw-r--r--qa/spec/git/repository_spec.rb134
-rw-r--r--qa/spec/scenario/test/integration/oauth_spec.rb9
-rw-r--r--qa/spec/spec_helper.rb14
-rwxr-xr-xscripts/clean-old-cached-assets6
-rwxr-xr-xscripts/review_apps/review-apps.sh5
-rw-r--r--spec/controllers/concerns/send_file_upload_spec.rb25
-rw-r--r--spec/controllers/groups/clusters_controller_spec.rb21
-rw-r--r--spec/controllers/groups/group_members_controller_spec.rb2
-rw-r--r--spec/controllers/import/bitbucket_controller_spec.rb11
-rw-r--r--spec/controllers/import/github_controller_spec.rb8
-rw-r--r--spec/controllers/omniauth_callbacks_controller_spec.rb23
-rw-r--r--spec/controllers/profiles/preferences_controller_spec.rb3
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb16
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb116
-rw-r--r--spec/controllers/projects/error_tracking_controller_spec.rb114
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb19
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb17
-rw-r--r--spec/controllers/projects/pipeline_schedules_controller_spec.rb11
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb47
-rw-r--r--spec/controllers/projects/services_controller_spec.rb10
-rw-r--r--spec/controllers/users_controller_spec.rb32
-rw-r--r--spec/factories/commits.rb10
-rw-r--r--spec/factories/error_tracking/project.rb15
-rw-r--r--spec/factories/project_error_tracking_settings.rb2
-rw-r--r--spec/factories/projects.rb2
-rw-r--r--spec/features/admin/admin_appearance_spec.rb12
-rw-r--r--spec/features/admin/admin_users_spec.rb42
-rw-r--r--spec/features/clusters/cluster_detail_page_spec.rb69
-rw-r--r--spec/features/dashboard/projects_spec.rb21
-rw-r--r--spec/features/markdown/copy_as_gfm_spec.rb17
-rw-r--r--spec/features/markdown/markdown_spec.rb27
-rw-r--r--spec/features/markdown/math_spec.rb18
-rw-r--r--spec/features/merge_request/user_accepts_merge_request_spec.rb4
-rw-r--r--spec/features/merge_request/user_customizes_merge_commit_message_spec.rb10
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb32
-rw-r--r--spec/features/merge_request/user_resolves_conflicts_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_versions_spec.rb31
-rw-r--r--spec/features/merge_requests/user_squashes_merge_request_spec.rb2
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb63
-rw-r--r--spec/features/projects/artifacts/user_downloads_artifacts_spec.rb2
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb19
-rw-r--r--spec/features/projects/blobs/edit_spec.rb11
-rw-r--r--spec/features/projects/clusters/applications_spec.rb10
-rw-r--r--spec/features/projects/jobs_spec.rb2
-rw-r--r--spec/features/projects/serverless/functions_spec.rb9
-rw-r--r--spec/features/projects/settings/pipelines_settings_spec.rb25
-rw-r--r--spec/features/projects/settings/user_changes_default_branch_spec.rb3
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb14
-rw-r--r--spec/features/projects_spec.rb11
-rw-r--r--spec/features/security/project/internal_access_spec.rb6
-rw-r--r--spec/features/security/project/private_access_spec.rb2
-rw-r--r--spec/features/security/project/public_access_spec.rb10
-rw-r--r--spec/features/snippets/show_spec.rb29
-rw-r--r--spec/features/task_lists_spec.rb66
-rw-r--r--spec/features/users/overview_spec.rb4
-rw-r--r--spec/finders/contributed_projects_finder_spec.rb12
-rw-r--r--spec/finders/issues_finder_spec.rb8
-rw-r--r--spec/finders/merge_requests_finder_spec.rb32
-rw-r--r--spec/fixtures/api/schemas/cluster_status.json3
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_basic.json3
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_widget.json6
-rw-r--r--spec/fixtures/api/schemas/error_tracking/list_projects.json13
-rw-r--r--spec/fixtures/api/schemas/error_tracking/project.json19
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/group_labels.json18
-rw-r--r--spec/fixtures/markdown.md.erb2
-rw-r--r--spec/fixtures/pages_non_writeable.zipbin0 -> 727 bytes
-rw-r--r--spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zipbin0 -> 1183 bytes
-rw-r--r--spec/fixtures/safe_zip/invalid-symlinks-outside.zipbin0 -> 1309 bytes
-rw-r--r--spec/fixtures/safe_zip/valid-non-writeable.zipbin0 -> 727 bytes
-rw-r--r--spec/fixtures/safe_zip/valid-simple.zipbin0 -> 1144 bytes
-rw-r--r--spec/fixtures/safe_zip/valid-symlinks-first.zipbin0 -> 528 bytes
-rw-r--r--spec/fixtures/sentry/list_projects_sample_response.json81
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb20
-rw-r--r--spec/helpers/auto_devops_helper_spec.rb35
-rw-r--r--spec/helpers/emails_helper_spec.rb14
-rw-r--r--spec/helpers/issuables_helper_spec.rb2
-rw-r--r--spec/helpers/markup_helper_spec.rb21
-rw-r--r--spec/helpers/members_helper_spec.rb4
-rw-r--r--spec/helpers/preferences_helper_spec.rb24
-rw-r--r--spec/helpers/projects_helper_spec.rb43
-rw-r--r--spec/helpers/users_helper_spec.rb70
-rw-r--r--spec/javascripts/api_spec.js16
-rw-r--r--spec/javascripts/behaviors/copy_as_gfm_spec.js33
-rw-r--r--spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js126
-rw-r--r--spec/javascripts/clusters/clusters_bundle_spec.js57
-rw-r--r--spec/javascripts/clusters/components/application_row_spec.js187
-rw-r--r--spec/javascripts/clusters/stores/clusters_store_spec.js3
-rw-r--r--spec/javascripts/diffs/components/compare_versions_spec.js4
-rw-r--r--spec/javascripts/diffs/components/diff_file_header_spec.js4
-rw-r--r--spec/javascripts/diffs/components/diff_stats_spec.js33
-rw-r--r--spec/javascripts/diffs/components/tree_list_spec.js27
-rw-r--r--spec/javascripts/diffs/store/getters_spec.js6
-rw-r--r--spec/javascripts/environments/environment_item_spec.js2
-rw-r--r--spec/javascripts/environments/environment_table_spec.js1
-rw-r--r--spec/javascripts/environments/environments_app_spec.js1
-rw-r--r--spec/javascripts/environments/folder/environments_folder_view_spec.js1
-rw-r--r--spec/javascripts/helpers/vue_test_utils_helper.js19
-rw-r--r--spec/javascripts/helpers/vue_test_utils_helper_spec.js48
-rw-r--r--spec/javascripts/ide/components/ide_spec.js68
-rw-r--r--spec/javascripts/ide/components/ide_status_bar_spec.js3
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js194
-rw-r--r--spec/javascripts/issue_show/components/description_spec.js23
-rw-r--r--spec/javascripts/issue_show/mock_data.js2
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js165
-rw-r--r--spec/javascripts/lib/utils/file_upload_spec.js44
-rw-r--r--spec/javascripts/lib/utils/grammar_spec.js35
-rw-r--r--spec/javascripts/lib/utils/icon_utils_spec.js67
-rw-r--r--spec/javascripts/merge_request_spec.js56
-rw-r--r--spec/javascripts/monitoring/charts/area_spec.js220
-rw-r--r--spec/javascripts/monitoring/dashboard_spec.js28
-rw-r--r--spec/javascripts/monitoring/graph/axis_spec.js65
-rw-r--r--spec/javascripts/monitoring/graph/deployment_spec.js53
-rw-r--r--spec/javascripts/monitoring/graph/flag_spec.js133
-rw-r--r--spec/javascripts/monitoring/graph/legend_spec.js44
-rw-r--r--spec/javascripts/monitoring/graph/track_info_spec.js44
-rw-r--r--spec/javascripts/monitoring/graph/track_line_spec.js52
-rw-r--r--spec/javascripts/monitoring/graph_path_spec.js56
-rw-r--r--spec/javascripts/monitoring/graph_spec.js127
-rw-r--r--spec/javascripts/monitoring/mock_data.js81
-rw-r--r--spec/javascripts/monitoring/utils/multiple_time_series_spec.js22
-rw-r--r--spec/javascripts/notes/components/discussion_reply_placeholder_spec.js34
-rw-r--r--spec/javascripts/notes/components/note_actions/reply_button_spec.js46
-rw-r--r--spec/javascripts/notes/components/note_actions_spec.js149
-rw-r--r--spec/javascripts/notes/components/note_app_spec.js167
-rw-r--r--spec/javascripts/notes/components/noteable_discussion_spec.js31
-rw-r--r--spec/javascripts/notes/mock_data.js1
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js14
-rw-r--r--spec/javascripts/notes/stores/mutation_spec.js23
-rw-r--r--spec/javascripts/notes_spec.js19
-rw-r--r--spec/javascripts/serverless/components/environment_row_spec.js81
-rw-r--r--spec/javascripts/serverless/components/function_row_spec.js33
-rw-r--r--spec/javascripts/serverless/components/functions_spec.js68
-rw-r--r--spec/javascripts/serverless/components/url_spec.js28
-rw-r--r--spec/javascripts/serverless/mock_data.js79
-rw-r--r--spec/javascripts/serverless/stores/serverless_store_spec.js36
-rw-r--r--spec/javascripts/task_list_spec.js156
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/commit_edit_spec.js85
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js61
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_commits_header_spec.js110
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js153
-rw-r--r--spec/javascripts/vue_mr_widget/mock_data.js6
-rw-r--r--spec/javascripts/vue_shared/components/file_finder/index_spec.js (renamed from spec/javascripts/ide/components/file_finder/index_spec.js)200
-rw-r--r--spec/javascripts/vue_shared/components/file_finder/item_spec.js (renamed from spec/javascripts/ide/components/file_finder/item_spec.js)6
-rw-r--r--spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js6
-rw-r--r--spec/lib/api/helpers_spec.rb2
-rw-r--r--spec/lib/banzai/filter/autolink_filter_spec.rb23
-rw-r--r--spec/lib/banzai/filter/external_link_filter_spec.rb65
-rw-r--r--spec/lib/banzai/filter/markdown_filter_spec.rb36
-rw-r--r--spec/lib/banzai/filter/project_reference_filter_spec.rb6
-rw-r--r--spec/lib/banzai/filter/spaced_link_filter_spec.rb5
-rw-r--r--spec/lib/banzai/object_renderer_spec.rb2
-rw-r--r--spec/lib/banzai/pipeline/email_pipeline_spec.rb14
-rw-r--r--spec/lib/banzai/pipeline/full_pipeline_spec.rb38
-rw-r--r--spec/lib/gitlab/auth_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration_spec.rb32
-rw-r--r--spec/lib/gitlab/bare_repository_import/repository_spec.rb2
-rw-r--r--spec/lib/gitlab/bitbucket_import/importer_spec.rb8
-rw-r--r--spec/lib/gitlab/bitbucket_import/wiki_formatter_spec.rb29
-rw-r--r--spec/lib/gitlab/data_builder/push_spec.rb4
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb57
-rw-r--r--spec/lib/gitlab/git/blame_spec.rb2
-rw-r--r--spec/lib/gitlab/git/blob_spec.rb2
-rw-r--r--spec/lib/gitlab/git/branch_spec.rb4
-rw-r--r--spec/lib/gitlab/git/commit_spec.rb4
-rw-r--r--spec/lib/gitlab/git/compare_spec.rb2
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb2
-rw-r--r--spec/lib/gitlab/git/remote_repository_spec.rb16
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb97
-rw-r--r--spec/lib/gitlab/git/tag_spec.rb2
-rw-r--r--spec/lib/gitlab/git/tree_spec.rb2
-rw-r--r--spec/lib/gitlab/git_access_spec.rb23
-rw-r--r--spec/lib/gitlab/gitaly_client/remote_service_spec.rb2
-rw-r--r--spec/lib/gitlab/gitaly_client/util_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb22
-rw-r--r--spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb14
-rw-r--r--spec/lib/gitlab/github_import/importer/repository_importer_spec.rb14
-rw-r--r--spec/lib/gitlab/import_export/project_tree_restorer_spec.rb65
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml3
-rw-r--r--spec/lib/gitlab/import_export/shared_spec.rb31
-rw-r--r--spec/lib/gitlab/import_export/version_checker_spec.rb2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/api_spec.rb45
-rw-r--r--spec/lib/gitlab/kubernetes/helm/install_command_spec.rb84
-rw-r--r--spec/lib/gitlab/kubernetes/helm/upgrade_command_spec.rb140
-rw-r--r--spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb8
-rw-r--r--spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb4
-rw-r--r--spec/lib/gitlab/project_template_spec.rb7
-rw-r--r--spec/lib/gitlab/shell_spec.rb17
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb4
-rw-r--r--spec/lib/safe_zip/entry_spec.rb196
-rw-r--r--spec/lib/safe_zip/extract_params_spec.rb54
-rw-r--r--spec/lib/safe_zip/extract_spec.rb80
-rw-r--r--spec/lib/sentry/client_spec.rb159
-rw-r--r--spec/mailers/notify_spec.rb8
-rw-r--r--spec/migrations/clean_up_for_members_spec.rb4
-rw-r--r--spec/migrations/fix_null_type_labels_spec.rb36
-rw-r--r--spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb114
-rw-r--r--spec/migrations/update_project_import_visibility_level_spec.rb86
-rw-r--r--spec/models/ability_spec.rb1
-rw-r--r--spec/models/application_record_spec.rb23
-rw-r--r--spec/models/application_setting_spec.rb7
-rw-r--r--spec/models/ci/build_spec.rb20
-rw-r--r--spec/models/clusters/applications/cert_manager_spec.rb17
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb16
-rw-r--r--spec/models/clusters/applications/jupyter_spec.rb16
-rw-r--r--spec/models/clusters/applications/knative_spec.rb16
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb20
-rw-r--r--spec/models/clusters/applications/runner_spec.rb16
-rw-r--r--spec/models/clusters/cluster_spec.rb105
-rw-r--r--spec/models/clusters/platforms/kubernetes_spec.rb13
-rw-r--r--spec/models/commit_collection_spec.rb11
-rw-r--r--spec/models/commit_spec.rb1
-rw-r--r--spec/models/concerns/cache_markdown_field_spec.rb224
-rw-r--r--spec/models/concerns/issuable_spec.rb72
-rw-r--r--spec/models/environment_spec.rb70
-rw-r--r--spec/models/error_tracking/project_error_tracking_setting_spec.rb176
-rw-r--r--spec/models/gpg_signature_spec.rb35
-rw-r--r--spec/models/lfs_download_object_spec.rb68
-rw-r--r--spec/models/merge_request_diff_spec.rb189
-rw-r--r--spec/models/merge_request_spec.rb48
-rw-r--r--spec/models/project_services/external_wiki_service_spec.rb21
-rw-r--r--spec/models/project_spec.rb92
-rw-r--r--spec/models/project_team_spec.rb15
-rw-r--r--spec/models/project_wiki_spec.rb4
-rw-r--r--spec/models/repository_spec.rb23
-rw-r--r--spec/models/resource_label_event_spec.rb6
-rw-r--r--spec/models/sent_notification_spec.rb34
-rw-r--r--spec/models/ssh_host_key_spec.rb29
-rw-r--r--spec/models/user_spec.rb27
-rw-r--r--spec/policies/ci/pipeline_policy_spec.rb8
-rw-r--r--spec/policies/note_policy_spec.rb2
-rw-r--r--spec/policies/personal_snippet_policy_spec.rb29
-rw-r--r--spec/policies/project_policy_spec.rb80
-rw-r--r--spec/policies/project_snippet_policy_spec.rb20
-rw-r--r--spec/presenters/blob_presenter_spec.rb2
-rw-r--r--spec/presenters/ci/trigger_presenter_spec.rb51
-rw-r--r--spec/presenters/commit_presenter_spec.rb54
-rw-r--r--spec/requests/api/files_spec.rb2
-rw-r--r--spec/requests/api/group_labels_spec.rb258
-rw-r--r--spec/requests/api/issues_spec.rb12
-rw-r--r--spec/requests/api/jobs_spec.rb4
-rw-r--r--spec/requests/api/merge_requests_spec.rb35
-rw-r--r--spec/requests/api/projects_spec.rb60
-rw-r--r--spec/requests/api/releases_spec.rb25
-rw-r--r--spec/requests/api/runner_spec.rb2
-rw-r--r--spec/requests/api/settings_spec.rb4
-rw-r--r--spec/requests/api/triggers_spec.rb14
-rw-r--r--spec/requests/api/users_spec.rb2
-rw-r--r--spec/requests/lfs_http_spec.rb23
-rw-r--r--spec/requests/openid_connect_spec.rb44
-rw-r--r--spec/requests/user_activity_spec.rb114
-rw-r--r--spec/serializers/cluster_application_entity_spec.rb8
-rw-r--r--spec/serializers/deployment_entity_spec.rb30
-rw-r--r--spec/serializers/environment_serializer_spec.rb4
-rw-r--r--spec/serializers/merge_request_widget_commit_entity_spec.rb21
-rw-r--r--spec/serializers/merge_request_widget_entity_spec.rb61
-rw-r--r--spec/services/clusters/applications/check_installation_progress_service_spec.rb123
-rw-r--r--spec/services/clusters/applications/create_service_spec.rb17
-rw-r--r--spec/services/clusters/applications/schedule_installation_service_spec.rb24
-rw-r--r--spec/services/clusters/applications/upgrade_service_spec.rb128
-rw-r--r--spec/services/error_tracking/list_projects_service_spec.rb149
-rw-r--r--spec/services/issues/update_service_spec.rb2
-rw-r--r--spec/services/members/create_service_spec.rb9
-rw-r--r--spec/services/members/destroy_service_spec.rb60
-rw-r--r--spec/services/merge_requests/create_service_spec.rb18
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb2
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb8
-rw-r--r--spec/services/merge_requests/squash_service_spec.rb71
-rw-r--r--spec/services/merge_requests/update_service_spec.rb2
-rw-r--r--spec/services/notes/build_service_spec.rb49
-rw-r--r--spec/services/notes/create_service_spec.rb41
-rw-r--r--spec/services/notification_service_spec.rb29
-rw-r--r--spec/services/preview_markdown_service_spec.rb19
-rw-r--r--spec/services/projects/create_service_spec.rb12
-rw-r--r--spec/services/projects/fork_service_spec.rb2
-rw-r--r--spec/services/projects/import_error_filter_spec.rb17
-rw-r--r--spec/services/projects/import_service_spec.rb13
-rw-r--r--spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb18
-rw-r--r--spec/services/projects/lfs_pointers/lfs_download_service_spec.rb162
-rw-r--r--spec/services/projects/transfer_service_spec.rb2
-rw-r--r--spec/services/projects/update_pages_configuration_service_spec.rb32
-rw-r--r--spec/services/projects/update_pages_service_spec.rb35
-rw-r--r--spec/services/projects/update_service_spec.rb2
-rw-r--r--spec/services/task_list_toggle_service_spec.rb96
-rw-r--r--spec/support/helpers/features/responsive_table_helpers.rb32
-rw-r--r--spec/support/helpers/stub_configuration.rb4
-rw-r--r--spec/support/helpers/stub_object_storage.rb7
-rw-r--r--spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb10
-rw-r--r--spec/support/shared_examples/issuable_shared_examples.rb73
-rw-r--r--spec/support/shared_examples/models/cluster_application_initial_status.rb29
-rw-r--r--spec/support/shared_examples/models/cluster_application_status_shared_examples.rb85
-rw-r--r--spec/support/shared_examples/models/cluster_application_version_shared_examples.rb20
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb38
-rw-r--r--spec/uploaders/external_diff_uploader_spec.rb67
-rw-r--r--spec/validators/js_regex_validator_spec.rb2
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb54
-rw-r--r--spec/views/projects/commit/_commit_box.html.haml_spec.rb6
-rw-r--r--spec/views/projects/issues/_related_branches.html.haml_spec.rb4
-rw-r--r--spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb53
-rw-r--r--spec/workers/mail_scheduler/notification_service_worker_spec.rb49
-rw-r--r--spec/workers/post_receive_spec.rb15
-rw-r--r--spec/workers/repository_fork_worker_spec.rb7
-rw-r--r--vendor/project_templates/gitbook.tar.gzbin0 -> 13808 bytes
-rw-r--r--vendor/project_templates/hexo.tar.gzbin0 -> 548020 bytes
-rw-r--r--vendor/project_templates/hugo.tar.gzbin0 -> 1048753 bytes
-rw-r--r--vendor/project_templates/jekyll.tar.gzbin0 -> 60703 bytes
-rw-r--r--vendor/project_templates/plainhtml.tar.gzbin0 -> 12079 bytes
-rw-r--r--yarn.lock39
962 files changed, 16882 insertions, 6956 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 16c56747711..4e8453726a3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -120,9 +120,8 @@ stages:
variables: &single-script-job-variables
GIT_STRATEGY: none
before_script:
- # We need to download the script rather than clone the repo since the
- # package-and-qa job will not be able to run when the branch gets
- # deleted (when merging the MR).
+ # We don't clone the repo by using GIT_STRATEGY: none and only download the
+ # single script we need here so it's much faster than cloning.
- export SCRIPT_NAME="${SCRIPT_NAME:-$CI_JOB_NAME}"
- apk add --update openssl
- wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/$SCRIPT_NAME
@@ -228,20 +227,21 @@ stages:
# Trigger a package build in omnibus-gitlab repository
#
package-and-qa:
- <<: *single-script-job
+ image: ruby:2.5-alpine
+ stage: test
+ before_script: []
+ dependencies: []
+ cache: {}
variables:
- <<: *single-script-job-variables
+ GIT_DEPTH: "1"
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}"
- SCRIPT_NAME: trigger-build
retry: 0
script:
- - gem install gitlab --no-document
- apk add --update openssl curl jq
- - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/review_apps/review-apps.sh
- - chmod 755 review-apps.sh
- - source ./review-apps.sh
+ - gem install gitlab --no-document
+ - source ./scripts/review_apps/review-apps.sh
- wait_for_job_to_be_done "gitlab:assets:compile"
- - ./$SCRIPT_NAME omnibus
+ - ./scripts/trigger-build omnibus
when: manual
only:
- //@gitlab-org/gitlab-ce
@@ -386,20 +386,27 @@ flaky-examples-check:
- scripts/merge-reports ${NEW_FLAKY_SPECS_REPORT} rspec_flaky/new_*_*.json
- scripts/detect-new-flaky-examples $NEW_FLAKY_SPECS_REPORT
+.assets-compile-cache: &assets-compile-cache
+ cache:
+ key: "assets-compile:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:v4"
+ paths:
+ - vendor/ruby/
+ - .yarn-cache/
+ # We have disabled caching of sprockets for now, as it fails to pick up changes in SCSS:
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/57431
+ # - tmp/cache/assets/sprockets
+
compile-assets:
<<: *dedicated-runner
<<: *except-docs
<<: *use-pg
stage: prepare
- cache:
- <<: *default-cache
script:
- node --version
- - date
- yarn install --frozen-lockfile --cache-folder .yarn-cache
- - date
- free -m
- bundle exec rake gitlab:assets:compile
+ - scripts/clean-old-cached-assets
variables:
# we override the max_old_space_size to prevent OOM errors
NODE_OPTIONS: --max_old_space_size=3584
@@ -408,6 +415,7 @@ compile-assets:
paths:
- node_modules
- public/assets
+ <<: *assets-compile-cache
setup-test-env:
<<: *dedicated-runner
@@ -628,7 +636,8 @@ gitlab:setup-mysql:
gitlab:assets:compile:
<<: *dedicated-no-docs-pull-cache-job
image: dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-git-2.18-chrome-71.0-node-8.x-yarn-1.12-graphicsmagick-1.3.29-docker-18.06.1
- dependencies: []
+ dependencies:
+ - setup-test-env
services:
- docker:stable-dind
variables:
@@ -642,18 +651,19 @@ gitlab:assets:compile:
DOCKER_DRIVER: overlay2
DOCKER_HOST: tcp://docker:2375
script:
- - date
+ - node --version
- yarn install --frozen-lockfile --production --cache-folder .yarn-cache
- - date
- free -m
- bundle exec rake gitlab:assets:compile
- - scripts/build_assets_image
+ - time scripts/build_assets_image
+ - scripts/clean-old-cached-assets
artifacts:
name: webpack-report
expire_in: 31d
paths:
- webpack-report/
- public/assets/
+ <<: *assets-compile-cache
only:
- //@gitlab-org/gitlab-ce
- //@gitlab-org/gitlab-ee
@@ -951,20 +961,21 @@ no_ee_check:
# GitLab Review apps
review-build-cng:
- <<: *single-script-job
<<: *review-only
+ image: ruby:2.5-alpine
+ stage: test
+ before_script: []
+ dependencies: []
+ cache: {}
variables:
- <<: *single-script-job-variables
- SCRIPT_NAME: trigger-build
+ GIT_DEPTH: "1"
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}"
script:
- - gem install gitlab --no-document
- apk add --update openssl curl jq
- - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/review_apps/review-apps.sh
- - chmod 755 review-apps.sh
- - source ./review-apps.sh
+ - gem install gitlab --no-document
+ - source ./scripts/review_apps/review-apps.sh
- wait_for_job_to_be_done "gitlab:assets:compile"
- - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./$SCRIPT_NAME cng
+ - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./scripts/trigger-build cng
review-deploy:
<<: *review-base
diff --git a/.gitlab/issue_templates/Feature proposal.md b/.gitlab/issue_templates/Feature proposal.md
index 0b22c7bc26b..1bb8d33ff63 100644
--- a/.gitlab/issue_templates/Feature proposal.md
+++ b/.gitlab/issue_templates/Feature proposal.md
@@ -39,7 +39,7 @@ Existing personas are: (copy relevant personas out of this comment, and delete a
### What does success look like, and how can we measure that?
-<!--- Define both the success metrics and acceptance criteria. Note thet success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this -->
+<!--- Define both the success metrics and acceptance criteria. Note that success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this -->
### Links / references
diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md
index 4bc4215d21b..aaa16145399 100644
--- a/.gitlab/issue_templates/Security developer workflow.md
+++ b/.gitlab/issue_templates/Security developer workflow.md
@@ -20,10 +20,9 @@ Set the title to: `Description of the original issue`
- [ ] Once the MR is ready to be merged, create MRs targetting the last 3 releases, plus the current RC if between the 7th and 22nd of the month.
- [ ] At this point, it might be easy to squash the commits from the MR into one
- You can use the script `bin/secpick` instead of the following steps, to help you cherry-picking. See the [secpick documentation]
- - [ ] Create the branch `security-X-Y` from `X-Y-stable` if it doesn't exist (and make sure it's up to date with stable)
- - [ ] Create each MR targetting the security branch `security-X-Y`
- - [ ] Add the ~security label and prefix with the version `WIP: [X.Y]` the title of the MR
-- [ ] Add the ~"Merge into Security" label to all of the MRs.
+ - [ ] Create each MR targetting the stable branch `X-Y-stable`, using the "Security Release" merge request template.
+ - Every merge request will have its own set of TODOs, so make sure to
+ complete those.
- [ ] Make sure all MRs have a link in the [links section](#links)
[secpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#secpick-script
diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md
index d72b4eb1cb6..246f2dae009 100644
--- a/.gitlab/merge_request_templates/Security Release.md
+++ b/.gitlab/merge_request_templates/Security Release.md
@@ -4,15 +4,18 @@ This MR should be created on `dev.gitlab.org`.
See [the general developer security release guidelines](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md).
+This merge request _must not_ close the corresponding security issue _unless_ it
+targets master.
+
-->
## Related issues
<!-- Mention the issue(s) this MR is related to -->
-## Author's checklist
+## Developer checklist
- [ ] Link to the developer security workflow issue on `dev.gitlab.org`
-- [ ] MR targets `master` or `security-X-Y` for backports
+- [ ] MR targets `master`, or `X-Y-stable` for backports
- [ ] Milestone is set for the version this MR applies to
- [ ] Title of this MR is the same as for all backports
- [ ] A [CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html) is added without a `merge_request` value, with `type` set to `security`
@@ -20,9 +23,9 @@ See [the general developer security release guidelines](https://gitlab.com/gitla
- [ ] Add a link to an EE MR if required
- [ ] Assign to a reviewer
-## Reviewers checklist
+## Reviewer checklist
- [ ] Correct milestone is applied and the title is matching across all backports
- [ ] Assigned to `@gitlab-release-tools-bot` with passing CI pipelines
-/label ~security ~"Merge into Security"
+/label ~security
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index c42d11a860e..77ad4753c84 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -80,11 +80,6 @@ Lint/InterpolationCheck:
Lint/MissingCopEnableDirective:
Enabled: false
-# Offense count: 1
-Lint/ReturnInVoidContext:
- Exclude:
- - 'app/models/project.rb'
-
# Offense count: 9
Lint/UriEscapeUnescape:
Exclude:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1deab58d38..e220d61b316 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,57 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 11.7.5 (2019-02-06)
+
+### Fixed (8 changes)
+
+- Fix import handling errors in Bitbucket Server importer. !24499
+- Adjusts suggestions unable to be applied. !24603
+- Fix 500 errors with legacy appearance logos. !24615
+- Fix form functionality for edit tag page. !24645
+- Update Workhorse to v8.0.2. !24870
+- Downcase aliased OAuth2 callback providers. !24877
+- Fix Detect Host Keys not working. !24884
+- Changed external wiki query method to prevent attribute caching. !24907
+
+
+## 11.7.2 (2019-01-29)
+
+### Fixed (1 change)
+
+- Fix uninitialized constant with GitLab Pages.
+
+
+## 11.7.1 (2019-01-28)
+
+### Security (24 changes)
+
+- Make potentially malicious links more visible in the UI and scrub RTLO chars from links. !2770
+- Don't process MR refs for guests in the notes. !2771
+- Sanitize user full name to clean up any URL to prevent mail clients from auto-linking URLs. !2828
+- Fixed XSS content in KaTex links.
+- Disallows unauthorized users from accessing the pipelines section.
+- Verify that LFS upload requests are genuine.
+- Extract GitLab Pages using RubyZip.
+- Prevent awarding emojis to notes whose parent is not visible to user.
+- Prevent unauthorized replies when discussion is locked or confidential.
+- Disable git v2 protocol temporarily.
+- Fix showing ci status for guest users when public pipline are not set.
+- Fix contributed projects info still visible when user enable private profile.
+- Add subresources removal to member destroy service.
+- Add more LFS validations to prevent forgery.
+- Use common error for unauthenticated users when creating issues.
+- Fix slow regex in project reference pattern.
+- Fix private user email being visible in push (and tag push) webhooks.
+- Fix wiki access rights when external wiki is enabled.
+- Group guests are no longer able to see merge requests they don't have access to at group level.
+- Fix path disclosure on project import error.
+- Restrict project import visibility based on its group.
+- Expose CI/CD trigger token only to the trigger owner.
+- Notify only users who can access the project on project move.
+- Alias GitHub and BitBucket OAuth2 callback URLs.
+
+
## 11.7.0 (2019-01-22)
### Security (14 changes, 1 of them is from the community)
@@ -188,6 +239,10 @@ entry.
- Update url placeholder for the sentry configuration page. !24338
+## 11.6.8 (2019-01-30)
+
+- No changes.
+
## 11.6.5 (2019-01-17)
### Fixed (5 changes)
@@ -528,6 +583,33 @@ entry.
- Enable Rubocop on lib/gitlab. (gfyoung)
+## 11.5.8 (2019-01-28)
+
+### Security (21 changes)
+
+- Make potentially malicious links more visible in the UI and scrub RTLO chars from links. !2770
+- Don't process MR refs for guests in the notes. !2771
+- Fixed XSS content in KaTex links.
+- Verify that LFS upload requests are genuine.
+- Extract GitLab Pages using RubyZip.
+- Prevent awarding emojis to notes whose parent is not visible to user.
+- Prevent unauthorized replies when discussion is locked or confidential.
+- Disable git v2 protocol temporarily.
+- Fix showing ci status for guest users when public pipline are not set.
+- Fix contributed projects info still visible when user enable private profile.
+- Disallows unauthorized users from accessing the pipelines section.
+- Add more LFS validations to prevent forgery.
+- Use common error for unauthenticated users when creating issues.
+- Fix slow regex in project reference pattern.
+- Fix private user email being visible in push (and tag push) webhooks.
+- Fix wiki access rights when external wiki is enabled.
+- Fix path disclosure on project import error.
+- Restrict project import visibility based on its group.
+- Expose CI/CD trigger token only to the trigger owner.
+- Notify only users who can access the project on project move.
+- Alias GitHub and BitBucket OAuth2 callback URLs.
+
+
## 11.5.5 (2018-12-20)
### Security (1 change)
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index cd99d386a8d..815d5ca06d5 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-1.14.0 \ No newline at end of file
+1.19.0
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index 88c5fb891dc..bc80560fad6 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-1.4.0
+1.5.0
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index da156181014..2bf50aaf17a 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-8.1.0 \ No newline at end of file
+8.3.0
diff --git a/Gemfile b/Gemfile
index 943b0260dcb..a3b01c275ce 100644
--- a/Gemfile
+++ b/Gemfile
@@ -57,6 +57,7 @@ gem 'u2f', '~> 0.2.1'
# GitLab Pages
gem 'validates_hostname', '~> 1.0.6'
+gem 'rubyzip', '~> 1.2.2', require: 'zip'
# Browser detection
gem 'browser', '~> 2.5'
@@ -112,10 +113,9 @@ gem 'seed-fu', '~> 2.3.7'
# Markdown and HTML processing
gem 'html-pipeline', '~> 2.8'
-gem 'deckar01-task_list', '2.0.1'
+gem 'deckar01-task_list', '2.2.0'
gem 'gitlab-markup', '~> 1.6.5'
gem 'github-markup', '~> 1.7.0', require: 'github/markup'
-gem 'redcarpet', '~> 3.4'
gem 'commonmarker', '~> 0.17'
gem 'RedCloth', '~> 4.3.2'
gem 'rdoc', '~> 6.0'
@@ -187,7 +187,7 @@ gem 're2', '~> 1.1.1'
gem 'version_sorter', '~> 2.1.0'
# Export Ruby Regex to Javascript
-gem 'js_regex', '~> 2.2.1'
+gem 'js_regex', '~> 3.1'
# User agent parsing
gem 'device_detector'
@@ -422,7 +422,7 @@ group :ed25519 do
end
# Gitaly GRPC client
-gem 'gitaly-proto', '~> 1.5.0', require: 'gitaly'
+gem 'gitaly-proto', '~> 1.10.0', require: 'gitaly'
gem 'grpc', '~> 1.15.0'
gem 'google-protobuf', '~> 3.6'
diff --git a/Gemfile.lock b/Gemfile.lock
index ec6af6ffb0c..0b2bd2c96bd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -113,6 +113,7 @@ GEM
activesupport (>= 4.0.0)
mime-types (>= 1.16)
cause (0.1)
+ character_set (1.1.2)
charlock_holmes (0.7.6)
childprocess (0.9.0)
ffi (~> 1.0, >= 1.0.11)
@@ -143,7 +144,7 @@ GEM
database_cleaner (1.7.0)
debug_inspector (0.0.3)
debugger-ruby_core_source (1.3.8)
- deckar01-task_list (2.0.1)
+ deckar01-task_list (2.2.0)
html-pipeline
declarative (0.0.10)
declarative-option (0.1.0)
@@ -277,7 +278,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
- gitaly-proto (1.5.0)
+ gitaly-proto (1.10.0)
grpc (~> 1.0)
github-markup (1.7.0)
gitlab-default_value_for (3.1.1)
@@ -400,8 +401,10 @@ GEM
multipart-post
oauth (~> 0.5, >= 0.5.0)
jquery-atwho-rails (1.3.2)
- js_regex (2.2.1)
- regexp_parser (>= 0.4.11, <= 0.5.0)
+ js_regex (3.1.1)
+ character_set (~> 1.1)
+ regexp_parser (~> 1.1)
+ regexp_property_values (~> 0.3)
json (1.8.6)
json-jwt (1.9.4)
activesupport
@@ -682,7 +685,6 @@ GEM
recaptcha (3.0.0)
json
recursive-open-struct (1.1.0)
- redcarpet (3.4.0)
redis (3.3.5)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
@@ -702,7 +704,8 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.6.0)
redis (>= 2.2, < 5)
- regexp_parser (0.5.0)
+ regexp_parser (1.3.0)
+ regexp_property_values (0.3.4)
representable (3.0.4)
declarative (< 0.1.0)
declarative-option (< 0.2.0)
@@ -982,7 +985,7 @@ DEPENDENCIES
connection_pool (~> 2.0)
creole (~> 0.5.0)
database_cleaner (~> 1.7.0)
- deckar01-task_list (= 2.0.1)
+ deckar01-task_list (= 2.2.0)
device_detector
devise (~> 4.4)
devise-two-factor (~> 3.0.0)
@@ -1017,7 +1020,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly-proto (~> 1.5.0)
+ gitaly-proto (~> 1.10.0)
github-markup (~> 1.7.0)
gitlab-default_value_for (~> 3.1.1)
gitlab-markup (~> 1.6.5)
@@ -1049,7 +1052,7 @@ DEPENDENCIES
jaeger-client (~> 0.10.0)
jira-ruby (~> 1.4)
jquery-atwho-rails (~> 1.3.2)
- js_regex (~> 2.2.1)
+ js_regex (~> 3.1)
json-schema (~> 2.8.0)
jwt (~> 2.1.0)
kaminari (~> 1.0)
@@ -1118,7 +1121,6 @@ DEPENDENCIES
rdoc (~> 6.0)
re2 (~> 1.1.1)
recaptcha (~> 3.0)
- redcarpet (~> 3.4)
redis (~> 3.2)
redis-namespace (~> 1.6.0)
redis-rails (~> 5.0.2)
@@ -1138,6 +1140,7 @@ DEPENDENCIES
ruby-prof (~> 0.17.0)
ruby-progressbar
ruby_parser (~> 3.8)
+ rubyzip (~> 1.2.2)
rugged (~> 0.27)
sanitize (~> 4.6)
sass (~> 3.5)
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index d1396b6c4bc..85eb08cc97d 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils';
const Api = {
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
+ groupMembersPath: '/api/:version/groups/:id/members',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
@@ -40,6 +41,12 @@ const Api = {
});
},
+ groupMembers(id) {
+ const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
// Return groups list. Filtered by query
groups(query, options, callback = $.noop) {
const url = Api.buildUrl(Api.groupsPath);
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index cace8bb9dba..73ce3e760ab 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -437,7 +437,7 @@ export class AwardsHandler {
createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
- <button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
+ <button class="btn award-control js-emoji-btn has-tooltip active" title="You">
${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 56293d5f96f..d1d75658181 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,11 +1,10 @@
-import installCustomElements from 'document-register-element';
+import 'document-register-element';
import isEmojiUnicodeSupported from '../emoji/support';
-installCustomElements(window);
+class GlEmoji extends HTMLElement {
+ constructor() {
+ super();
-export default function installGlEmojiElement() {
- const GlEmojiElementProto = Object.create(HTMLElement.prototype);
- GlEmojiElementProto.createdCallback = function createdCallback() {
const emojiUnicode = this.textContent.trim();
const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset;
@@ -43,9 +42,11 @@ export default function installGlEmojiElement() {
});
}
}
- };
+ }
+}
- document.registerElement('gl-emoji', {
- prototype: GlEmojiElementProto,
- });
+export default function installGlEmojiElement() {
+ if (!customElements.get('gl-emoji')) {
+ customElements.define('gl-emoji', GlEmoji);
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 947d019c725..52d9f2f0322 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -1,8 +1,5 @@
import $ from 'jquery';
-import { DOMParser } from 'prosemirror-model';
import { getSelectedFragment } from '~/lib/utils/common_utils';
-import schema from './schema';
-import markdownSerializer from './serializer';
export class CopyAsGFM {
constructor() {
@@ -39,9 +36,13 @@ export class CopyAsGFM {
div.appendChild(el.cloneNode(true));
const html = div.innerHTML;
- clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
- clipboardData.setData('text/html', html);
+ CopyAsGFM.nodeToGFM(el)
+ .then(res => {
+ clipboardData.setData('text/plain', el.textContent);
+ clipboardData.setData('text/x-gfm', res);
+ clipboardData.setData('text/html', html);
+ })
+ .catch(() => {});
}
static pasteGFM(e) {
@@ -137,11 +138,21 @@ export class CopyAsGFM {
}
static nodeToGFM(node) {
- const wrapEl = document.createElement('div');
- wrapEl.appendChild(node.cloneNode(true));
- const doc = DOMParser.fromSchema(schema).parse(wrapEl);
-
- return markdownSerializer.serialize(doc);
+ return Promise.all([
+ import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'),
+ ])
+ .then(([prosemirrorModel, schema, markdownSerializer]) => {
+ const { DOMParser } = prosemirrorModel;
+ const wrapEl = document.createElement('div');
+ wrapEl.appendChild(node.cloneNode(true));
+ const doc = DOMParser.fromSchema(schema.default).parse(wrapEl);
+
+ const res = markdownSerializer.default.serialize(doc);
+ return res;
+ })
+ .catch(() => {});
}
}
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 35f1bb6b080..7adccbb062f 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -28,16 +28,13 @@ MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function($form) {
var mdText;
- var markdownVersion;
- var url;
var preview = $form.find('.js-md-preview');
+ var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
- markdownVersion = $form.attr('data-markdown-version');
- url = this.versionedPreviewPath(preview.data('url'), markdownVersion);
if (mdText.trim().length === 0) {
preview.text(this.emptyMessage);
@@ -67,16 +64,6 @@ MarkdownPreview.prototype.showPreview = function($form) {
}
};
-MarkdownPreview.prototype.versionedPreviewPath = function(markdownPreviewPath, markdownVersion) {
- if (typeof markdownVersion === 'undefined') {
- return markdownPreviewPath;
- }
-
- return `${markdownPreviewPath}${
- markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
- }markdown_version=${markdownVersion}`;
-};
-
MarkdownPreview.prototype.fetchMarkdownPreview = function(text, url, success) {
if (!url) {
return;
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 0eb067d4963..680f2031409 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -64,26 +64,30 @@ export default class ShortcutsIssuable extends Shortcuts {
const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
const blockquoteEl = document.createElement('blockquote');
blockquoteEl.appendChild(el);
- const text = CopyAsGFM.nodeToGFM(blockquoteEl);
-
- if (text.trim() === '') {
- return false;
- }
-
- // If replyField already has some content, add a newline before our quote
- const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
- $replyField
- .val((a, current) => `${current}${separator}${text}\n\n`)
- .trigger('input')
- .trigger('change');
-
- // Trigger autosize
- const event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- $replyField.get(0).dispatchEvent(event);
+ CopyAsGFM.nodeToGFM(blockquoteEl)
+ .then(text => {
+ if (text.trim() === '') {
+ return false;
+ }
+
+ // If replyField already has some content, add a newline before our quote
+ const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
+ $replyField
+ .val((a, current) => `${current}${separator}${text}\n\n`)
+ .trigger('input')
+ .trigger('change');
+
+ // Trigger autosize
+ const event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ $replyField.get(0).dispatchEvent(event);
+
+ // Focus the input field
+ $replyField.focus();
- // Focus the input field
- $replyField.focus();
+ return false;
+ })
+ .catch(() => {});
return false;
}
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index b1f992c03ff..6ebd1ad109e 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -6,7 +6,13 @@ import Flash from '../flash';
import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
-import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants';
+import {
+ APPLICATION_STATUS,
+ REQUEST_SUBMITTED,
+ REQUEST_FAILURE,
+ UPGRADE_REQUESTED,
+ UPGRADE_REQUEST_FAILURE,
+} from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
@@ -120,11 +126,17 @@ export default class Clusters {
addListeners() {
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
eventHub.$on('installApplication', this.installApplication);
+ eventHub.$on('upgradeApplication', data => this.upgradeApplication(data));
+ eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId));
+ eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId));
}
removeListeners() {
if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken);
eventHub.$off('installApplication', this.installApplication);
+ eventHub.$off('upgradeApplication', this.upgradeApplication);
+ eventHub.$off('upgradeFailed', this.upgradeFailed);
+ eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess);
}
initPolling() {
@@ -231,22 +243,33 @@ export default class Clusters {
installApplication(data) {
const appId = data.id;
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING);
+ this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED);
this.store.updateAppProperty(appId, 'requestReason', null);
+ this.store.updateAppProperty(appId, 'statusReason', null);
+
+ this.service.installApplication(appId, data.params).catch(() => {
+ this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
+ this.store.updateAppProperty(
+ appId,
+ 'requestReason',
+ s__('ClusterIntegration|Request to begin installing failed'),
+ );
+ });
+ }
+
+ upgradeApplication(data) {
+ const appId = data.id;
+ this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED);
+ this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING);
+ this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId));
+ }
+
+ upgradeFailed(appId) {
+ this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE);
+ }
- this.service
- .installApplication(appId, data.params)
- .then(() => {
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS);
- })
- .catch(() => {
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
- this.store.updateAppProperty(
- appId,
- 'requestReason',
- s__('ClusterIntegration|Request to begin installing failed'),
- );
- });
+ dismissUpgradeSuccess(appId) {
+ this.store.updateAppProperty(appId, 'requestStatus', null);
}
destroy() {
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index d4354dcfebd..5952e93b9a7 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -1,20 +1,24 @@
<script>
/* eslint-disable vue/require-default-prop */
+import { GlLink } from '@gitlab/ui';
+import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
import { s__, sprintf } from '../../locale';
import eventHub from '../event_hub';
import identicon from '../../vue_shared/components/identicon.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
import {
APPLICATION_STATUS,
- REQUEST_LOADING,
- REQUEST_SUCCESS,
+ REQUEST_SUBMITTED,
REQUEST_FAILURE,
+ UPGRADE_REQUESTED,
} from '../constants';
export default {
components: {
loadingButton,
identicon,
+ TimeagoTooltip,
+ GlLink,
},
props: {
id: {
@@ -59,6 +63,18 @@ export default {
type: String,
required: false,
},
+ version: {
+ type: String,
+ required: false,
+ },
+ chartRepo: {
+ type: String,
+ required: false,
+ },
+ upgradeAvailable: {
+ type: Boolean,
+ required: false,
+ },
installApplicationRequestParams: {
type: Object,
required: false,
@@ -72,11 +88,31 @@ export default {
isKnownStatus() {
return Object.values(APPLICATION_STATUS).includes(this.status);
},
+ isInstalling() {
+ return (
+ this.status === APPLICATION_STATUS.SCHEDULED ||
+ this.status === APPLICATION_STATUS.INSTALLING ||
+ (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled)
+ );
+ },
isInstalled() {
return (
this.status === APPLICATION_STATUS.INSTALLED ||
this.status === APPLICATION_STATUS.UPDATED ||
- this.status === APPLICATION_STATUS.UPDATING
+ this.status === APPLICATION_STATUS.UPDATING ||
+ this.status === APPLICATION_STATUS.UPDATE_ERRORED
+ );
+ },
+ canInstall() {
+ if (this.isInstalling) {
+ return false;
+ }
+
+ return (
+ this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
+ this.status === APPLICATION_STATUS.INSTALLABLE ||
+ this.status === APPLICATION_STATUS.ERROR ||
+ this.isUnknownStatus
);
},
hasLogo() {
@@ -90,12 +126,7 @@ export default {
return `js-cluster-application-row-${this.id}`;
},
installButtonLoading() {
- return (
- !this.status ||
- this.status === APPLICATION_STATUS.SCHEDULED ||
- this.status === APPLICATION_STATUS.INSTALLING ||
- this.requestStatus === REQUEST_LOADING
- );
+ return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling;
},
installButtonDisabled() {
// Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but
@@ -104,30 +135,17 @@ export default {
return (
((this.status !== APPLICATION_STATUS.INSTALLABLE &&
this.status !== APPLICATION_STATUS.ERROR) ||
- this.requestStatus === REQUEST_LOADING ||
- this.requestStatus === REQUEST_SUCCESS) &&
+ this.isInstalling) &&
this.isKnownStatus
);
},
installButtonLabel() {
let label;
- if (
- this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
- this.status === APPLICATION_STATUS.INSTALLABLE ||
- this.status === APPLICATION_STATUS.ERROR ||
- this.isUnknownStatus
- ) {
+ if (this.canInstall) {
label = s__('ClusterIntegration|Install');
- } else if (
- this.status === APPLICATION_STATUS.SCHEDULED ||
- this.status === APPLICATION_STATUS.INSTALLING
- ) {
+ } else if (this.isInstalling) {
label = s__('ClusterIntegration|Installing');
- } else if (
- this.status === APPLICATION_STATUS.INSTALLED ||
- this.status === APPLICATION_STATUS.UPDATED ||
- this.status === APPLICATION_STATUS.UPDATING
- ) {
+ } else if (this.isInstalled) {
label = s__('ClusterIntegration|Installed');
}
@@ -140,13 +158,79 @@ export default {
return s__('ClusterIntegration|Manage');
},
hasError() {
- return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE;
+ return (
+ !this.isInstalling &&
+ (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE)
+ );
},
generalErrorDescription() {
return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), {
title: this.title,
});
},
+ versionLabel() {
+ if (this.upgradeFailed) {
+ return s__('ClusterIntegration|Upgrade failed');
+ } else if (this.isUpgrading) {
+ return s__('ClusterIntegration|Upgrading');
+ }
+
+ return s__('ClusterIntegration|Upgraded');
+ },
+ upgradeRequested() {
+ return this.requestStatus === UPGRADE_REQUESTED;
+ },
+ upgradeSuccessful() {
+ return this.status === APPLICATION_STATUS.UPDATED;
+ },
+ upgradeFailed() {
+ if (this.isUpgrading) {
+ return false;
+ }
+
+ return this.status === APPLICATION_STATUS.UPDATE_ERRORED;
+ },
+ upgradeFailureDescription() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again.',
+ ),
+ {
+ title: this.title,
+ },
+ );
+ },
+ upgradeSuccessDescription() {
+ return sprintf(s__('ClusterIntegration|%{title} upgraded successfully.'), {
+ title: this.title,
+ });
+ },
+ upgradeButtonLabel() {
+ let label;
+ if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) {
+ label = s__('ClusterIntegration|Upgrade');
+ } else if (this.isUpgrading) {
+ label = s__('ClusterIntegration|Upgrading');
+ } else if (this.upgradeFailed) {
+ label = s__('ClusterIntegration|Retry upgrade');
+ }
+
+ return label;
+ },
+ isUpgrading() {
+ // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend
+ return (
+ this.status === APPLICATION_STATUS.UPDATING ||
+ (this.upgradeRequested && !this.upgradeSuccessful)
+ );
+ },
+ },
+ watch: {
+ status() {
+ if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) {
+ eventHub.$emit('upgradeFailed', this.id);
+ }
+ },
},
methods: {
installClicked() {
@@ -155,6 +239,15 @@ export default {
params: this.installApplicationRequestParams,
});
},
+ upgradeClicked() {
+ eventHub.$emit('upgradeApplication', {
+ id: this.id,
+ params: this.installApplicationRequestParams,
+ });
+ },
+ dismissUpgradeSuccess() {
+ eventHub.$emit('dismissUpgradeSuccess', this.id);
+ },
},
};
</script>
@@ -208,6 +301,51 @@ export default {
</li>
</ul>
</div>
+
+ <div
+ v-if="(upgradeSuccessful || upgradeFailed) && !upgradeAvailable"
+ class="form-text text-muted label p-0 js-cluster-application-upgrade-details"
+ >
+ {{ versionLabel }}
+
+ <span v-if="upgradeSuccessful"> to</span>
+
+ <gl-link
+ v-if="upgradeSuccessful"
+ :href="chartRepo"
+ target="_blank"
+ class="js-cluster-application-upgrade-version"
+ >
+ chart v{{ version }}
+ </gl-link>
+ </div>
+
+ <div
+ v-if="upgradeFailed && !isUpgrading"
+ class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message"
+ >
+ {{ upgradeFailureDescription }}
+ </div>
+
+ <div
+ v-if="upgradeRequested && upgradeSuccessful"
+ class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3"
+ >
+ {{ upgradeSuccessDescription }}
+
+ <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess">
+ &times;
+ </button>
+ </div>
+
+ <loading-button
+ v-if="upgradeAvailable || upgradeFailed || isUpgrading"
+ class="btn btn-primary js-cluster-application-upgrade-button mt-2"
+ :loading="isUpgrading"
+ :disabled="isUpgrading"
+ :label="upgradeButtonLabel"
+ @click="upgradeClicked"
+ />
</div>
<div
:class="{ 'section-25': showManageButton, 'section-15': !showManageButton }"
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 5d19c79570a..0cf187d4189 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -362,6 +362,9 @@ export default {
:status-reason="applications.runner.statusReason"
:request-status="applications.runner.requestStatus"
:request-reason="applications.runner.requestReason"
+ :version="applications.runner.version"
+ :chart-repo="applications.runner.chartRepo"
+ :upgrade-available="applications.runner.upgradeAvailable"
:disabled="!helmInstalled"
title-link="https://docs.gitlab.com/runner/"
>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index e31afadf186..39022879d91 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -12,16 +12,19 @@ export const APPLICATION_STATUS = {
SCHEDULED: 'scheduled',
INSTALLING: 'installing',
INSTALLED: 'installed',
- UPDATED: 'updated',
UPDATING: 'updating',
+ UPDATED: 'updated',
+ UPDATE_ERRORED: 'update_errored',
ERROR: 'errored',
};
// These are only used client-side
-export const REQUEST_LOADING = 'request-loading';
-export const REQUEST_SUCCESS = 'request-success';
+export const REQUEST_SUBMITTED = 'request-submitted';
export const REQUEST_FAILURE = 'request-failure';
+export const UPGRADE_REQUESTED = 'upgrade-requested';
+export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';
+export const RUNNER = 'runner';
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 8f74be4e0e6..d309678be27 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,6 +1,6 @@
import { s__ } from '../../locale';
import { parseBoolean } from '../../lib/utils/common_utils';
-import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER } from '../constants';
+import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER, RUNNER } from '../constants';
export default class ClusterStore {
constructor() {
@@ -40,6 +40,9 @@ export default class ClusterStore {
statusReason: null,
requestStatus: null,
requestReason: null,
+ version: null,
+ chartRepo: 'https://gitlab.com/charts/gitlab-runner',
+ upgradeAvailable: null,
},
prometheus: {
title: s__('ClusterIntegration|Prometheus'),
@@ -100,7 +103,13 @@ export default class ClusterStore {
this.state.statusReason = serverState.status_reason;
serverState.applications.forEach(serverAppEntry => {
- const { name: appId, status, status_reason: statusReason } = serverAppEntry;
+ const {
+ name: appId,
+ status,
+ status_reason: statusReason,
+ version,
+ update_available: upgradeAvailable,
+ } = serverAppEntry;
this.state.applications[appId] = {
...(this.state.applications[appId] || {}),
@@ -124,6 +133,9 @@ export default class ClusterStore {
serverAppEntry.hostname || this.state.applications.knative.hostname;
this.state.applications.knative.externalIp =
serverAppEntry.external_ip || this.state.applications.knative.externalIp;
+ } else if (appId === RUNNER) {
+ this.state.applications.runner.version = version;
+ this.state.applications.runner.upgradeAvailable = upgradeAvailable;
}
});
}
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index a7ed175f7a4..009153d0703 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -7,4 +7,3 @@ import 'vendor/jquery.caret';
import 'vendor/jquery.atwho';
import 'vendor/jquery.scrollTo';
import 'jquery.waitforimages';
-import 'select2/select2';
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index d4c1b07093d..f0ce2579ee7 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -129,6 +129,10 @@ export default {
created() {
this.adjustView();
eventHub.$once('fetchedNotesData', this.setDiscussions);
+ eventHub.$once('fetchDiffData', this.fetchData);
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchDiffData', this.fetchData);
},
methods: {
...mapActions(['startTaskList']),
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 3ef54752436..0bf2dde8b96 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -6,6 +6,7 @@ import { polyfillSticky } from '~/lib/utils/sticky';
import Icon from '~/vue_shared/components/icon.vue';
import CompareVersionsDropdown from './compare_versions_dropdown.vue';
import SettingsDropdown from './settings_dropdown.vue';
+import DiffStats from './diff_stats.vue';
export default {
components: {
@@ -14,6 +15,7 @@ export default {
GlLink,
GlButton,
SettingsDropdown,
+ DiffStats,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -35,8 +37,15 @@ export default {
},
},
computed: {
- ...mapState('diffs', ['commit', 'showTreeList', 'startVersion', 'latestVersionPath']),
- ...mapGetters('diffs', ['hasCollapsedFile']),
+ ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']),
+ ...mapState('diffs', [
+ 'commit',
+ 'showTreeList',
+ 'startVersion',
+ 'latestVersionPath',
+ 'addedLines',
+ 'removedLines',
+ ]),
comparableDiffs() {
return this.mergeRequestDiffs.slice(1);
},
@@ -104,6 +113,11 @@ export default {
<gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link>
</div>
<div class="inline-parallel-buttons d-none d-md-flex ml-auto">
+ <diff-stats
+ :diff-files-length="diffFilesLength"
+ :added-lines="addedLines"
+ :removed-lines="removedLines"
+ />
<gl-button
v-if="commit || startVersion"
:href="latestVersionPath"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index b58f704bebb..60586d4a607 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -9,6 +9,7 @@ import { GlTooltipDirective } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import EditButton from './edit_button.vue';
+import DiffStats from './diff_stats.vue';
export default {
components: {
@@ -16,6 +17,7 @@ export default {
EditButton,
Icon,
FileIcon,
+ DiffStats,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -202,6 +204,7 @@ export default {
v-if="!diffFile.submodule && addMergeRequestButtons"
class="file-actions d-none d-sm-block"
>
+ <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
<template v-if="diffFile.blob && diffFile.blob.readable_text">
<button
:disabled="!diffHasDiscussions(diffFile)"
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
new file mode 100644
index 00000000000..2e5855380af
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -0,0 +1,52 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { n__ } from '~/locale';
+
+export default {
+ components: { Icon },
+ props: {
+ addedLines: {
+ type: Number,
+ required: true,
+ },
+ removedLines: {
+ type: Number,
+ required: true,
+ },
+ diffFilesLength: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ filesText() {
+ return n__('File', 'Files', this.diffFilesLength);
+ },
+ isCompareVersionsHeader() {
+ return Boolean(this.diffFilesLength);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="diff-stats"
+ :class="{
+ 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader,
+ 'd-inline-flex': !isCompareVersionsHeader,
+ }"
+ >
+ <div v-if="diffFilesLength !== null" class="diff-stats-group">
+ <icon name="doc-code" class="diff-stats-icon text-secondary" />
+ <strong>{{ diffFilesLength }} {{ filesText }}</strong>
+ </div>
+ <div class="diff-stats-group cgreen">
+ <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong>
+ </div>
+ <div class="diff-stats-group cred">
+ <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 0b3def3d29d..96ae197d8b8 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -13,39 +13,17 @@ export default {
Icon,
FileRow,
},
- data() {
- return {
- search: '',
- };
- },
computed: {
- ...mapState('diffs', ['tree', 'addedLines', 'removedLines', 'renderTreeList']),
- ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']),
+ ...mapState('diffs', ['tree', 'renderTreeList']),
+ ...mapGetters('diffs', ['allBlobs']),
filteredTreeList() {
- const search = this.search.toLowerCase().trim();
-
- if (search === '') return this.renderTreeList ? this.tree : this.allBlobs;
-
- return this.allBlobs.reduce((acc, folder) => {
- const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0);
-
- if (tree.length) {
- return acc.concat({
- ...folder,
- tree,
- });
- }
-
- return acc;
- }, []);
+ return this.renderTreeList ? this.tree : this.allBlobs;
},
},
methods: {
- ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
- clearSearch() {
- this.search = '';
- },
+ ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']),
},
+ shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '&#8984;' : 'Ctrl'}+P`,
FileRowStats,
};
</script>
@@ -55,21 +33,17 @@ export default {
<div class="append-bottom-8 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<icon name="search" class="position-absolute tree-list-icon" />
- <input
- v-model="search"
- :placeholder="s__('MergeRequest|Filter files')"
- type="search"
- class="form-control"
- />
<button
- v-show="search"
- :aria-label="__('Clear search')"
type="button"
- class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
- @click="clearSearch"
+ class="form-control text-left text-secondary"
+ @click="toggleFileFinder(true)"
>
- <icon name="close" />
+ {{ s__('MergeRequest|Search files') }}
</button>
+ <span
+ class="position-absolute text-secondary diff-tree-search-shortcut"
+ v-html="$options.shortcutKeyCharacter"
+ ></span>
</div>
</div>
<div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
@@ -90,13 +64,6 @@ export default {
{{ s__('MergeRequest|No files found') }}
</p>
</div>
- <div v-once class="pt-3 pb-3 text-center">
- {{ n__('%d changed file', '%d changed files', diffFilesLength) }}
- <div>
- <span class="cgreen"> {{ n__('%d addition', '%d additions', addedLines) }} </span>
- <span class="cred"> {{ n__('%d deleted', '%d deletions', removedLines) }} </span>
- </div>
- </div>
</div>
</template>
@@ -104,4 +71,15 @@ export default {
.tree-list-blobs .file-row-name {
margin-left: 12px;
}
+
+.diff-tree-search-shortcut {
+ top: 50%;
+ right: 10px;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+.tree-list-icon {
+ pointer-events: none;
+}
</style>
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 094e5cdea9c..63954d9d412 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,11 +1,60 @@
import Vue from 'vue';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
+import FindFile from '~/vue_shared/components/file_finder/index.vue';
+import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
import { TREE_LIST_STORAGE_KEY } from './constants';
export default function initDiffsApp(store) {
+ const fileFinderEl = document.getElementById('js-diff-file-finder');
+
+ if (fileFinderEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: fileFinderEl,
+ store,
+ computed: {
+ ...mapState('diffs', ['fileFinderVisible', 'isLoading']),
+ ...mapGetters('diffs', ['flatBlobsList']),
+ },
+ watch: {
+ fileFinderVisible(newVal, oldVal) {
+ if (newVal && !oldVal && !this.flatBlobsList.length) {
+ eventHub.$emit('fetchDiffData');
+ }
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']),
+ openFile(file) {
+ window.mrTabs.tabShown('diffs');
+ this.scrollToFile(file.path);
+ },
+ },
+ render(createElement) {
+ return createElement(FindFile, {
+ props: {
+ files: this.flatBlobsList,
+ visible: this.fileFinderVisible,
+ loading: this.isLoading,
+ showDiffStats: true,
+ clearSearchOnClose: false,
+ },
+ on: {
+ toggle: this.toggleFileFinder,
+ click: this.openFile,
+ },
+ class: ['diff-file-finder'],
+ style: {
+ display: this.fileFinderVisible ? '' : 'none',
+ },
+ });
+ },
+ });
+ }
+
return new Vue({
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 2c5019fb652..7fb66ce433b 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -296,5 +296,9 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals
}
};
+export const toggleFileFinder = ({ commit }, visible) => {
+ commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 86c0c7190f9..0e1ad654a2b 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -74,24 +74,25 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.file_hash === fileHash);
-export const allBlobs = state =>
- Object.values(state.treeEntries)
- .filter(f => f.type === 'blob')
- .reduce((acc, file) => {
- const { parentPath } = file;
-
- if (parentPath && !acc.some(f => f.path === parentPath)) {
- acc.push({
- path: parentPath,
- isHeader: true,
- tree: [],
- });
- }
-
- acc.find(f => f.path === parentPath).tree.push(file);
-
- return acc;
- }, []);
+export const flatBlobsList = state =>
+ Object.values(state.treeEntries).filter(f => f.type === 'blob');
+
+export const allBlobs = (state, getters) =>
+ getters.flatBlobsList.reduce((acc, file) => {
+ const { parentPath } = file;
+
+ if (parentPath && !acc.some(f => f.path === parentPath)) {
+ acc.push({
+ path: parentPath,
+ isHeader: true,
+ tree: [],
+ });
+ }
+
+ acc.find(f => f.path === parentPath).tree.push(file);
+
+ return acc;
+ }, []);
export const diffFilesLength = state => state.diffFiles.length;
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 05b4c552f6e..47f78a5db54 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -11,6 +11,8 @@ const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY);
export default () => ({
isLoading: true,
+ addedLines: null,
+ removedLines: null,
endpoint: '',
basePath: '',
commit: null,
@@ -29,4 +31,5 @@ export default () => ({
highlightedRow: null,
renderTreeList: true,
showWhitespace: true,
+ fileFinderVisible: false,
});
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index e760b4d1079..71ad108ce88 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -22,3 +22,4 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
export const SET_TREE_DATA = 'SET_TREE_DATA';
export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST';
export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE';
+export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 4aeb393b29b..7bbafe66199 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -244,4 +244,7 @@ export default {
[types.SET_SHOW_WHITESPACE](state, showWhitespace) {
state.showWhitespace = showWhitespace;
},
+ [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) {
+ state.fileFinderVisible = visible;
+ },
};
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index dbfcf8cc921..cb1b1173190 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -64,6 +64,7 @@ class DueDateSelect {
this.saveDueDate(true);
}
},
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($dueDateInput.val()));
@@ -183,6 +184,7 @@ export default class DueDateSelectors {
onSelect(dateText) {
$datePicker.val(calendar.toString(dateText));
},
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate(datePickerVal));
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index bd402c0eea5..6ece8b92a30 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -22,10 +22,6 @@ export default {
type: Object,
required: true,
},
- canCreateDeployment: {
- type: Boolean,
- required: true,
- },
canReadEnvironment: {
type: Boolean,
required: true,
@@ -51,11 +47,7 @@ export default {
<slot name="emptyState"></slot>
<div v-if="!isLoading && environments.length > 0" class="table-holder">
- <environment-table
- :environments="environments"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- />
+ <environment-table :environments="environments" :can-read-environment="canReadEnvironment" />
<table-pagination
v-if="pagination && pagination.totalPages > 1"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index f44806d82a6..503c1b38f71 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -47,12 +47,6 @@ export default {
default: () => ({}),
},
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
- },
-
canReadEnvironment: {
type: Boolean,
required: false,
@@ -151,7 +145,7 @@ export default {
},
actions() {
- if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) {
+ if (!this.model || !this.model.last_deployment) {
return [];
}
@@ -561,7 +555,7 @@ export default {
/>
<rollback-component
- v-if="canRetry && canCreateDeployment"
+ v-if="canRetry"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
/>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 87c1d44dd40..aa2417d3194 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -24,10 +24,6 @@ export default {
type: Boolean,
required: true,
},
- canCreateDeployment: {
- type: Boolean,
- required: true,
- },
canReadEnvironment: {
type: Boolean,
required: true,
@@ -106,7 +102,6 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
@onChangePage="onChangePage"
>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 75bdf87137f..e2c304de00a 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -23,12 +23,6 @@ export default {
required: false,
default: false,
},
-
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
- },
},
methods: {
folderUrl(model) {
@@ -64,7 +58,6 @@ export default {
is="environment-item"
:key="`environment-item-${i}`"
:model="model"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
/>
@@ -79,7 +72,6 @@ export default {
v-for="(children, index) in model.children"
:key="`env-item-${i}-${index}`"
:model="children"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
/>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 982e550e73c..56e7f69cad6 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -18,7 +18,6 @@ export default () =>
endpoint: environmentsData.environmentsDataEndpoint,
folderName: environmentsData.environmentsDataFolderName,
cssContainerClass: environmentsData.cssClass,
- canCreateDeployment: parseBoolean(environmentsData.environmentsDataCanCreateDeployment),
canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment),
};
},
@@ -28,7 +27,6 @@ export default () =>
endpoint: this.endpoint,
folderName: this.folderName,
cssContainerClass: this.cssContainerClass,
- canCreateDeployment: this.canCreateDeployment,
canReadEnvironment: this.canReadEnvironment,
},
});
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index d6f0b6115a6..80f0e00400b 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -23,10 +23,6 @@ export default {
type: String,
required: true,
},
- canCreateDeployment: {
- type: Boolean,
- required: true,
- },
canReadEnvironment: {
type: Boolean,
required: true,
@@ -55,7 +51,6 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
@onChangePage="onChangePage"
/>
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index d366e7550b7..6af66d0f86e 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -20,7 +20,6 @@ export default () =>
helpPagePath: environmentsData.helpPagePath,
cssContainerClass: environmentsData.cssClass,
canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
- canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment),
canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment),
};
},
@@ -32,7 +31,6 @@ export default () =>
helpPagePath: this.helpPagePath,
cssContainerClass: this.cssContainerClass,
canCreateEnvironment: this.canCreateEnvironment,
- canCreateDeployment: this.canCreateDeployment,
canReadEnvironment: this.canReadEnvironment,
},
});
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 96dc1f07cb9..e81a1525df0 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -143,7 +143,7 @@ export default {
*/
created() {
this.service = new EnvironmentsService(this.endpoint);
- this.requestData = { page: this.page, scope: this.scope };
+ this.requestData = { page: this.page, scope: this.scope, nested: true };
this.poll = new Poll({
resource: this.service,
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 4e07ccba91a..cb4ff6856db 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -7,8 +7,8 @@ export default class EnvironmentsService {
}
fetchEnvironments(options = {}) {
- const { scope, page } = options;
- return axios.get(this.environmentsEndpoint, { params: { scope, page } });
+ const { scope, page, nested } = options;
+ return axios.get(this.environmentsEndpoint, { params: { scope, page, nested } });
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 5808a2d4afa..ac9a31c202c 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -20,7 +20,8 @@ export default class EnvironmentsStore {
*
* Stores the received environments.
*
- * In the main environments endpoint, each environment has the following schema
+ * In the main environments endpoint (with { nested: true } in params), each folder
+ * has the following schema:
* { name: String, size: Number, latest: Object }
* In the endpoint to retrieve environments from each folder, the environment does
* not have the `latest` key and the data is all in the root level.
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index 2049760fe29..bdadbb1bb2a 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -4,93 +4,97 @@ import Api from './api';
import { normalizeHeaders } from './lib/utils/common_utils';
export default function groupsSelect() {
- // Needs to be accessible in rspec
- window.GROUP_SELECT_PER_PAGE = 20;
- $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
- const $select = $(this);
- const allAvailable = $select.data('allAvailable');
- const skipGroups = $select.data('skipGroups') || [];
- const parentGroupID = $select.data('parentId');
- const groupsPath = parentGroupID
- ? Api.subgroupsPath.replace(':id', parentGroupID)
- : Api.groupsPath;
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ // Needs to be accessible in rspec
+ window.GROUP_SELECT_PER_PAGE = 20;
+ $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
+ const $select = $(this);
+ const allAvailable = $select.data('allAvailable');
+ const skipGroups = $select.data('skipGroups') || [];
+ const parentGroupID = $select.data('parentId');
+ const groupsPath = parentGroupID
+ ? Api.subgroupsPath.replace(':id', parentGroupID)
+ : Api.groupsPath;
- $select.select2({
- placeholder: 'Search for a group',
- allowClear: $select.hasClass('allowClear'),
- multiple: $select.hasClass('multiselect'),
- minimumInputLength: 0,
- ajax: {
- url: Api.buildUrl(groupsPath),
- dataType: 'json',
- quietMillis: 250,
- transport(params) {
- axios[params.type.toLowerCase()](params.url, {
- params: params.data,
- })
- .then(res => {
- const results = res.data || [];
- const headers = normalizeHeaders(res.headers);
- const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
- const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
- const more = currentPage < totalPages;
+ $select.select2({
+ placeholder: 'Search for a group',
+ allowClear: $select.hasClass('allowClear'),
+ multiple: $select.hasClass('multiselect'),
+ minimumInputLength: 0,
+ ajax: {
+ url: Api.buildUrl(groupsPath),
+ dataType: 'json',
+ quietMillis: 250,
+ transport(params) {
+ axios[params.type.toLowerCase()](params.url, {
+ params: params.data,
+ })
+ .then(res => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
+ const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
+ const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
+ const more = currentPage < totalPages;
- params.success({
- results,
- pagination: {
- more,
- },
- });
- })
- .catch(params.error);
- },
- data(search, page) {
- return {
- search,
- page,
- per_page: window.GROUP_SELECT_PER_PAGE,
- all_available: allAvailable,
- };
- },
- results(data, page) {
- if (data.length) return { results: [] };
+ params.success({
+ results,
+ pagination: {
+ more,
+ },
+ });
+ })
+ .catch(params.error);
+ },
+ data(search, page) {
+ return {
+ search,
+ page,
+ per_page: window.GROUP_SELECT_PER_PAGE,
+ all_available: allAvailable,
+ };
+ },
+ results(data, page) {
+ if (data.length) return { results: [] };
- const groups = data.length ? data : data.results || [];
- const more = data.pagination ? data.pagination.more : false;
- const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
+ const groups = data.length ? data : data.results || [];
+ const more = data.pagination ? data.pagination.more : false;
+ const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
- return {
- results,
- page,
- more,
- };
- },
- },
- // eslint-disable-next-line consistent-return
- initSelection(element, callback) {
- const id = $(element).val();
- if (id !== '') {
- return Api.group(id, callback);
- }
- },
- formatResult(object) {
- return `<div class='group-result'> <div class='group-name'>${
- object.full_name
- }</div> <div class='group-path'>${object.full_path}</div> </div>`;
- },
- formatSelection(object) {
- return object.full_name;
- },
- dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
+ return {
+ results,
+ page,
+ more,
+ };
+ },
+ },
+ // eslint-disable-next-line consistent-return
+ initSelection(element, callback) {
+ const id = $(element).val();
+ if (id !== '') {
+ return Api.group(id, callback);
+ }
+ },
+ formatResult(object) {
+ return `<div class='group-result'> <div class='group-name'>${
+ object.full_name
+ }</div> <div class='group-path'>${object.full_path}</div> </div>`;
+ },
+ formatSelection(object) {
+ return object.full_name;
+ },
+ dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
+ });
- $select.on('select2-loaded', () => {
- const dropdown = document.querySelector('.select2-infinite .select2-results');
- dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
- });
- });
+ $select.on('select2-loaded', () => {
+ const dropdown = document.querySelector('.select2-infinite .select2-results');
+ dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
+ });
+ });
+ })
+ .catch(() => {});
}
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index caec8779cac..9894ebb0624 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,20 +1,17 @@
<script>
import Vue from 'vue';
-import Mousetrap from 'mousetrap';
import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
+import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue';
-import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
-const originalStopCallback = Mousetrap.stopCallback;
-
export default {
components: {
NewModal,
@@ -42,21 +39,18 @@ export default {
'emptyStateSvgPath',
'currentProjectId',
'errorMessage',
+ 'loading',
+ ]),
+ ...mapGetters([
+ 'activeFile',
+ 'hasChanges',
+ 'someUncommittedChanges',
+ 'isCommitModeActive',
+ 'allBlobs',
]),
- ...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']),
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
-
- Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
- if (e.preventDefault) {
- e.preventDefault();
- }
-
- this.toggleFileFinder(!this.fileFindVisible);
- });
-
- Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
},
methods: {
...mapActions(['toggleFileFinder']),
@@ -70,17 +64,8 @@ export default {
});
return returnValue;
},
- mousetrapStopCallback(e, el, combo) {
- if (
- (combo === 't' && el.classList.contains('dropdown-input-field')) ||
- el.classList.contains('inputarea')
- ) {
- return true;
- } else if (combo === 'command+p' || combo === 'ctrl+p') {
- return false;
- }
-
- return originalStopCallback(e, el, combo);
+ openFile(file) {
+ this.$router.push(`/project${file.url}`);
},
},
};
@@ -90,7 +75,14 @@ export default {
<article class="ide position-relative d-flex flex-column align-items-stretch">
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
- <find-file v-show="fileFindVisible" />
+ <find-file
+ v-show="fileFindVisible"
+ :files="allBlobs"
+ :visible="fileFindVisible"
+ :loading="loading"
+ @toggle="toggleFileFinder"
+ @click="openFile"
+ />
<ide-sidebar />
<div class="multi-file-edit-pane">
<template v-if="activeFile">
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index f1d40586903..ce577ae85b0 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -107,16 +107,23 @@ export default {
class="commit-sha"
>{{ lastCommit.short_id }}</a
>
- by {{ lastCommit.author_name }}
+ by
+ <user-avatar-image
+ css-classes="ide-status-avatar"
+ :size="18"
+ :img-src="latestPipeline && latestPipeline.commit.author_gravatar_url"
+ :img-alt="lastCommit.author_name"
+ :tooltip-text="lastCommit.author_name"
+ />
+ {{ lastCommit.author_name }}
<time
v-tooltip
:datetime="lastCommit.committed_date"
:title="tooltipTitle(lastCommit.committed_date)"
data-placement="top"
data-container="body"
+ >{{ lastCommitFormatedAge }}</time
>
- {{ lastCommitFormatedAge }}
- </time>
</div>
<div v-if="file" class="ide-status-file">{{ file.name }}</div>
<div v-if="file" class="ide-status-file">{{ file.eol }}</div>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 09245ed0296..804ebae4555 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -1,8 +1,3 @@
-// Fuzzy file finder
-export const MAX_FILE_FINDER_RESULTS = 40;
-export const FILE_FINDER_ROW_HEIGHT = 55;
-export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
-
export const MAX_WINDOW_HEIGHT_COMPACT = 750;
// Commit message textarea
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index d97a950a8b2..24c2f71ae2b 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -202,7 +202,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch(
'setErrorMessage',
{
- text: __('An error accured whilst committing your changes.'),
+ text: __('An error occurred whilst committing your changes.'),
action: () =>
dispatch('commitChanges').then(() =>
dispatch('setErrorMessage', null, { root: true }),
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
index 612c524ca1c..e0fb58ef195 100644
--- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -11,10 +11,14 @@ class AutoWidthDropdownSelect {
init() {
const { dropdownClass } = this;
- this.$selectElement.select2({
- dropdownCssClass: dropdownClass,
- ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
- });
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ this.$selectElement.select2({
+ dropdownCssClass: dropdownClass,
+ ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
+ });
+ })
+ .catch(() => {});
return this;
}
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index f3d722409b0..48e7ed1318d 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -7,10 +7,14 @@ export default class IssuableContext {
constructor(currentUser) {
this.userSelect = new UsersSelect(currentUser);
- $('select.select2').select2({
- width: 'resolve',
- dropdownAutoWidth: true,
- });
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('select.select2').select2({
+ width: 'resolve',
+ dropdownAutoWidth: true,
+ });
+ })
+ .catch(() => {});
$('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() {
return $(this).submit();
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index c81a2230310..9336b71cfd7 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -44,6 +44,7 @@ export default class IssuableForm {
parse: dateString => parsePikadayDate(dateString),
toString: date => pikadayToString(date),
onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)),
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($issuableDueDate.val()));
}
@@ -120,35 +121,39 @@ export default class IssuableForm {
}
initTargetBranchDropdown() {
- this.$targetBranchSelect.select2({
- ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
- ajax: {
- url: this.$targetBranchSelect.data('endpoint'),
- dataType: 'JSON',
- quietMillis: 250,
- data(search) {
- return {
- search,
- };
- },
- results(data) {
- return {
- // `data` keys are translated so we can't just access them with a string based key
- results: data[Object.keys(data)[0]].map(name => ({
- id: name,
- text: name,
- })),
- };
- },
- },
- initSelection(el, callback) {
- const val = el.val();
-
- callback({
- id: val,
- text: val,
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ this.$targetBranchSelect.select2({
+ ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
+ ajax: {
+ url: this.$targetBranchSelect.data('endpoint'),
+ dataType: 'JSON',
+ quietMillis: 250,
+ data(search) {
+ return {
+ search,
+ };
+ },
+ results(data) {
+ return {
+ // `data` keys are translated so we can't just access them with a string based key
+ results: data[Object.keys(data)[0]].map(name => ({
+ id: name,
+ text: name,
+ })),
+ };
+ },
+ },
+ initSelection(el, callback) {
+ const val = el.val();
+
+ callback({
+ id: val,
+ text: val,
+ });
+ },
});
- },
- });
+ })
+ .catch(() => {});
}
}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index cd569eb3045..bd757a76ee7 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,5 +1,7 @@
<script>
import Visibility from 'visibilityjs';
+import { __, s__, sprintf } from '~/locale';
+import createFlash from '~/flash';
import { visitUrl } from '../../lib/utils/url_utility';
import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub';
@@ -10,7 +12,6 @@ import descriptionComponent from './description.vue';
import editedComponent from './edited.vue';
import formComponent from './form.vue';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
-import { __ } from '~/locale';
export default {
components: {
@@ -107,11 +108,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
projectPath: {
type: String,
required: true,
@@ -130,6 +126,11 @@ export default {
required: false,
default: true,
},
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
const store = new Store({
@@ -141,6 +142,7 @@ export default {
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
taskStatus: this.initialTaskStatus,
+ lock_version: this.lockVersion,
});
return {
@@ -161,6 +163,9 @@ export default {
const titleChanged = this.initialTitleText !== this.store.formState.title;
return descriptionChanged || titleChanged;
},
+ defaultErrorMessage() {
+ return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
+ },
},
created() {
this.service = new Service(this.endpoint);
@@ -207,6 +212,17 @@ export default {
}
return undefined;
},
+ updateStoreState() {
+ return this.service
+ .getData()
+ .then(res => res.data)
+ .then(data => {
+ this.store.updateState(data);
+ })
+ .catch(() => {
+ createFlash(this.defaultErrorMessage);
+ });
+ },
openForm() {
if (!this.showForm) {
@@ -214,6 +230,7 @@ export default {
this.store.setFormState({
title: this.state.titleText,
description: this.state.descriptionText,
+ lock_version: this.state.lock_version,
lockedWarningVisible: false,
updateLoading: false,
});
@@ -232,20 +249,24 @@ export default {
if (window.location.pathname !== data.web_url) {
visitUrl(data.web_url);
}
-
- return this.service.getData();
})
- .then(res => res.data)
- .then(data => {
- this.store.updateState(data);
+ .then(this.updateStoreState)
+ .then(() => {
eventHub.$emit('close.form');
})
- .catch(error => {
- if (error && error.name === 'SpamError') {
+ .catch((error = {}) => {
+ const { name, response = {} } = error;
+
+ if (name === 'SpamError') {
this.openRecaptcha();
} else {
- eventHub.$emit('close.form');
- window.Flash(`Error updating ${this.issuableType}`);
+ let errMsg = this.defaultErrorMessage;
+
+ if (response.data && response.data.errors) {
+ errMsg += `. ${response.data.errors.join(' ')}`;
+ }
+
+ createFlash(errMsg);
}
});
},
@@ -269,8 +290,9 @@ export default {
visitUrl(data.web_url);
})
.catch(() => {
- eventHub.$emit('close.form');
- window.Flash(`Error deleting ${this.issuableType}`);
+ createFlash(
+ sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
+ );
});
},
},
@@ -286,7 +308,6 @@ export default {
:issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
- :markdown-version="markdownVersion"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-delete-button="showDeleteButton"
@@ -314,6 +335,8 @@ export default {
:task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
+ :lock-version="state.lock_version"
+ @taskListUpdateFailed="updateStoreState"
/>
<edited-component
v-if="hasUpdated"
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 5ca88d75063..58f14bac8c8 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -1,5 +1,7 @@
<script>
import $ from 'jquery';
+import { s__, sprintf } from '~/locale';
+import createFlash from '~/flash';
import animateMixin from '../mixins/animate';
import TaskList from '../../task_list';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
@@ -35,6 +37,11 @@ export default {
required: false,
default: null,
},
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -67,8 +74,10 @@ export default {
new TaskList({
dataType: this.issuableType,
fieldName: 'description',
+ lockVersion: this.lockVersion,
selector: '.detail-page-description',
onSuccess: this.taskListUpdateSuccess.bind(this),
+ onError: this.taskListUpdateError.bind(this),
});
}
},
@@ -82,6 +91,21 @@ export default {
}
},
+ taskListUpdateError() {
+ createFlash(
+ sprintf(
+ s__(
+ 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.',
+ ),
+ {
+ issueType: this.issuableType,
+ },
+ ),
+ );
+
+ this.$emit('taskListUpdateFailed');
+ },
+
updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta');
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 90258c0e044..299130e56ae 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -20,11 +20,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
canAttachFile: {
type: Boolean,
required: false,
@@ -48,7 +43,6 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
>
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 45b60bc3392..eade31f1d14 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -39,11 +39,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
projectPath: {
type: String,
required: true,
@@ -101,7 +96,6 @@ export default {
:form-state="formState"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
/>
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 32044d6da25..3c17e73ccec 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,3 +1,5 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
export default class Store {
constructor(initialState) {
this.state = initialState;
@@ -6,6 +8,7 @@ export default class Store {
description: '',
lockedWarningVisible: false,
updateLoading: false,
+ lock_version: 0,
};
}
@@ -14,14 +17,10 @@ export default class Store {
this.formState.lockedWarningVisible = true;
}
+ Object.assign(this.state, convertObjectPropsToCamelCase(data));
this.state.titleHtml = data.title;
- this.state.titleText = data.title_text;
this.state.descriptionHtml = data.description;
- this.state.descriptionText = data.description_text;
- this.state.taskStatus = data.task_status;
- this.state.updatedAt = data.updated_at;
- this.state.updatedByName = data.updated_by_name;
- this.state.updatedByPath = data.updated_by_path;
+ this.state.lock_version = data.lock_version;
}
stateShouldUpdate(data) {
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 91332c21b52..c5076d65ff9 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -39,7 +39,9 @@ export default {
<ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
<span class="font-weight-bold">{{ __('Pipeline') }}</span>
- <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a>
+ <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
+ >#{{ pipeline.id }}</a
+ >
<template v-if="hasRef">
{{ __('from') }}
<a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index 062501d1d04..f134a54dd53 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -70,7 +70,18 @@ export default class LabelManager {
const $detachedLabel = $label.detach();
this.toggleLabelPriorityBadge($detachedLabel, action);
- $detachedLabel.appendTo($target);
+
+ const $labelEls = $target.find('li.label-list-item');
+
+ /*
+ * If there is a label element in the target, we'd want to
+ * append the new label just right next to it.
+ */
+ if ($labelEls.length) {
+ $labelEls.last().after($detachedLabel);
+ } else {
+ $detachedLabel.appendTo($target);
+ }
if ($from.find('li').length) {
$from.find('.empty-message').removeClass('hidden');
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 3b6a57dad44..0ceff10a02a 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -221,6 +221,22 @@ export const scrollToElement = element => {
};
/**
+ * Returns a function that can only be invoked once between
+ * each browser screen repaint.
+ * @param {Function} fn
+ */
+export const debounceByAnimationFrame = fn => {
+ let requestId;
+
+ return function debounced(...args) {
+ if (requestId) {
+ window.cancelAnimationFrame(requestId);
+ }
+ requestId = window.requestAnimationFrame(() => fn.apply(this, args));
+ };
+};
+
+/**
this will take in the `name` of the param you want to parse in the url
if the name does not exist this function will return `null`
otherwise it will return the value of the param key provided
@@ -614,10 +630,18 @@ export const spriteIcon = (icon, className = '') => {
/**
* This method takes in object with snake_case property names
- * and returns new object with camelCase property names
+ * and returns a new object with camelCase property names
*
* Reasoning for this method is to ensure consistent property
* naming conventions across JS code.
+ *
+ * This method also supports additional params in `options` object
+ *
+ * @param {Object} obj - Object to be converted.
+ * @param {Object} options - Object containing additional options.
+ * @param {boolean} options.deep - FLag to allow deep object converting
+ * @param {Array[]} dropKeys - List of properties to discard while building new object
+ * @param {Array[]} ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object
*/
export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
if (obj === null) {
@@ -625,12 +649,26 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
}
const initial = Array.isArray(obj) ? [] : {};
+ const { deep = false, dropKeys = [], ignoreKeyNames = [] } = options;
return Object.keys(obj).reduce((acc, prop) => {
const result = acc;
const val = obj[prop];
- if (options.deep && (isObject(val) || Array.isArray(val))) {
+ // Drop properties from new object if
+ // there are any mentioned in options
+ if (dropKeys.indexOf(prop) > -1) {
+ return acc;
+ }
+
+ // Skip converting properties in new object
+ // if there are any mentioned in options
+ if (ignoreKeyNames.indexOf(prop) > -1) {
+ result[prop] = obj[prop];
+ return acc;
+ }
+
+ if (deep && (isObject(val) || Array.isArray(val))) {
result[convertToCamelCase(prop)] = convertObjectPropsToCamelCase(val, options);
} else {
result[convertToCamelCase(prop)] = obj[prop];
diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js
index b41ffb44971..82ee83e4348 100644
--- a/app/assets/javascripts/lib/utils/file_upload.js
+++ b/app/assets/javascripts/lib/utils/file_upload.js
@@ -1,6 +1,9 @@
export default (buttonSelector, fileSelector) => {
const btn = document.querySelector(buttonSelector);
const fileInput = document.querySelector(fileSelector);
+
+ if (!btn || !fileInput) return;
+
const form = btn.closest('form');
btn.addEventListener('click', () => {
diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js
new file mode 100644
index 00000000000..18f9e2ed846
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/grammar.js
@@ -0,0 +1,40 @@
+import { sprintf, s__ } from '~/locale';
+
+/**
+ * Combines each given item into a noun series sentence fragment. It does this
+ * in a way that supports i18n by giving context and punctuation to the locale
+ * functions.
+ *
+ * **Examples:**
+ *
+ * - `["A", "B"] => "A and B"`
+ * - `["A", "B", "C"] => "A, B, and C"`
+ *
+ * **Why only nouns?**
+ *
+ * Some languages need a bit more context to translate other series.
+ *
+ * @param {String[]} items
+ */
+export const toNounSeriesText = items => {
+ if (items.length === 0) {
+ return '';
+ } else if (items.length === 1) {
+ return items[0];
+ } else if (items.length === 2) {
+ return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), {
+ firstItem: items[0],
+ lastItem: items[1],
+ });
+ }
+
+ return items.reduce((item, nextItem, idx) =>
+ idx === items.length - 1
+ ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem })
+ : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }),
+ );
+};
+
+export default {
+ toNounSeriesText,
+};
diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js
new file mode 100644
index 00000000000..7b8dd9bbef7
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/icon_utils.js
@@ -0,0 +1,18 @@
+/* eslint-disable import/prefer-default-export */
+
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * Retrieve SVG icon path content from gitlab/svg sprite icons
+ * @param {String} name
+ */
+export const getSvgIconPathContent = name =>
+ axios
+ .get(gon.sprite_icons)
+ .then(({ data: svgs }) =>
+ new DOMParser()
+ .parseFromString(svgs, 'text/xml')
+ .querySelector(`#${name} path`)
+ .getAttribute('d'),
+ )
+ .catch(() => null);
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 4ba3543f9b2..63db4938cd7 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -100,27 +100,30 @@ function deferredInitialisation() {
});
// Initialize select2 selects
- $('select.select2').select2({
- width: 'resolve',
- dropdownAutoWidth: true,
- });
-
- // Close select2 on escape
- $('.js-select2').on('select2-close', () => {
- setTimeout(() => {
- $('.select2-container-active').removeClass('select2-container-active');
- $(':focus').blur();
- }, 1);
- });
+ if ($('select.select2').length) {
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('select.select2').select2({
+ width: 'resolve',
+ dropdownAutoWidth: true,
+ });
+
+ // Close select2 on escape
+ $('.js-select2').on('select2-close', () => {
+ setTimeout(() => {
+ $('.select2-container-active').removeClass('select2-container-active');
+ $(':focus').blur();
+ }, 1);
+ });
+ })
+ .catch(() => {});
+ }
// Initialize tooltips
$body.tooltip({
selector: '.has-tooltip, [data-toggle="tooltip"]',
trigger: 'hover',
boundary: 'viewport',
- placement(tip, el) {
- return $(el).data('placement') || 'bottom';
- },
});
// Initialize popovers
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index 0beedcacf33..0dabb28ea66 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -33,6 +33,7 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d
toggleClearInput.call($input);
},
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($input.val()));
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 0deae478deb..3b42a154af8 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import { __ } from '~/locale';
+import createFlash from '~/flash';
import TaskList from './task_list';
import MergeRequestTabs from './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper';
@@ -35,10 +36,18 @@ function MergeRequest(opts) {
dataType: 'merge_request',
fieldName: 'description',
selector: '.detail-page-description',
+ lockVersion: this.$el.data('lockVersion'),
onSuccess: result => {
document.querySelector('#task_status').innerText = result.task_status;
document.querySelector('#task_status_short').innerText = result.task_status_short;
},
+ onError: () => {
+ createFlash(
+ __(
+ 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.',
+ ),
+ );
+ },
});
}
}
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index e2cffe0b4b4..14c02db7bcc 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -1,10 +1,16 @@
<script>
-import { GlAreaChart } from '@gitlab/ui';
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import Icon from '~/vue_shared/components/icon.vue';
+
+let debouncedResize;
export default {
components: {
GlAreaChart,
+ Icon,
},
inheritAttrs: false,
props: {
@@ -26,22 +32,38 @@ export default {
);
},
},
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
alertData: {
type: Object,
required: false,
default: () => ({}),
},
},
+ data() {
+ return {
+ tooltip: {
+ title: '',
+ content: '',
+ isDeployment: false,
+ sha: '',
+ },
+ width: 0,
+ height: 0,
+ scatterSymbol: undefined,
+ };
+ },
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;
- }),
- );
+ accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []);
return accumulator;
}, {});
},
@@ -51,14 +73,17 @@ export default {
name: 'Time',
type: 'time',
axisLabel: {
- formatter: date => dateFormat(date, 'h:MMtt'),
+ formatter: date => dateFormat(date, 'h:MM TT'),
+ },
+ axisPointer: {
+ snap: true,
},
nameTextStyle: {
padding: [18, 0, 0, 0],
},
},
yAxis: {
- name: this.graphData.y_label,
+ name: this.yAxisLabel,
axisLabel: {
formatter: value => value.toFixed(3),
},
@@ -69,33 +94,129 @@ export default {
legend: {
formatter: this.xAxisLabel,
},
+ series: this.scatterSeries,
+ };
+ },
+ earliestDatapoint() {
+ return Object.values(this.chartData).reduce((acc, data) => {
+ const [[timestamp]] = data.sort(([a], [b]) => {
+ if (a < b) {
+ return -1;
+ }
+ return a > b ? 1 : 0;
+ });
+
+ return timestamp < acc || acc === null ? timestamp : acc;
+ }, null);
+ },
+ recentDeployments() {
+ return this.deploymentData.reduce((acc, deployment) => {
+ if (deployment.created_at >= this.earliestDatapoint) {
+ acc.push({
+ id: deployment.id,
+ createdAt: deployment.created_at,
+ sha: deployment.sha,
+ commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
+ tag: deployment.tag,
+ tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
+ ref: deployment.ref.name,
+ showDeploymentFlag: false,
+ });
+ }
+
+ return acc;
+ }, []);
+ },
+ scatterSeries() {
+ return {
+ type: 'scatter',
+ data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
+ symbol: this.scatterSymbol,
+ symbolSize: 14,
};
},
xAxisLabel() {
return this.graphData.queries.map(query => query.label).join(', ');
},
+ yAxisLabel() {
+ const [query] = this.graphData.queries;
+ return `${this.graphData.y_label} (${query.unit})`;
+ },
+ },
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', debouncedResize);
+ },
+ created() {
+ debouncedResize = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', debouncedResize);
+ this.getScatterSymbol();
},
methods: {
formatTooltipText(params) {
- const [date, value] = params;
- return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
+ const [seriesData] = params.seriesData;
+ this.tooltip.isDeployment = seriesData.componentSubType === 'scatter';
+ this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+ if (this.tooltip.isDeployment) {
+ const [deploy] = this.recentDeployments.filter(
+ deployment => deployment.createdAt === seriesData.value[0],
+ );
+ this.tooltip.sha = deploy.sha.substring(0, 8);
+ } else {
+ this.tooltip.content = `${this.yAxisLabel} ${seriesData.value[1].toFixed(3)}`;
+ }
+ },
+ getScatterSymbol() {
+ getSvgIconPathContent('rocket')
+ .then(path => {
+ if (path) {
+ this.scatterSymbol = `path://${path}`;
+ }
+ })
+ .catch(() => {});
+ },
+ onResize() {
+ const { width, height } = this.$refs.areaChart.$el.getBoundingClientRect();
+ this.width = width;
+ this.height = height;
},
},
};
</script>
<template>
- <div class="prometheus-graph">
+ <div class="prometheus-graph col-12 col-lg-6">
<div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div class="prometheus-graph-widgets"><slot></slot></div>
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
</div>
<gl-area-chart
+ ref="areaChart"
v-bind="$attrs"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:thresholds="alertData"
- />
+ :width="width"
+ :height="height"
+ >
+ <template slot="tooltipTitle">
+ <div v-if="tooltip.isDeployment">
+ {{ __('Deployed') }}
+ </div>
+ {{ tooltip.title }}
+ </template>
+ <template slot="tooltipContent">
+ <div v-if="tooltip.isDeployment" class="d-flex align-items-center">
+ <icon name="commit" class="mr-2" />
+ {{ tooltip.sha }}
+ </div>
+ <template v-else>
+ {{ tooltip.content }}
+ </template>
+ </template>
+ </gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index cea5c1a56ca..895a57785bc 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,20 +1,19 @@
<script>
-import _ from 'underscore';
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';
import MonitoringStore from '../stores/monitoring_store';
-import eventHub from '../event_hub';
+
+const sidebarAnimationDuration = 150;
+let sidebarMutationObserver;
export default {
components: {
MonitorAreaChart,
- Graph,
GraphGroup,
EmptyState,
Icon,
@@ -25,21 +24,11 @@ export default {
required: false,
default: true,
},
- showLegend: {
- type: Boolean,
- required: false,
- default: true,
- },
showPanels: {
type: Boolean,
required: false,
default: true,
},
- forceSmallGraph: {
- type: Boolean,
- required: false,
- default: false,
- },
documentationPath: {
type: String,
required: true,
@@ -99,48 +88,32 @@ export default {
store: new MonitoringStore(),
state: 'gettingStarted',
showEmptyState: true,
- hoverData: {},
elWidth: 0,
};
},
- computed: {
- graphComponent() {
- return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
- },
- forceRedraw() {
- return this.elWidth;
- },
- },
created() {
this.service = new MonitoringService({
metricsEndpoint: this.metricsEndpoint,
deploymentEndpoint: this.deploymentEndpoint,
environmentsEndpoint: this.environmentsEndpoint,
});
- this.mutationObserverConfig = {
- attributes: true,
- childList: false,
- subtree: false,
- };
- eventHub.$on('hoverChanged', this.hoverChanged);
},
beforeDestroy() {
- eventHub.$off('hoverChanged', this.hoverChanged);
- window.removeEventListener('resize', this.resizeThrottled, false);
- this.sidebarMutationObserver.disconnect();
+ if (sidebarMutationObserver) {
+ sidebarMutationObserver.disconnect();
+ }
},
mounted() {
- this.resizeThrottled = _.debounce(this.resize, 100);
if (!this.hasMetrics) {
this.state = 'gettingStarted';
} else {
this.getGraphsData();
- window.addEventListener('resize', this.resizeThrottled, false);
-
- const sidebarEl = document.querySelector('.nav-sidebar');
- // The sidebar listener
- this.sidebarMutationObserver = new MutationObserver(this.resizeThrottled);
- this.sidebarMutationObserver.observe(sidebarEl, this.mutationObserverConfig);
+ sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
+ sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
+ attributes: true,
+ childList: false,
+ subtree: false,
+ });
}
},
methods: {
@@ -168,42 +141,40 @@ export default {
this.showEmptyState = false;
})
- .then(this.resize)
.catch(() => {
this.state = 'unableToConnect';
});
},
- resize() {
- this.elWidth = this.$el.clientWidth;
- },
- hoverChanged(data) {
- this.hoverData = data;
+ onSidebarMutation() {
+ setTimeout(() => {
+ this.elWidth = this.$el.clientWidth;
+ }, sidebarAnimationDuration);
},
},
};
</script>
<template>
- <div v-if="!showEmptyState" :key="forceRedraw" class="prometheus-graphs prepend-top-default">
+ <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default">
<div class="environments d-flex align-items-center">
{{ s__('Metrics|Environment') }}
<div class="dropdown prepend-left-10">
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
- <span> {{ currentEnvironmentName }} </span> <icon name="chevron-down" />
+ <span>{{ currentEnvironmentName }}</span>
+ <icon name="chevron-down" />
</button>
<div
v-if="store.environmentsData.length > 0"
class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"
>
<ul>
- <li v-for="environment in store.environmentsData" :key="environment.latest.id">
+ <li v-for="environment in store.environmentsData" :key="environment.id">
<a
- :href="environment.latest.metrics_path"
- :class="{ 'is-active': environment.latest.name == currentEnvironmentName }"
+ :href="environment.metrics_path"
+ :class="{ 'is-active': environment.name == currentEnvironmentName }"
class="dropdown-item"
+ >{{ environment.name }}</a
>
- {{ environment.latest.name }}
- </a>
</li>
</ul>
</div>
@@ -215,23 +186,15 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
- <component
- :is="graphComponent"
+ <monitor-area-chart
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
- :hover-data="hoverData"
:deployment-data="store.deploymentData"
- :project-path="projectPath"
- :tags-path="tagsPath"
- :show-legend="showLegend"
- :small-graph="forceSmallGraph"
:alert-data="getGraphAlerts(graphData.id)"
+ :container-width="elWidth"
group-id="monitor-area-chart"
- >
- <!-- EE content -->
- {{ null }}
- </component>
+ />
</graph-group>
</div>
<empty-state
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
deleted file mode 100644
index 309b73f5a4d..00000000000
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ /dev/null
@@ -1,329 +0,0 @@
-<script>
-import { scaleLinear, scaleTime } from 'd3-scale';
-import { axisLeft, axisBottom } from 'd3-axis';
-import _ from 'underscore';
-import { max, extent } from 'd3-array';
-import { select } from 'd3-selection';
-import GraphAxis from './graph/axis.vue';
-import GraphLegend from './graph/legend.vue';
-import GraphFlag from './graph/flag.vue';
-import GraphDeployment from './graph/deployment.vue';
-import GraphPath from './graph/path.vue';
-import MonitoringMixin from '../mixins/monitoring_mixins';
-import eventHub from '../event_hub';
-import measurements from '../utils/measurements';
-import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
-import createTimeSeries from '../utils/multiple_time_series';
-import bp from '../../breakpoints';
-
-const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
-
-export default {
- components: {
- GraphAxis,
- GraphFlag,
- GraphDeployment,
- GraphPath,
- GraphLegend,
- },
- mixins: [MonitoringMixin],
- props: {
- graphData: {
- type: Object,
- required: true,
- },
- deploymentData: {
- type: Array,
- required: true,
- },
- hoverData: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- projectPath: {
- type: String,
- required: true,
- },
- tagsPath: {
- type: String,
- required: true,
- },
- showLegend: {
- type: Boolean,
- required: false,
- default: true,
- },
- smallGraph: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- baseGraphHeight: 450,
- baseGraphWidth: 600,
- graphHeight: 450,
- graphWidth: 600,
- graphHeightOffset: 120,
- margin: {},
- unitOfDisplay: '',
- yAxisLabel: '',
- legendTitle: '',
- reducedDeploymentData: [],
- measurements: measurements.large,
- currentData: {
- time: new Date(),
- value: 0,
- },
- currentXCoordinate: 0,
- currentCoordinates: {},
- showFlag: false,
- showFlagContent: false,
- timeSeries: [],
- graphDrawData: {},
- realPixelRatio: 1,
- seriesUnderMouse: [],
- };
- },
- computed: {
- outerViewBox() {
- return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
- },
- innerViewBox() {
- return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
- },
- axisTransform() {
- return `translate(70, ${this.graphHeight - 100})`;
- },
- paddingBottomRootSvg() {
- return {
- paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`,
- };
- },
- deploymentFlagData() {
- return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag);
- },
- shouldRenderData() {
- return this.graphData.queries.filter(s => s.result.length > 0).length > 0;
- },
- },
- watch: {
- hoverData() {
- this.positionFlag();
- },
- },
- mounted() {
- this.draw();
- },
- methods: {
- showDot(path) {
- return this.showFlagContent && this.seriesUnderMouse.includes(path);
- },
- draw() {
- const breakpointSize = bp.getBreakpointSize();
- 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.yAxisLabel = this.graphData.y_label || 'Values';
- this.graphWidth = svgWidth - this.margin.left - this.margin.right;
- this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
- this.baseGraphHeight = this.graphHeight - 50;
- this.baseGraphWidth = this.graphWidth;
-
- // pixel offsets inside the svg and outside are not 1:1
- this.realPixelRatio = svgWidth / this.baseGraphWidth;
-
- // 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();
- point.x = e.clientX;
- point.y = e.clientY;
- point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
- point.x += 7;
-
- this.seriesUnderMouse = this.timeSeries.filter(series => {
- const mouseX = series.timeSeriesScaleX.invert(point.x);
- let minDistance = Infinity;
-
- const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => {
- const distance = Math.abs(Number(new Date(x)) - Number(mouseX));
- if (distance < minDistance) {
- minDistance = distance;
- return x;
- }
- return closest;
- });
-
- return series.values.find(v => v.time.toString() === closestTickMark);
- });
-
- const firstTimeSeries = this.seriesUnderMouse[0];
- const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
- const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
- const d0 = firstTimeSeries.values[overlayIndex - 1];
- const d1 = firstTimeSeries.values[overlayIndex];
- if (d0 === undefined || d1 === undefined) return;
- const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
- const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1;
- const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time;
- const currentDeployXPos = this.mouseOverDeployInfo(point.x);
-
- eventHub.$emit('hoverChanged', {
- hoveredDate,
- currentDeployXPos,
- });
- },
- renderAxesPaths() {
- ({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries(
- this.graphData.queries,
- this.graphWidth,
- this.graphHeight,
- this.graphHeightOffset,
- ));
-
- if (_.findWhere(this.timeSeries, { renderCanary: true })) {
- this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
- }
-
- const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]);
- const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]);
-
- const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
- axisXScale.domain(d3.extent(allValues, d => d.time));
- axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
-
- this.allXAxisValues = this.timeSeries.reduce((obj, series) => {
- const seriesKeys = {};
- series.values.forEach(v => {
- seriesKeys[v.time] = true;
- });
- return {
- ...obj,
- ...seriesKeys,
- };
- }, {});
-
- const xAxis = d3
- .axisBottom()
- .scale(axisXScale)
- .ticks(this.graphWidth / 120)
- .tickFormat(timeScaleFormat);
-
- const yAxis = d3
- .axisLeft()
- .scale(axisYScale)
- .ticks(measurements.yTicks);
-
- d3.select(this.$refs.baseSvg)
- .select('.x-axis')
- .call(xAxis);
-
- const width = this.graphWidth;
- d3.select(this.$refs.baseSvg)
- .select('.y-axis')
- .call(yAxis)
- .selectAll('.tick')
- .each(function createTickLines(d, i) {
- if (i > 0) {
- d3.select(this)
- .select('line')
- .attr('x2', width)
- .attr('class', 'axis-tick');
- } // Avoid adding the class to the first tick, to prevent coloring
- }); // This will select all of the ticks once they're rendered
- },
- },
-};
-</script>
-
-<template>
- <div
- class="prometheus-graph"
- @mouseover="showFlagContent = true"
- @mouseleave="showFlagContent = false"
- >
- <div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div class="prometheus-graph-widgets"><slot></slot></div>
- </div>
- <div :style="paddingBottomRootSvg" class="prometheus-svg-container">
- <svg ref="baseSvg" :viewBox="outerViewBox">
- <g :transform="axisTransform" class="x-axis" />
- <g class="y-axis" transform="translate(70, 20)" />
- <graph-axis
- :graph-width="graphWidth"
- :graph-height="graphHeight"
- :margin="margin"
- :measurements="measurements"
- :y-axis-label="yAxisLabel"
- :unit-of-display="unitOfDisplay"
- />
- <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data">
- <slot name="additionalSvgContent" :graphDrawData="graphDrawData" />
- <graph-path
- v-for="(path, index) in timeSeries"
- :key="index"
- :generated-line-path="path.linePath"
- :generated-area-path="path.areaPath"
- :line-style="path.lineStyle"
- :line-color="path.lineColor"
- :area-color="path.areaColor"
- :current-coordinates="currentCoordinates[path.metricTag]"
- :show-dot="showDot(path)"
- />
- <graph-deployment
- :deployment-data="reducedDeploymentData"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- />
- <rect
- ref="graphOverlay"
- :width="graphWidth - 70"
- :height="graphHeight - 100"
- class="prometheus-graph-overlay"
- transform="translate(-5, 20)"
- @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"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- :show-flag-content="showFlagContent"
- :time-series="seriesUnderMouse"
- :unit-of-display="unitOfDisplay"
- :legend-title="legendTitle"
- :deployment-flag-data="deploymentFlagData"
- :current-coordinates="currentCoordinates"
- />
- </div>
- <graph-legend v-if="showLegend" :legend-title="legendTitle" :time-series="timeSeries" />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue
deleted file mode 100644
index 8f046857a20..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/axis.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-<script>
-import { convertToSentenceCase } from '~/lib/utils/text_utility';
-import { s__ } from '~/locale';
-
-export default {
- props: {
- graphWidth: {
- type: Number,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- margin: {
- type: Object,
- required: true,
- },
- measurements: {
- type: Object,
- required: true,
- },
- yAxisLabel: {
- type: String,
- required: true,
- },
- unitOfDisplay: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- yLabelWidth: 0,
- yLabelHeight: 0,
- };
- },
- computed: {
- textTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0;
-
- return `translate(15, ${yCoordinate}) rotate(-90)`;
- },
-
- rectTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 +
- this.yLabelWidth / 2 || 0;
-
- return `translate(0, ${yCoordinate}) rotate(-90)`;
- },
-
- xPosition() {
- return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0;
- },
-
- yPosition() {
- return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0;
- },
-
- yAxisLabelSentenceCase() {
- return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`;
- },
-
- timeString() {
- return s__('PrometheusDashboard|Time');
- },
- },
- mounted() {
- this.$nextTick(() => {
- const bbox = this.$refs.ylabel.getBBox();
- this.yLabelWidth = bbox.width + 10; // Added some padding
- this.yLabelHeight = bbox.height + 5;
- });
- },
-};
-</script>
-<template>
- <g class="axis-label-container">
- <line
- :y1="yPosition"
- :x2="graphWidth + 20"
- :y2="yPosition"
- class="label-x-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- />
- <line
- :x2="10"
- :y2="yPosition"
- class="label-y-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- y1="0"
- />
- <rect
- :transform="rectTransform"
- :width="yLabelWidth"
- :height="yLabelHeight"
- class="rect-axis-text"
- />
- <text
- ref="ylabel"
- :transform="textTransform"
- class="label-axis-text y-label-text"
- text-anchor="middle"
- >
- {{ yAxisLabelSentenceCase }}
- </text>
- <rect :x="xPosition + 60" :y="graphHeight - 80" class="rect-axis-text" width="35" height="50" />
- <text :x="xPosition + 60" :y="yPosition" class="label-axis-text x-label-text" dy=".35em">
- {{ timeString }}
- </text>
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue
deleted file mode 100644
index bee9784692c..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/deployment.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-export default {
- props: {
- deploymentData: {
- type: Array,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- },
- computed: {
- calculatedHeight() {
- return this.graphHeight - this.graphHeightOffset;
- },
- },
- methods: {
- transformDeploymentGroup(deployment) {
- return `translate(${Math.floor(deployment.xPos) - 5}, 20)`;
- },
- },
-};
-</script>
-<template>
- <g class="deploy-info">
- <g
- v-for="(deployment, index) in deploymentData"
- :key="index"
- :transform="transformDeploymentGroup(deployment)"
- >
- <rect :height="calculatedHeight" x="0" y="0" width="3" fill="url(#shadow-gradient)" />
- <line :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" stroke="#000" />
- </g>
- <svg height="0" width="0">
- <defs>
- <linearGradient id="shadow-gradient">
- <stop offset="0%" stop-color="#000" stop-opacity="0.4" />
- <stop offset="100%" stop-color="#000" stop-opacity="0" />
- </linearGradient>
- </defs>
- </svg>
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
deleted file mode 100644
index 9d6d1caef80..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/flag.vue
+++ /dev/null
@@ -1,151 +0,0 @@
-<script>
-import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
-import { formatRelevantDigits } from '../../../lib/utils/number_utils';
-import Icon from '../../../vue_shared/components/icon.vue';
-import TrackLine from './track_line.vue';
-
-export default {
- components: {
- Icon,
- TrackLine,
- },
- props: {
- currentXCoordinate: {
- type: Number,
- required: true,
- },
- currentData: {
- type: Object,
- required: true,
- },
- deploymentFlagData: {
- type: Object,
- required: false,
- default: null,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- realPixelRatio: {
- type: Number,
- required: true,
- },
- showFlagContent: {
- type: Boolean,
- required: true,
- },
- timeSeries: {
- type: Array,
- required: true,
- },
- unitOfDisplay: {
- type: String,
- required: true,
- },
- legendTitle: {
- type: String,
- required: true,
- },
- currentCoordinates: {
- type: Object,
- required: true,
- },
- },
- computed: {
- formatTime() {
- return this.deploymentFlagData
- ? timeFormat(this.deploymentFlagData.time)
- : timeFormat(this.currentData.time);
- },
- formatDate() {
- return this.deploymentFlagData
- ? dateFormat(this.deploymentFlagData.time)
- : dateFormat(this.currentData.time);
- },
- cursorStyle() {
- const xCoordinate = this.deploymentFlagData
- ? this.deploymentFlagData.xPos
- : this.currentXCoordinate;
-
- const offsetTop = 20 * this.realPixelRatio;
- const offsetLeft = (70 + xCoordinate) * this.realPixelRatio;
- const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio;
-
- return {
- top: `${offsetTop}px`,
- left: `${offsetLeft}px`,
- height: `${height}px`,
- };
- },
- flagOrientation() {
- if (this.currentXCoordinate * this.realPixelRatio > 120) {
- return 'left';
- }
- return 'right';
- },
- },
- methods: {
- seriesMetricValue(seriesIndex, series) {
- const indexFromCoordinates = this.currentCoordinates[series.metricTag]
- ? this.currentCoordinates[series.metricTag].currentDataIndex
- : 0;
- const index = this.deploymentFlagData
- ? this.deploymentFlagData.seriesIndex
- : indexFromCoordinates;
- const value = series.values[index] && series.values[index].value;
- if (Number.isNaN(value)) {
- return '-';
- }
- return `${formatRelevantDigits(value)}${this.unitOfDisplay}`;
- },
- seriesMetricLabel(index, series) {
- if (this.timeSeries.length < 2) {
- return this.legendTitle;
- }
- if (series.metricTag) {
- return series.metricTag;
- }
- return `series ${index + 1}`;
- },
- },
-};
-</script>
-
-<template>
- <div :style="cursorStyle" class="prometheus-graph-cursor">
- <div v-if="showFlagContent" :class="flagOrientation" class="prometheus-graph-flag popover">
- <div class="arrow-shadow"></div>
- <div class="arrow"></div>
- <div class="popover-title">
- <h5 v-if="deploymentFlagData">Deployed</h5>
- {{ formatDate }} <strong>{{ formatTime }}</strong>
- </div>
- <div v-if="deploymentFlagData" class="popover-content deploy-meta-content">
- <div>
- <icon :size="12" name="commit" />
- <a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} </a>
- </div>
- <div v-if="deploymentFlagData.tag">
- <icon :size="12" name="label" />
- <a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} </a>
- </div>
- </div>
- <div class="popover-content">
- <table class="prometheus-table">
- <tr v-for="(series, index) in timeSeries" :key="index">
- <track-line :track="series" />
- <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
- <td>
- <strong>{{ seriesMetricValue(index, series) }}</strong>
- </td>
- </tr>
- </table>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue
deleted file mode 100644
index b5211c306a3..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/legend.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-<script>
-import TrackLine from './track_line.vue';
-import TrackInfo from './track_info.vue';
-
-export default {
- components: {
- TrackLine,
- TrackInfo,
- },
- props: {
- legendTitle: {
- type: String,
- required: true,
- },
- timeSeries: {
- type: Array,
- required: true,
- },
- },
- methods: {
- isStable(track) {
- return {
- 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
- };
- },
- },
-};
-</script>
-<template>
- <div class="prometheus-graph-legends prepend-left-10">
- <table class="prometheus-table">
- <tr
- v-for="(series, index) in timeSeries"
- v-if="series.shouldRenderLegend"
- :key="index"
- :class="isStable(series)"
- >
- <td>
- <strong v-if="series.renderCanary">{{ series.trackName }}</strong>
- </td>
- <track-line :track="series" />
- <td v-if="timeSeries.length > 1" class="legend-metric-title">
- <track-info v-if="series.metricTag" :track="series" />
- <track-info v-else :track="series">
- <strong>{{ legendTitle }}</strong> series {{ index + 1 }}
- </track-info>
- </td>
- <td v-else>
- <track-info :track="series">
- <strong>{{ legendTitle }}</strong>
- </track-info>
- </td>
- <template v-for="(track, trackIndex) in series.tracksLegend">
- <track-line :key="`track-line-${trackIndex}`" :track="track" />
- <td :key="`track-info-${trackIndex}`">
- <track-info :track="track" class="legend-metric-title" />
- </td>
- </template>
- </tr>
- </table>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue
deleted file mode 100644
index f2c237ec391..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/path.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-export default {
- props: {
- generatedLinePath: {
- type: String,
- required: true,
- },
- generatedAreaPath: {
- type: String,
- required: true,
- },
- lineStyle: {
- type: String,
- required: false,
- default: '',
- },
- lineColor: {
- type: String,
- required: true,
- },
- areaColor: {
- type: String,
- required: true,
- },
- currentCoordinates: {
- type: Object,
- required: false,
- default: () => ({ currentX: 0, currentY: 0 }),
- },
- showDot: {
- type: Boolean,
- required: true,
- },
- },
- computed: {
- strokeDashArray() {
- if (this.lineStyle === 'dashed') return '3, 1';
- if (this.lineStyle === 'dotted') return '1, 1';
- return null;
- },
- },
-};
-</script>
-<template>
- <g transform="translate(-5, 20)">
- <circle
- v-if="showDot"
- :cx="currentCoordinates.currentX"
- :cy="currentCoordinates.currentY"
- :fill="lineColor"
- :stroke="lineColor"
- class="circle-path"
- r="3"
- />
- <path :d="generatedAreaPath" :fill="areaColor" class="metric-area" />
- <path
- :d="generatedLinePath"
- :stroke="lineColor"
- :stroke-dasharray="strokeDashArray"
- class="metric-line"
- fill="none"
- stroke-width="1"
- />
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue
deleted file mode 100644
index 3464067834f..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/track_info.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { formatRelevantDigits } from '~/lib/utils/number_utils';
-
-export default {
- name: 'TrackInfo',
- props: {
- track: {
- type: Object,
- required: true,
- },
- },
- computed: {
- summaryMetrics() {
- return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits(
- this.track.max,
- )}`;
- },
- },
-};
-</script>
-<template>
- <span>
- <slot>
- <strong> {{ track.metricTag }} </strong>
- </slot>
- {{ summaryMetrics }}
- </span>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue
deleted file mode 100644
index d2ed1ba113e..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/track_line.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-export default {
- name: 'TrackLine',
- props: {
- track: {
- type: Object,
- required: true,
- },
- },
- computed: {
- stylizedLine() {
- if (this.track.lineStyle === 'dashed') return '6, 3';
- if (this.track.lineStyle === 'dotted') return '3, 3';
- return null;
- },
- },
-};
-</script>
-<template>
- <td>
- <svg width="16" height="8">
- <line
- :stroke-dasharray="stylizedLine"
- :stroke="track.lineColor"
- :x1="0"
- :x2="16"
- :y1="4"
- :y2="4"
- stroke-width="4"
- />
- </svg>
- </td>
-</template>
diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/monitoring/event_hub.js
deleted file mode 100644
index 0948c2e5352..00000000000
--- a/app/assets/javascripts/monitoring/event_hub.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import Vue from 'vue';
-
-export default new Vue();
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
deleted file mode 100644
index 87c3d969de4..00000000000
--- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { bisectDate } from '../utils/date_time_formatters';
-
-const mixins = {
- methods: {
- mouseOverDeployInfo(mouseXPos) {
- if (!this.reducedDeploymentData) return false;
-
- let dataFound = false;
- this.reducedDeploymentData = this.reducedDeploymentData.map(d => {
- const deployment = d;
- if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
- dataFound = d.xPos + 1;
-
- deployment.showDeploymentFlag = true;
- } else {
- deployment.showDeploymentFlag = false;
- }
- return deployment;
- });
-
- return dataFound;
- },
-
- formatDeployments() {
- this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
- const time = new Date(deployment.created_at);
- const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time));
-
- time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
-
- if (xPos >= 0) {
- const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1);
-
- deploymentDataArray.push({
- id: deployment.id,
- time,
- sha: deployment.sha,
- commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
- tag: deployment.tag,
- tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
- ref: deployment.ref.name,
- xPos,
- seriesIndex,
- showDeploymentFlag: false,
- });
- }
-
- return deploymentDataArray;
- }, []);
- },
-
- positionFlag() {
- const timeSeries = this.seriesUnderMouse[0];
- if (!timeSeries) {
- return;
- }
- const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate);
-
- this.currentData = timeSeries.values[hoveredDataIndex];
- this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time));
-
- this.currentCoordinates = {};
-
- this.seriesUnderMouse.forEach(series => {
- const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate);
- const currentData = series.values[currentDataIndex];
- const currentX = Math.floor(series.timeSeriesScaleX(currentData.time));
- const currentY = Math.floor(series.timeSeriesScaleY(currentData.value));
-
- this.currentCoordinates[series.metricTag] = {
- currentX,
- currentY,
- currentDataIndex,
- };
- });
-
- if (this.hoverData.currentDeployXPos) {
- this.showFlag = false;
- } else {
- this.showFlag = true;
- }
- },
- },
-};
-
-export default mixins;
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 8692c873a41..70635059bd9 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -13,7 +13,7 @@ function checkQueryEmptyData(query) {
result: query.result.filter(timeSeries => {
const newTimeSeries = timeSeries;
const hasValue = series =>
- !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined);
+ !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined);
const hasNonNullValue = timeSeries.values.find(hasValue);
newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : [];
@@ -33,10 +33,10 @@ function normalizeMetrics(metrics) {
...query,
result: query.result.map(result => ({
...result,
- values: result.values.map(([timestamp, value]) => ({
- time: new Date(timestamp * 1000),
- value: Number(value),
- })),
+ values: result.values.map(([timestamp, value]) => [
+ new Date(timestamp * 1000).toISOString(),
+ Number(value),
+ ]),
})),
}));
@@ -66,9 +66,7 @@ export default class MonitoringStore {
}
storeEnvironmentsData(environmentsData = []) {
- this.environmentsData = environmentsData.filter(
- environment => !!environment.latest.last_deployment,
- );
+ this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment);
}
getMetricsCount() {
diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
deleted file mode 100644
index d88c13609dc..00000000000
--- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { timeFormat as time } from 'd3-time-format';
-import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time';
-import { bisector } from 'd3-array';
-
-const d3 = {
- time,
- bisector,
- timeSecond,
- timeMinute,
- timeHour,
- timeDay,
- timeWeek,
- timeMonth,
- timeYear,
-};
-
-export const dateFormat = d3.time('%d %b %Y, ');
-export const timeFormat = d3.time('%-I:%M%p');
-export const dateFormatWithName = d3.time('%a, %b %-d');
-export const bisectDate = d3.bisector(d => d.time).left;
-
-export function timeScaleFormat(date) {
- let formatFunction;
- if (d3.timeSecond(date) < date) {
- formatFunction = d3.time('.%L');
- } else if (d3.timeMinute(date) < date) {
- formatFunction = d3.time(':%S');
- } else if (d3.timeHour(date) < date) {
- formatFunction = d3.time('%-I:%M');
- } else if (d3.timeDay(date) < date) {
- formatFunction = d3.time('%-I %p');
- } else if (d3.timeWeek(date) < date) {
- formatFunction = d3.time('%a %d');
- } else if (d3.timeMonth(date) < date) {
- formatFunction = d3.time('%b %d');
- } else if (d3.timeYear(date) < date) {
- formatFunction = d3.time('%B');
- } else {
- formatFunction = d3.time('%Y');
- }
- return formatFunction(date);
-}
diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js
deleted file mode 100644
index 7c771f43eee..00000000000
--- a/app/assets/javascripts/monitoring/utils/measurements.js
+++ /dev/null
@@ -1,44 +0,0 @@
-export default {
- small: {
- // Covers both xs and sm screen sizes
- margin: {
- top: 40,
- right: 40,
- bottom: 50,
- left: 40,
- },
- legends: {
- width: 15,
- height: 3,
- offsetX: 20,
- offsetY: 32,
- },
- backgroundLegend: {
- width: 30,
- height: 50,
- },
- axisLabelLineOffset: -20,
- },
- large: {
- // This covers both md and lg screen sizes
- margin: {
- top: 80,
- right: 80,
- bottom: 100,
- left: 80,
- },
- legends: {
- width: 15,
- height: 3,
- offsetX: 20,
- offsetY: 34,
- },
- backgroundLegend: {
- width: 30,
- height: 150,
- },
- axisLabelLineOffset: 20,
- },
- xTicks: 8,
- yTicks: 3,
-};
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
deleted file mode 100644
index 50ba14dfb2e..00000000000
--- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js
+++ /dev/null
@@ -1,223 +0,0 @@
-import _ from 'underscore';
-import { scaleLinear, scaleTime } from 'd3-scale';
-import { line, area, curveLinear } from 'd3-shape';
-import { extent, max, sum } from 'd3-array';
-import { timeMinute, timeSecond } from 'd3-time';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-const d3 = {
- scaleLinear,
- scaleTime,
- line,
- area,
- curveLinear,
- extent,
- max,
- timeMinute,
- timeSecond,
- sum,
-};
-
-const defaultColorPalette = {
- blue: ['#1f78d1', '#8fbce8'],
- orange: ['#fc9403', '#feca81'],
- red: ['#db3b21', '#ed9d90'],
- green: ['#1aaa55', '#8dd5aa'],
- purple: ['#6666c4', '#d1d1f0'],
-};
-
-const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
-
-const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
-
-function queryTimeSeries(query, graphDrawData, lineStyle) {
- let usedColors = [];
- let renderCanary = false;
- const timeSeriesParsed = [];
-
- function pickColor(name) {
- let pick;
- if (name && defaultColorPalette[name]) {
- pick = name;
- } else {
- const unusedColors = _.difference(defaultColorOrder, usedColors);
- if (unusedColors.length > 0) {
- [pick] = unusedColors;
- } else {
- usedColors = [];
- [pick] = defaultColorOrder;
- }
- }
- usedColors.push(pick);
- return defaultColorPalette[pick];
- }
-
- function findByDate(series, time) {
- const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60);
- if (val) {
- return val.value;
- }
- return NaN;
- }
-
- // The timeseries data may have gaps in it
- // but we need a regularly-spaced set of time/value pairs
- // this gives us a complete range of one minute intervals
- // offset the same amount as the original data
- const [minX, maxX] = graphDrawData.xDom;
- const offset = d3.timeMinute(minX) - Number(minX);
- const datesWithoutGaps = d3.timeSecond
- .every(60)
- .range(d3.timeMinute.offset(minX, -1), maxX)
- .map(d => d - offset);
-
- query.result.forEach((timeSeries, timeSeriesNumber) => {
- let metricTag = '';
- let lineColor = '';
- let areaColor = '';
- let shouldRenderLegend = true;
- const timeSeriesValues = timeSeries.values.map(d => d.value);
- const maximumValue = d3.max(timeSeriesValues);
- const accum = d3.sum(timeSeriesValues);
- const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable');
-
- if (trackName === 'Canary') {
- renderCanary = true;
- }
-
- const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
- const seriesCustomizationData =
- query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
-
- if (seriesCustomizationData) {
- metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
- [lineColor, areaColor] = pickColor(seriesCustomizationData.color);
- if (timeSeriesParsed.length > 0) {
- shouldRenderLegend = false;
- } else {
- shouldRenderLegend = true;
- }
- } else {
- metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
- [lineColor, areaColor] = pickColor();
- if (timeSeriesParsed.length > 1) {
- shouldRenderLegend = false;
- }
- }
-
- const values = datesWithoutGaps.map(time => ({
- time,
- value: findByDate(timeSeries.values, time),
- }));
-
- timeSeriesParsed.push({
- linePath: graphDrawData.lineFunction(values),
- areaPath: graphDrawData.areaBelowLine(values),
- timeSeriesScaleX: graphDrawData.timeSeriesScaleX,
- timeSeriesScaleY: graphDrawData.timeSeriesScaleY,
- values: timeSeries.values,
- max: maximumValue,
- average: accum / timeSeries.values.length,
- lineStyle,
- lineColor,
- areaColor,
- metricTag,
- trackName,
- 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;
-}
-
-function xyDomain(queries) {
- const allValues = queries.reduce(
- (allQueryResults, query) =>
- allQueryResults.concat(
- query.result.reduce((allResults, result) => allResults.concat(result.values), []),
- ),
- [],
- );
-
- const xDom = d3.extent(allValues, d => d.time);
- const yDom = [0, d3.max(allValues.map(d => d.value))];
-
- return {
- xDom,
- yDom,
- };
-}
-
-export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) {
- const { xDom, yDom } = xyDomain(queries);
-
- const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
- const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
-
- timeSeriesScaleX.domain(xDom);
- timeSeriesScaleX.ticks(d3.timeMinute, 60);
- timeSeriesScaleY.domain(yDom);
-
- const defined = d => !Number.isNaN(d.value) && d.value != null;
-
- const lineFunction = d3
- .line()
- .defined(defined)
- .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
- .x(d => timeSeriesScaleX(d.time))
- .y(d => timeSeriesScaleY(d.value));
-
- const areaBelowLine = d3
- .area()
- .defined(defined)
- .curve(d3.curveLinear)
- .x(d => timeSeriesScaleX(d.time))
- .y0(graphHeight - graphHeightOffset)
- .y1(d => timeSeriesScaleY(d.value));
-
- const areaAboveLine = d3
- .area()
- .defined(defined)
- .curve(d3.curveLinear)
- .x(d => timeSeriesScaleX(d.time))
- .y0(0)
- .y1(d => timeSeriesScaleY(d.value));
-
- return {
- lineFunction,
- areaBelowLine,
- areaAboveLine,
- xDom,
- yDom,
- timeSeriesScaleX,
- timeSeriesScaleY,
- };
-}
-
-export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
- const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset);
-
- const timeSeries = queries.reduce((series, query, index) => {
- const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length];
- return series.concat(queryTimeSeries(query, graphDrawData, lineStyle));
- }, []);
-
- return {
- timeSeries,
- graphDrawData,
- };
-}
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index c3443c300e3..c9c01354333 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1239,15 +1239,13 @@ export default class Notes {
var postUrl = $originalContentEl.data('postUrl');
var targetId = $originalContentEl.data('targetId');
var targetType = $originalContentEl.data('targetType');
- var markdownVersion = $originalContentEl.data('markdownVersion');
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm
.find('form')
.attr('action', `${postUrl}?html=true`)
- .attr('data-remote', 'true')
- .attr('data-markdown-version', markdownVersion);
+ .attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
$editForm
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index d669ba5a8fa..1d6cb9485f7 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -39,11 +39,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
},
data() {
return {
@@ -342,7 +337,6 @@ Please check your network connection and try again.`;
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
- :markdown-version="markdownVersion"
:add-spacing-classes="false"
>
<textarea
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
new file mode 100644
index 00000000000..ea590905e3c
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+ name: 'ReplyPlaceholder',
+};
+</script>
+
+<template>
+ <button
+ ref="button"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
+ :title="s__('MergeRequests|Add a reply')"
+ @click="$emit('onClick')"
+ >
+ {{ s__('MergeRequests|Reply...') }}
+ </button>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index d99694b06e9..cad0d382fa2 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -2,11 +2,13 @@
import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import ReplyButton from './note_actions/reply_button.vue';
export default {
name: 'NoteActions',
components: {
Icon,
+ ReplyButton,
GlLoadingIcon,
},
directives: {
@@ -21,6 +23,11 @@ export default {
type: [String, Number],
required: true,
},
+ discussionId: {
+ type: String,
+ required: false,
+ default: '',
+ },
noteUrl: {
type: String,
required: false,
@@ -36,6 +43,10 @@ export default {
required: false,
default: null,
},
+ showReply: {
+ type: Boolean,
+ required: true,
+ },
canEdit: {
type: Boolean,
required: true,
@@ -80,6 +91,9 @@ export default {
},
computed: {
...mapGetters(['getUserDataByProp']),
+ showReplyButton() {
+ return gon.features && gon.features.replyToIndividualNotes && this.showReply;
+ },
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -137,10 +151,9 @@ export default {
</div>
<div v-if="canAwardEmoji" class="note-actions-item">
<a
- v-gl-tooltip.bottom
+ v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button js-add-award js-note-emoji"
- data-position="right"
href="#"
title="Add reaction"
>
@@ -153,9 +166,15 @@ export default {
<icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" />
</a>
</div>
+ <reply-button
+ v-if="showReplyButton"
+ ref="replyButton"
+ class="js-reply-button"
+ :note-id="discussionId"
+ />
<div v-if="canEdit" class="note-actions-item">
<button
- v-gl-tooltip.bottom
+ v-gl-tooltip
type="button"
title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent"
@@ -166,7 +185,7 @@ export default {
</div>
<div v-if="showDeleteAction" class="note-actions-item">
<button
- v-gl-tooltip.bottom
+ v-gl-tooltip
type="button"
title="Delete comment"
class="note-action-button js-note-delete btn btn-transparent"
@@ -177,7 +196,7 @@ export default {
</div>
<div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item">
<button
- v-gl-tooltip.bottom
+ v-gl-tooltip
type="button"
title="More actions"
class="note-action-button more-actions-toggle btn btn-transparent"
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
new file mode 100644
index 00000000000..b2f9d7f128a
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -0,0 +1,40 @@
+<script>
+import { mapActions } from 'vuex';
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ReplyButton',
+ components: {
+ Icon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ noteId: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['convertToDiscussion']),
+ },
+};
+</script>
+
+<template>
+ <div class="note-actions-item">
+ <gl-button
+ ref="button"
+ v-gl-tooltip.bottom
+ class="note-action-button"
+ variant="transparent"
+ :title="__('Reply to comment')"
+ @click="convertToDiscussion(noteId)"
+ >
+ <icon name="comment" css-classes="link-highlight" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 3efdd1c5c17..17e5fcab5b7 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -171,7 +171,6 @@ export default {
:class="getAwardClassBindings(awardList)"
:title="awardTitle(awardList)"
data-boundary="viewport"
- data-placement="bottom"
class="btn award-control"
type="button"
@click="handleAward(awardName)"
@@ -187,7 +186,6 @@ export default {
title="Add reaction"
aria-label="Add reaction"
data-boundary="viewport"
- data-placement="bottom"
type="button"
>
<span class="award-control-icon award-control-icon-neutral">
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index bcf5d334da4..ff303d0f55a 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -111,7 +111,6 @@ export default {
: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_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 269b4a4b117..92258a25438 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -26,11 +26,6 @@ export default {
required: false,
default: '',
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
saveButtonTitle: {
type: String,
required: false,
@@ -202,7 +197,6 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 695efe3602f..b7e9f7c2028 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -24,6 +24,7 @@ import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import discussionNavigation from '../mixins/discussion_navigation';
+import ReplyPlaceholder from './discussion_reply_placeholder.vue';
import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue';
export default {
@@ -39,6 +40,7 @@ export default {
resolveDiscussionButton,
jumpToNextDiscussionButton,
toggleRepliesWidget,
+ ReplyPlaceholder,
placeholderNote,
placeholderSystemNote,
systemNote,
@@ -396,6 +398,7 @@ Please check your network connection and try again.`;
:line="line"
:commit="commit"
:help-page-path="helpPagePath"
+ :show-reply-button="canReply"
@handleDeleteNote="deleteNoteHandler"
>
<note-edited-text
@@ -447,14 +450,7 @@ Please check your network connection and try again.`;
>
<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>
+ <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" />
<resolve-discussion-button
v-if="discussion.resolvable"
:is-resolving="isResolving"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3c48d81ed05..56108a58010 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -29,6 +29,11 @@ export default {
type: Object,
required: true,
},
+ discussion: {
+ type: Object,
+ required: false,
+ default: null,
+ },
line: {
type: Object,
required: false,
@@ -54,7 +59,7 @@ export default {
};
},
computed: {
- ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']),
+ ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
author() {
return this.note.author;
},
@@ -80,6 +85,19 @@ export default {
isTarget() {
return this.targetNoteHash === this.noteAnchorId;
},
+ discussionId() {
+ if (this.discussion) {
+ return this.discussion.id;
+ }
+ return '';
+ },
+ showReplyButton() {
+ if (!this.discussion || !this.getNoteableData.current_user.can_create_note) {
+ return false;
+ }
+
+ return this.discussion.individual_note && !this.commentsDisabled;
+ },
actionText() {
if (!this.commit) {
return '';
@@ -231,6 +249,7 @@ export default {
:note-id="note.id"
:note-url="note.noteable_note_url"
:access-level="note.human_access"
+ :show-reply="showReplyButton"
:can-edit="note.current_user.can_edit"
:can-award-emoji="note.current_user.can_award_emoji"
:can-delete="note.current_user.can_edit"
@@ -241,6 +260,7 @@ export default {
:is-resolved="note.resolved"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
+ :discussion-id="discussionId"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index f3fcfdfda05..6d72b72e628 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -44,11 +44,6 @@ export default {
required: false,
default: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
helpPagePath: {
type: String,
required: false,
@@ -204,7 +199,12 @@ export default {
:key="discussion.id"
:note="discussion.notes[0]"
/>
- <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" />
+ <noteable-note
+ v-else
+ :key="discussion.id"
+ :note="discussion.notes[0]"
+ :discussion="discussion"
+ />
</template>
<noteable-discussion
v-else
@@ -216,10 +216,6 @@ export default {
</template>
</ul>
- <comment-form
- v-if="!commentsDisabled"
- :noteable-type="noteableType"
- :markdown-version="markdownVersion"
- />
+ <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" />
</div>
</template>
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 2f715c85fa6..4883266dae5 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -18,7 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData);
- const markdownVersion = parseInt(notesDataset.markdownVersion, 10);
let currentUserData = {};
noteableData.noteableType = notesDataset.noteableType;
@@ -37,7 +36,6 @@ document.addEventListener('DOMContentLoaded', () => {
return {
noteableData,
currentUserData,
- markdownVersion,
notesData: JSON.parse(notesDataset.notesData),
};
},
@@ -47,7 +45,6 @@ document.addEventListener('DOMContentLoaded', () => {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
- markdownVersion: this.markdownVersion,
},
});
},
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 2105a62cecb..ff65f14d529 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -426,5 +426,8 @@ export const submitSuggestion = (
});
};
+export const convertToDiscussion = ({ commit }, noteId) =>
+ commit(types.CONVERT_TO_DISCUSSION, noteId);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index df943c155f4..2bffedad336 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -17,6 +17,7 @@ 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';
+export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 33d39ad2ec9..d167f8ef421 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -264,4 +264,9 @@ export default {
).length;
state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1;
},
+
+ [types.CONVERT_TO_DISCUSSION](state, discussionId) {
+ const discussion = utils.findNoteObjectById(state.discussions, discussionId);
+ Object.assign(discussion, { individual_note: false });
+ },
};
diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js
index 8f98be79640..01001d4f3ff 100644
--- a/app/assets/javascripts/pages/dashboard/projects/index.js
+++ b/app/assets/javascripts/pages/dashboard/projects/index.js
@@ -1,7 +1,5 @@
import ProjectsList from '~/projects_list';
-import Star from '../../../star';
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/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js
index 8f98be79640..01001d4f3ff 100644
--- a/app/assets/javascripts/pages/explore/projects/index.js
+++ b/app/assets/javascripts/pages/explore/projects/index.js
@@ -1,7 +1,5 @@
import ProjectsList from '~/projects_list';
-import Star from '../../../star';
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/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index c7ce4675573..0dd0d5336fc 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import createFlash from '~/flash';
import GfmAutoComplete from '~/gfm_auto_complete';
+import emojiRegex from 'emoji-regex';
import EmojiMenu from './emoji_menu';
const defaultStatusEmoji = 'speech_balloon';
@@ -42,6 +43,17 @@ document.addEventListener('DOMContentLoaded', () => {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+ const userNameInput = document.getElementById('user_name');
+ userNameInput.addEventListener('input', () => {
+ const EMOJI_REGEX = emojiRegex();
+ if (EMOJI_REGEX.test(userNameInput.value)) {
+ // set field to invalid so it gets detected by GlFieldErrors
+ userNameInput.setCustomValidity('Invalid field');
+ } else {
+ userNameInput.setCustomValidity('');
+ }
+ });
+
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
const emojiMenu = new EmojiMenu(
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
index 0b644780ad4..0d69a689316 100644
--- a/app/assets/javascripts/pages/projects/environments/metrics/index.js
+++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js
@@ -1,3 +1,3 @@
-import monitoringBundle from '~/monitoring/monitoring_bundle';
+import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle';
document.addEventListener('DOMContentLoaded', monitoringBundle);
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index 3ccad513c05..26d7fa7371d 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -43,10 +43,26 @@ document.addEventListener('DOMContentLoaded', () => {
],
});
+ const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => {
+ if (firstDayOfWeek === 0) {
+ return weekDays;
+ }
+
+ return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => {
+ const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length];
+
+ return {
+ ...acc,
+ [reorderedDayName]: weekDays[reorderedDayName],
+ };
+ }, {});
+ };
+
const hourData = chartData(projectChartData.hour);
responsiveChart($('#hour-chart'), hourData);
- const dayData = chartData(projectChartData.weekDays);
+ const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week);
+ const dayData = chartData(weekDays);
responsiveChart($('#weekday-chart'), dayData);
const monthData = chartData(projectChartData.month);
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 8a84ac37dab..afa099d0e0b 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -159,7 +159,7 @@ export default class ActivityCalendar {
.append('g')
.attr('transform', (group, i) => {
_.each(group, (stamp, a) => {
- if (a === 0 && stamp.day === 0) {
+ if (a === 0 && stamp.day === this.firstDayOfWeek) {
const month = stamp.date.getMonth();
const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace;
const lastMonth = _.last(this.months);
@@ -205,6 +205,14 @@ export default class ActivityCalendar {
y: 29 + this.dayYPos(5),
},
];
+
+ if (this.firstDayOfWeek === 1) {
+ days.push({
+ text: 'S',
+ y: 29 + this.dayYPos(7),
+ });
+ }
+
this.svg
.append('g')
.selectAll('text')
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 1c3fd58ca74..39cd891c111 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -234,7 +234,7 @@ export default class UserTabs {
data,
calendarActivitiesPath,
utcOffset,
- 0,
+ gon.first_day_of_week,
monthsAgo,
);
}
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index cf9db89e32b..2b32a6e4a98 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -108,7 +108,7 @@ export default {
:href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
- class="js-pipeline-graph-job-link"
+ class="js-pipeline-graph-job-link qa-job-link"
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index a33835472bb..5ee510eb11d 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -5,97 +5,101 @@ import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
export default function projectSelect() {
- $('.ajax-project-select').each(function(i, select) {
- var placeholder;
- const simpleFilter = $(select).data('simpleFilter') || false;
- this.groupId = $(select).data('groupId');
- this.includeGroups = $(select).data('includeGroups');
- this.allProjects = $(select).data('allProjects') || false;
- this.orderBy = $(select).data('orderBy') || 'id';
- this.withIssuesEnabled = $(select).data('withIssuesEnabled');
- this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
- this.withShared =
- $(select).data('withShared') === undefined ? true : $(select).data('withShared');
- this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
- this.allowClear = $(select).data('allowClear') || false;
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('.ajax-project-select').each(function(i, select) {
+ var placeholder;
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ this.groupId = $(select).data('groupId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
+ this.withShared =
+ $(select).data('withShared') === undefined ? true : $(select).data('withShared');
+ this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
+ this.allowClear = $(select).data('allowClear') || false;
- placeholder = 'Search for project';
- if (this.includeGroups) {
- placeholder += ' or group';
- }
+ placeholder = 'Search for project';
+ if (this.includeGroups) {
+ placeholder += ' or group';
+ }
- $(select).select2({
- placeholder: placeholder,
- minimumInputLength: 0,
- query: (function(_this) {
- return function(query) {
- var finalCallback, projectsCallback;
- finalCallback = function(projects) {
- var data;
- data = {
- results: projects,
- };
- return query.callback(data);
- };
- if (_this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
+ $(select).select2({
+ placeholder: placeholder,
+ minimumInputLength: 0,
+ query: (function(_this) {
+ return function(query) {
+ var finalCallback, projectsCallback;
+ finalCallback = function(projects) {
var data;
- data = groups.concat(projects);
- return finalCallback(data);
+ data = {
+ results: projects,
+ };
+ return query.callback(data);
};
- return Api.groups(query.term, {}, groupsCallback);
+ if (_this.includeGroups) {
+ projectsCallback = function(projects) {
+ var groupsCallback;
+ groupsCallback = function(groups) {
+ var data;
+ data = groups.concat(projects);
+ return finalCallback(data);
+ };
+ return Api.groups(query.term, {}, groupsCallback);
+ };
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (_this.groupId) {
+ return Api.groupProjects(
+ _this.groupId,
+ query.term,
+ {
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ with_shared: _this.withShared,
+ include_subgroups: _this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ } else {
+ return Api.projects(
+ query.term,
+ {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ membership: !_this.allProjects,
+ },
+ projectsCallback,
+ );
+ }
};
- } else {
- projectsCallback = finalCallback;
- }
- if (_this.groupId) {
- return Api.groupProjects(
- _this.groupId,
- query.term,
- {
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- with_shared: _this.withShared,
- include_subgroups: _this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
- } else {
- return Api.projects(
- query.term,
- {
- order_by: _this.orderBy,
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- membership: !_this.allProjects,
- },
- projectsCallback,
- );
- }
- };
- })(this),
- id: function(project) {
- if (simpleFilter) return project.id;
- return JSON.stringify({
- name: project.name,
- url: project.web_url,
- });
- },
- text: function(project) {
- return project.name_with_namespace || project.name;
- },
+ })(this),
+ id: function(project) {
+ if (simpleFilter) return project.id;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
+ });
+ },
+ text: function(project) {
+ return project.name_with_namespace || project.name;
+ },
- initSelection: function(el, callback) {
- return Api.project(el.val()).then(({ data }) => callback(data));
- },
+ initSelection: function(el, callback) {
+ return Api.project(el.val()).then(({ data }) => callback(data));
+ },
- allowClear: this.allowClear,
+ allowClear: this.allowClear,
- dropdownCssClass: 'ajax-project-dropdown',
- });
- if (simpleFilter) return select;
- return new ProjectSelectComboButton(select);
- });
+ dropdownCssClass: 'ajax-project-dropdown',
+ });
+ if (simpleFilter) return select;
+ return new ProjectSelectComboButton(select);
+ });
+ })
+ .catch(() => {});
}
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
index 3dbac3ff942..d3b5f532dc1 100644
--- a/app/assets/javascripts/project_select_combo_button.js
+++ b/app/assets/javascripts/project_select_combo_button.js
@@ -44,9 +44,13 @@ export default class ProjectSelectComboButton {
// eslint-disable-next-line class-methods-use-this
openDropdown(event) {
- $(event.currentTarget)
- .siblings('.project-item-select')
- .select2('open');
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $(event.currentTarget)
+ .siblings('.project-item-select')
+ .select2('open');
+ })
+ .catch(() => {});
}
selectProject() {
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 998554d1be5..d65e73a3f9c 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -115,15 +115,35 @@ const bindEvents = () => {
const templates = {
rails: {
text: 'Ruby on Rails',
- icon: '.template-option svg.icon-rails',
+ icon: '.template-option .icon-rails',
},
express: {
text: 'NodeJS Express',
- icon: '.template-option svg.icon-node-express',
+ icon: '.template-option .icon-express',
},
spring: {
text: 'Spring',
- icon: '.template-option svg.icon-java-spring',
+ icon: '.template-option .icon-spring',
+ },
+ hugo: {
+ text: 'Pages/Hugo',
+ icon: '.template-option .icon-hugo',
+ },
+ jekyll: {
+ text: 'Pages/Jekyll',
+ icon: '.template-option .icon-jekyll',
+ },
+ plainhtml: {
+ text: 'Pages/Plain HTML',
+ icon: '.template-option .icon-plainhtml',
+ },
+ gitbook: {
+ text: 'Pages/GitBook',
+ icon: '.template-option .icon-gitbook',
+ },
+ hexo: {
+ text: 'Pages/Hexo',
+ icon: '.template-option .icon-hexo',
},
};
diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue
new file mode 100644
index 00000000000..4d18c5c4bdd
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/environment_row.vue
@@ -0,0 +1,65 @@
+<script>
+import FunctionRow from './function_row.vue';
+import ItemCaret from '~/groups/components/item_caret.vue';
+
+export default {
+ components: {
+ ItemCaret,
+ FunctionRow,
+ },
+ props: {
+ env: {
+ type: Array,
+ required: true,
+ },
+ envName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isOpen: true,
+ };
+ },
+ computed: {
+ envId() {
+ if (this.envName === '*') {
+ return 'env-global';
+ }
+
+ return `env-${this.envName}`;
+ },
+ isOpenClass() {
+ return {
+ 'is-open': this.isOpen,
+ };
+ },
+ },
+ methods: {
+ toggleOpen() {
+ this.isOpen = !this.isOpen;
+ },
+ },
+};
+</script>
+
+<template>
+ <li :id="envId" :class="isOpenClass" class="group-row has-children">
+ <div
+ class="group-row-contents d-flex justify-content-end align-items-center"
+ role="button"
+ @click.stop="toggleOpen"
+ >
+ <div class="folder-toggle-wrap d-flex align-items-center">
+ <item-caret :is-group-open="isOpen" />
+ </div>
+ <div class="group-text flex-grow title namespace-title prepend-left-default">
+ {{ envName }}
+ </div>
+ </div>
+ <ul v-if="isOpen" class="content-list group-list-tree">
+ <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" />
+ </ul>
+ </li>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index 2b1c21f041b..4f89ad69129 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -1,13 +1,11 @@
<script>
import PodBox from './pod_box.vue';
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import Icon from '~/vue_shared/components/icon.vue';
+import Url from './url.vue';
export default {
components: {
- Icon,
PodBox,
- ClipboardButton,
+ Url,
},
props: {
func: {
@@ -36,24 +34,9 @@ export default {
<section id="serverless-function-details">
<h3>{{ name }}</h3>
<div class="append-bottom-default">
- <div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div>
- </div>
- <div class="clipboard-group append-bottom-default">
- <div class="label label-monospace">{{ funcUrl }}</div>
- <clipboard-button
- :text="String(funcUrl)"
- :title="s__('ServerlessDetails|Copy URL to clipboard')"
- class="input-group-text js-clipboard-btn"
- />
- <a
- :href="funcUrl"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="input-group-text btn btn-default"
- >
- <icon name="external-link" />
- </a>
+ <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
+ <url :uri="funcUrl" />
<h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
<div v-if="podCount > 0">
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 44bfae388cb..773d18781fd 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,9 +1,12 @@
<script>
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import Url from './url.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
export default {
components: {
Timeago,
+ Url,
},
props: {
func: {
@@ -16,13 +19,18 @@ export default {
return this.func.name;
},
description() {
- return this.func.description;
+ const desc = this.func.description.split('\n');
+ if (desc.length > 1) {
+ return desc[1];
+ }
+
+ return desc[0];
},
detailUrl() {
return this.func.detail_url;
},
- environment() {
- return this.func.environment_scope;
+ targetUrl() {
+ return this.func.url;
},
image() {
return this.func.image;
@@ -31,25 +39,34 @@ export default {
return this.func.created_at;
},
},
+ methods: {
+ checkClass(element) {
+ if (element.closest('.no-expand') === null) {
+ return true;
+ }
+
+ return false;
+ },
+ openDetails(e) {
+ if (this.checkClass(e.target)) {
+ visitUrl(this.detailUrl);
+ }
+ },
+ },
};
</script>
<template>
- <div class="gl-responsive-table-row">
- <div class="table-section section-20 section-wrap">
- <a :href="detailUrl">{{ name }}</a>
- </div>
- <div class="table-section section-10">{{ environment }}</div>
- <div class="table-section section-40 section-wrap">
- <span class="line-break">{{ description }}</span>
+ <li :id="name" class="group-row">
+ <div class="group-row-contents" role="button" @click="openDetails">
+ <p class="float-right text-right">
+ <span>{{ image }}</span
+ ><br />
+ <timeago :time="timestamp" />
+ </p>
+ <b>{{ name }}</b>
+ <div v-for="line in description.split('\n')" :key="line">{{ line }}</div>
+ <url :uri="targetUrl" class="prepend-top-8 no-expand" />
</div>
- <div class="table-section section-20">{{ image }}</div>
- <div class="table-section section-10"><timeago :time="timestamp" /></div>
- </div>
+ </li>
</template>
-
-<style>
-.line-break {
- white-space: pre;
-}
-</style>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 9606a78410e..4bde409f906 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,19 +1,21 @@
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
+import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
export default {
components: {
+ EnvironmentRow,
FunctionRow,
EmptyState,
GlSkeletonLoading,
},
props: {
functions: {
- type: Array,
+ type: Object,
required: true,
- default: () => [],
+ default: () => ({}),
},
installed: {
type: Boolean,
@@ -45,33 +47,21 @@ export default {
<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-10" role="rowheader">
- {{ s__('Serverless|Cluster Env') }}
- </div>
- <div class="table-section section-40" role="rowheader">
- {{ s__('Serverless|Description') }}
- </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>
+ <template v-if="loadingData">
+ <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
+ </template>
+ <template v-else>
+ <div class="groups-list-tree-container">
+ <ul class="content-list group-list-tree">
+ <environment-row
+ v-for="(env, index) in functions"
+ :key="index"
+ :env="env"
+ :env-name="index"
+ />
+ </ul>
</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>
+ </template>
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
@@ -111,16 +101,3 @@ export default {
<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/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
new file mode 100644
index 00000000000..ca53bf6c52a
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ ClipboardButton,
+ },
+ props: {
+ uri: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="clipboard-group">
+ <div class="url-text-field label label-monospace">{{ uri }}</div>
+ <clipboard-button
+ :text="uri"
+ :title="s__('ServerlessURL|Copy URL to clipboard')"
+ class="input-group-text js-clipboard-btn"
+ />
+ <gl-button
+ :href="uri"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="input-group-text btn btn-default"
+ >
+ <icon name="external-link" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
index 774c15b5b12..816d55a03f9 100644
--- a/app/assets/javascripts/serverless/stores/serverless_store.js
+++ b/app/assets/javascripts/serverless/stores/serverless_store.js
@@ -1,7 +1,7 @@
export default class ServerlessStore {
constructor(knativeInstalled = false, clustersPath, helpPath) {
this.state = {
- functions: [],
+ functions: {},
hasFunctionData: true,
loadingData: true,
installed: knativeInstalled,
@@ -10,8 +10,13 @@ export default class ServerlessStore {
};
}
- updateFunctionsFromServer(functions = []) {
- this.state.functions = functions;
+ updateFunctionsFromServer(upstreamFunctions = []) {
+ this.state.functions = upstreamFunctions.reduce((rv, func) => {
+ const envs = rv;
+ envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
+
+ return envs;
+ }, {});
}
updateLoadingState(loadingData) {
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index edefb3735d7..5172a1ef3d6 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import 'deckar01-task_list';
+import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
@@ -8,46 +9,79 @@ export default class TaskList {
this.selector = options.selector;
this.dataType = options.dataType;
this.fieldName = options.fieldName;
+ this.lockVersion = options.lockVersion;
+ this.taskListContainerSelector = `${this.selector} .js-task-list-container`;
+ this.updateHandler = this.update.bind(this);
this.onSuccess = options.onSuccess || (() => {});
- this.onError = function showFlash(e) {
- let errorMessages = '';
+ this.onError =
+ options.onError ||
+ function showFlash(e) {
+ let errorMessages = '';
- if (e.response.data && typeof e.response.data === 'object') {
- errorMessages = e.response.data.errors.join(' ');
- }
+ if (e.response.data && typeof e.response.data === 'object') {
+ errorMessages = e.response.data.errors.join(' ');
+ }
- return new Flash(errorMessages || 'Update failed', 'alert');
- };
+ return new Flash(errorMessages || __('Update failed'), 'alert');
+ };
this.init();
}
init() {
- // Prevent duplicate event bindings
- this.disable();
- $(`${this.selector} .js-task-list-container`).taskList('enable');
- $(document).on(
- 'tasklist:changed',
- `${this.selector} .js-task-list-container`,
- this.update.bind(this),
- );
+ this.disable(); // Prevent duplicate event bindings
+
+ $(this.taskListContainerSelector).taskList('enable');
+ $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
+ }
+
+ getTaskListTarget(e) {
+ return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector);
+ }
+
+ disableTaskListItems(e) {
+ this.getTaskListTarget(e).taskList('disable');
+ }
+
+ enableTaskListItems(e) {
+ this.getTaskListTarget(e).taskList('enable');
}
disable() {
- $(`${this.selector} .js-task-list-container`).taskList('disable');
- $(document).off('tasklist:changed', `${this.selector} .js-task-list-container`);
+ this.disableTaskListItems();
+ $(document).off('tasklist:changed', this.taskListContainerSelector);
}
update(e) {
const $target = $(e.target);
+ const { index, checked, lineNumber, lineSource } = e.detail;
const patchData = {};
+
patchData[this.dataType] = {
[this.fieldName]: $target.val(),
+ lock_version: this.lockVersion,
+ update_task: {
+ index,
+ checked,
+ line_number: lineNumber,
+ line_source: lineSource,
+ },
};
+ this.disableTaskListItems(e);
+
return axios
.patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData)
- .then(({ data }) => this.onSuccess(data))
- .catch(err => this.onError(err));
+ .then(({ data }) => {
+ this.lockVersion = data.lock_version;
+ this.enableTaskListItems(e);
+
+ return this.onSuccess(data);
+ })
+ .catch(({ response }) => {
+ this.enableTaskListItems(e);
+
+ return this.onError(response.data);
+ });
}
}
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index ce051582299..4017630d6ef 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -579,101 +579,109 @@ function UsersSelect(currentUser, els, options = {}) {
};
})(this),
);
- $('.ajax-users-select').each(
- (function(_this) {
- return function(i, select) {
- var firstUser, showAnyUser, showEmailUser, showNullUser;
- var options = {};
- options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('projectId');
- options.groupId = $(select).data('groupId');
- options.showCurrentUser = $(select).data('currentUser');
- options.authorId = $(select).data('authorId');
- options.skipUsers = $(select).data('skipUsers');
- showNullUser = $(select).data('nullUser');
- showAnyUser = $(select).data('anyUser');
- showEmailUser = $(select).data('emailUser');
- firstUser = $(select).data('firstUser');
- return $(select).select2({
- placeholder: 'Search for a user',
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query: function(query) {
- return _this.users(query.term, options, function(users) {
- var anyUser, data, emailUser, index, len, name, nullUser, obj, ref;
- data = {
- results: users,
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- ref = data.results;
-
- for (index = 0, len = ref.length; index < len; index += 1) {
- obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('.ajax-users-select').each(
+ (function(_this) {
+ return function(i, select) {
+ var firstUser, showAnyUser, showEmailUser, showNullUser;
+ var options = {};
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ options.projectId = $(select).data('projectId');
+ options.groupId = $(select).data('groupId');
+ options.showCurrentUser = $(select).data('currentUser');
+ options.authorId = $(select).data('authorId');
+ options.skipUsers = $(select).data('skipUsers');
+ showNullUser = $(select).data('nullUser');
+ showAnyUser = $(select).data('anyUser');
+ showEmailUser = $(select).data('emailUser');
+ firstUser = $(select).data('firstUser');
+ return $(select).select2({
+ placeholder: 'Search for a user',
+ multiple: $(select).hasClass('multiselect'),
+ minimumInputLength: 0,
+ query: function(query) {
+ return _this.users(query.term, options, function(users) {
+ var anyUser, data, emailUser, index, len, name, nullUser, obj, ref;
+ data = {
+ results: users,
+ };
+ if (query.term.length === 0) {
+ if (firstUser) {
+ // Move current user to the front of the list
+ ref = data.results;
+
+ for (index = 0, len = ref.length; index < len; index += 1) {
+ obj = ref[index];
+ if (obj.username === firstUser) {
+ data.results.splice(index, 1);
+ data.results.unshift(obj);
+ break;
+ }
+ }
+ }
+ if (showNullUser) {
+ nullUser = {
+ name: 'Unassigned',
+ id: 0,
+ };
+ data.results.unshift(nullUser);
+ }
+ if (showAnyUser) {
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
+ }
+ anyUser = {
+ name: name,
+ id: null,
+ };
+ data.results.unshift(anyUser);
}
}
- }
- if (showNullUser) {
- nullUser = {
- name: 'Unassigned',
- id: 0,
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
+ if (
+ showEmailUser &&
+ data.results.length === 0 &&
+ query.term.match(/^[^@]+@[^@]+$/)
+ ) {
+ var trimmed = query.term.trim();
+ emailUser = {
+ name: 'Invite "' + trimmed + '" by email',
+ username: trimmed,
+ id: trimmed,
+ invite: true,
+ };
+ data.results.unshift(emailUser);
}
- anyUser = {
- name: name,
- id: null,
- };
- data.results.unshift(anyUser);
- }
- }
- if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
- var trimmed = query.term.trim();
- emailUser = {
- name: 'Invite "' + trimmed + '" by email',
- username: trimmed,
- id: trimmed,
- invite: true,
- };
- data.results.unshift(emailUser);
- }
- return query.callback(data);
+ return query.callback(data);
+ });
+ },
+ initSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.initSelection.apply(_this, args);
+ },
+ formatResult: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatResult.apply(_this, args);
+ },
+ formatSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatSelection.apply(_this, args);
+ },
+ dropdownCssClass: 'ajax-users-dropdown',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup: function(m) {
+ return m;
+ },
});
- },
- initSelection: function() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.initSelection.apply(_this, args);
- },
- formatResult: function() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.formatResult.apply(_this, args);
- },
- formatSelection: function() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.formatSelection.apply(_this, args);
- },
- dropdownCssClass: 'ajax-users-dropdown',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup: function(m) {
- return m;
- },
- });
- };
- })(this),
- );
+ };
+ })(this),
+ );
+ })
+ .catch(() => {});
}
UsersSelect.prototype.initSelection = function(element, callback) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
new file mode 100644
index 00000000000..a38f25cce35
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -0,0 +1,40 @@
+<script>
+export default {
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <li>
+ <div class="commit-message-editor">
+ <div class="d-flex flex-wrap align-items-center justify-content-between">
+ <label class="col-form-label" :for="inputId">
+ <strong>{{ label }}</strong>
+ </label>
+ <slot name="header"></slot>
+ </div>
+ <textarea
+ :id="inputId"
+ :value="value"
+ class="form-control js-gfm-input append-bottom-default commit-message-edit"
+ required="required"
+ rows="7"
+ @input="$emit('input', $event.target.value)"
+ ></textarea>
+ <slot name="checkbox"></slot>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
new file mode 100644
index 00000000000..b3c1c0e329d
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ commits: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown
+ right
+ no-caret
+ text="Use an existing commit message"
+ variant="link"
+ class="mr-commit-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="commit in commits"
+ :key="commit.short_id"
+ class="text-nowrap text-truncate"
+ @click="$emit('input', commit.message)"
+ >
+ <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
new file mode 100644
index 00000000000..a1d3a09cca4
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import _ from 'underscore';
+import { __, n__, sprintf, s__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ },
+ props: {
+ isSquashEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ commitsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ expanded: false,
+ };
+ },
+ computed: {
+ collapseIcon() {
+ return this.expanded ? 'chevron-down' : 'chevron-right';
+ },
+ commitsCountMessage() {
+ return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount);
+ },
+ modifyLinkMessage() {
+ return this.isSquashEnabled ? __('Modify commit messages') : __('Modify merge commit');
+ },
+ ariaLabel() {
+ return this.expanded ? __('Collapse') : __('Expand');
+ },
+ message() {
+ return sprintf(
+ s__(
+ 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.',
+ ),
+ {
+ commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`,
+ mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`,
+ targetBranch: `<span class="label-branch">${_.escape(this.targetBranch)}</span>`,
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ toggle() {
+ this.expanded = !this.expanded;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="js-mr-widget-commits-count mr-widget-extension clickable d-flex align-items-center px-3 py-2"
+ @click="toggle()"
+ >
+ <gl-button
+ :aria-label="ariaLabel"
+ variant="blank"
+ class="commit-edit-toggle mr-2"
+ @click.stop="toggle()"
+ >
+ <icon :name="collapseIcon" :size="16" />
+ </gl-button>
+ <span v-if="expanded">{{ __('Collapse') }}</span>
+ <span v-else>
+ <span v-html="message"></span>
+ <gl-button variant="link" class="modify-message-button">
+ {{ modifyLinkMessage }}
+ </gl-button>
+ </span>
+ </div>
+ <div v-show="expanded"><slot></slot></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index b8f29649eb5..ce4207864ea 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -2,17 +2,24 @@
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import simplePoll from '~/lib/utils/simple_poll';
+import { __ } from '~/locale';
import MergeRequest from '../../../merge_request';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
import SquashBeforeMerge from './squash_before_merge.vue';
+import CommitsHeader from './commits_header.vue';
+import CommitEdit from './commit_edit.vue';
+import CommitMessageDropdown from './commit_message_dropdown.vue';
export default {
name: 'ReadyToMerge',
components: {
statusIcon,
SquashBeforeMerge,
+ CommitsHeader,
+ CommitEdit,
+ CommitMessageDropdown,
},
props: {
mr: { type: Object, required: true },
@@ -22,27 +29,20 @@ export default {
return {
removeSourceBranch: this.mr.shouldRemoveSourceBranch,
mergeWhenBuildSucceeds: false,
- useCommitMessageWithDescription: false,
setToMergeWhenPipelineSucceeds: false,
- showCommitMessageEditor: false,
isMakingRequest: false,
isMergingImmediately: false,
commitMessage: this.mr.commitMessage,
squashBeforeMerge: this.mr.squash,
successSvg,
warningSvg,
+ squashCommitMessage: this.mr.squashCommitMessage,
};
},
computed: {
shouldShowMergeWhenPipelineSucceedsText() {
return this.mr.isPipelineActive;
},
- commitMessageLinkTitle() {
- const withDesc = 'Include description in commit message';
- const withoutDesc = "Don't include description in commit message";
-
- return this.useCommitMessageWithDescription ? withoutDesc : withDesc;
- },
status() {
const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr;
@@ -84,9 +84,9 @@ export default {
},
mergeButtonText() {
if (this.isMergingImmediately) {
- return 'Merge in progress';
+ return __('Merge in progress');
} else if (this.shouldShowMergeWhenPipelineSucceedsText) {
- return 'Merge when pipeline succeeds';
+ return __('Merge when pipeline succeeds');
}
return 'Merge';
@@ -98,7 +98,7 @@ export default {
const { commitMessage } = this;
return Boolean(
!commitMessage.length ||
- !this.shouldShowMergeControls() ||
+ !this.shouldShowMergeControls ||
this.isMakingRequest ||
this.mr.preventMerge,
);
@@ -110,18 +110,14 @@ export default {
const { commitsCount, enableSquashBeforeMerge } = this.mr;
return enableSquashBeforeMerge && commitsCount > 1;
},
- },
- methods: {
shouldShowMergeControls() {
return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText;
},
- updateCommitMessage() {
- const cmwd = this.mr.commitMessageWithDescription;
- this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription;
- this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage;
- },
- toggleCommitMessageEditor() {
- this.showCommitMessageEditor = !this.showCommitMessageEditor;
+ },
+ methods: {
+ updateMergeCommitMessage(includeDescription) {
+ const { commitMessageWithDescription, commitMessage } = this.mr;
+ this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage;
},
handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) {
// TODO: Remove no-param-reassign
@@ -139,6 +135,7 @@ export default {
merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds,
should_remove_source_branch: this.removeSourceBranch === true,
squash: this.squashBeforeMerge,
+ squash_commit_message: this.squashCommitMessage,
};
this.isMakingRequest = true;
@@ -158,7 +155,7 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line
});
},
initiateMergePolling() {
@@ -194,7 +191,7 @@ export default {
}
})
.catch(() => {
- new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line
+ new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line
});
},
initiateRemoveSourceBranchPolling() {
@@ -223,7 +220,7 @@ export default {
}
})
.catch(() => {
- new Flash('Something went wrong while deleting the source branch. Please try again.'); // eslint-disable-line
+ new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line
});
},
},
@@ -231,127 +228,136 @@ export default {
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :status="iconClass" />
- <div class="media-body">
- <div class="mr-widget-body-controls media space-children">
- <span class="btn-group">
- <button
- :disabled="isMergeButtonDisabled"
- :class="mergeButtonClass"
- type="button"
- class="qa-merge-button"
- @click="handleMergeButtonClick()"
- >
- <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
- {{ mergeButtonText }}
- </button>
- <button
- v-if="shouldShowMergeOptionsDropdown"
- :disabled="isMergeButtonDisabled"
- type="button"
- class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
- data-toggle="dropdown"
- aria-label="Select merge moment"
- >
- <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i>
- </button>
- <ul
- v-if="shouldShowMergeOptionsDropdown"
- class="dropdown-menu dropdown-menu-right"
- role="menu"
- >
- <li>
- <a
- class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option"
- href="#"
- @click.prevent="handleMergeButtonClick(true)"
- >
- <span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span>
- <span class="media-body merge-opt-title">Merge when pipeline succeeds</span>
- </span>
- </a>
- </li>
- <li>
- <a
- class="accept-merge-request qa-merge-immediately-option"
- href="#"
- @click.prevent="handleMergeButtonClick(false, true)"
- >
- <span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
- <span class="media-body merge-opt-title">Merge immediately</span>
- </span>
- </a>
- </li>
- </ul>
- </span>
- <div class="media-body-wrap space-children">
- <template v-if="shouldShowMergeControls()">
- <label v-if="mr.canRemoveSourceBranch">
- <input
- id="remove-source-branch-input"
- v-model="removeSourceBranch"
- :disabled="isRemoveSourceBranchButtonDisabled"
- class="js-remove-source-branch-checkbox"
- type="checkbox"
- />
- Delete source branch
- </label>
-
- <!-- Placeholder for EE extension of this component -->
- <squash-before-merge
- v-if="shouldShowSquashBeforeMerge"
- v-model="squashBeforeMerge"
- :help-path="mr.squashBeforeMergeHelpPath"
- :is-disabled="isMergeButtonDisabled"
- />
-
- <span v-if="mr.ffOnlyEnabled" class="js-fast-forward-message">
- Fast-forward merge without a merge commit
- </span>
+ <div>
+ <div class="mr-widget-body media">
+ <status-icon :status="iconClass" />
+ <div class="media-body">
+ <div class="mr-widget-body-controls media space-children">
+ <span class="btn-group">
<button
- v-else
:disabled="isMergeButtonDisabled"
- class="js-modify-commit-message-button btn btn-default btn-sm"
+ :class="mergeButtonClass"
type="button"
- @click="toggleCommitMessageEditor"
+ class="qa-merge-button"
+ @click="handleMergeButtonClick()"
>
- Modify commit message
+ <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
+ {{ mergeButtonText }}
</button>
- </template>
- <template v-else>
- <span class="bold js-resolve-mr-widget-items-message">
- You can only merge once the items above are resolved
- </span>
- </template>
- </div>
- </div>
- <div v-if="showCommitMessageEditor" class="prepend-top-default commit-message-editor">
- <div class="form-group clearfix">
- <label class="col-form-label" for="commit-message"> Commit message </label>
- <div class="col-sm-10">
- <div class="commit-message-container">
- <div class="max-width-marker"></div>
- <textarea
- id="commit-message"
- v-model="commitMessage"
- class="form-control js-commit-message"
- required="required"
- rows="14"
- name="Commit message"
- ></textarea>
- </div>
- <p class="hint">
- Try to keep the first line under 52 characters and the others under 72
- </p>
- <div class="hint">
- <a href="#" @click.prevent="updateCommitMessage"> {{ commitMessageLinkTitle }} </a>
- </div>
+ <button
+ v-if="shouldShowMergeOptionsDropdown"
+ :disabled="isMergeButtonDisabled"
+ type="button"
+ class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
+ data-toggle="dropdown"
+ aria-label="Select merge moment"
+ >
+ <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i>
+ </button>
+ <ul
+ v-if="shouldShowMergeOptionsDropdown"
+ class="dropdown-menu dropdown-menu-right"
+ role="menu"
+ >
+ <li>
+ <a
+ class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option"
+ href="#"
+ @click.prevent="handleMergeButtonClick(true)"
+ >
+ <span class="media">
+ <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span>
+ <span class="media-body merge-opt-title">{{
+ __('Merge when pipeline succeeds')
+ }}</span>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ class="accept-merge-request qa-merge-immediately-option"
+ href="#"
+ @click.prevent="handleMergeButtonClick(false, true)"
+ >
+ <span class="media">
+ <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
+ <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span>
+ </span>
+ </a>
+ </li>
+ </ul>
+ </span>
+ <div class="media-body-wrap space-children">
+ <template v-if="shouldShowMergeControls">
+ <label v-if="mr.canRemoveSourceBranch">
+ <input
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ class="js-remove-source-branch-checkbox"
+ type="checkbox"
+ />
+ {{ __('Delete source branch') }}
+ </label>
+
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ v-model="squashBeforeMerge"
+ :help-path="mr.squashBeforeMergeHelpPath"
+ :is-disabled="isMergeButtonDisabled"
+ />
+ </template>
+ <template v-else>
+ <span class="bold js-resolve-mr-widget-items-message">
+ {{ __('You can only merge once the items above are resolved') }}
+ </span>
+ </template>
</div>
</div>
</div>
</div>
+ <template v-if="shouldShowMergeControls">
+ <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message">
+ {{ __('Fast-forward merge without a merge commit') }}
+ </div>
+ <template v-else>
+ <commits-header
+ :is-squash-enabled="squashBeforeMerge"
+ :commits-count="mr.commitsCount"
+ :target-branch="mr.targetBranch"
+ >
+ <ul class="border-top content-list commits-list flex-list">
+ <commit-edit
+ v-if="squashBeforeMerge"
+ v-model="squashCommitMessage"
+ :label="__('Squash commit message')"
+ input-id="squash-message-edit"
+ squash
+ >
+ <commit-message-dropdown
+ slot="header"
+ v-model="squashCommitMessage"
+ :commits="mr.commits"
+ />
+ </commit-edit>
+ <commit-edit
+ v-model="commitMessage"
+ :label="__('Merge commit message')"
+ input-id="merge-message-edit"
+ >
+ <label slot="checkbox">
+ <input
+ id="include-description"
+ type="checkbox"
+ @change="updateMergeCommitMessage($event.target.checked)"
+ />
+ {{ __('Include merge request description') }}
+ </label>
+ </commit-edit>
+ </ul>
+ </commits-header>
+ </template>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js
deleted file mode 100644
index 8780aa4bd1c..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import MRWidgetOptions from './mr_widget_options.vue';
-
-export default MRWidgetOptions;
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 60cebbfc2b2..0cedbdbdfef 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import MrWidgetOptions from './ee_switch_mr_widget_options';
+import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import Translate from '../vue_shared/translate';
Vue.use(Translate);
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 5a9d86594b1..abbbe19c5ef 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
@@ -3,6 +3,9 @@ import _ from 'underscore';
import { __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
+import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
+import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
+import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import createFlash from '../flash';
import WidgetHeader from './components/mr_widget_header.vue';
import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
@@ -28,10 +31,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue';
import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds.vue';
import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue';
import CheckingState from './components/states/mr_widget_checking.vue';
-import MRWidgetStore from './stores/ee_switch_mr_widget_store';
-import MRWidgetService from './services/ee_switch_mr_widget_service';
import eventHub from './event_hub';
-import stateMaps from './stores/ee_switch_state_maps';
import notify from '~/lib/utils/notify';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
@@ -142,8 +142,8 @@ export default {
}
},
methods: {
- createService(store) {
- const endpoints = {
+ getServiceEndpoints(store) {
+ return {
mergePath: store.mergePath,
mergeCheckPath: store.mergeCheckPath,
cancelAutoMergePath: store.cancelAutoMergePath,
@@ -154,7 +154,9 @@ export default {
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
};
- return new MRWidgetService(endpoints);
+ },
+ createService(store) {
+ return new MRWidgetService(this.getServiceEndpoints(store));
},
checkStatus(cb, isRebased) {
return this.service
@@ -313,7 +315,7 @@ export default {
:endpoint="mr.testResultsPath"
/>
- <div class="mr-widget-section">
+ <div class="mr-widget-section p-0">
<component :is="componentName" :mr="mr" :service="service" />
<section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links">
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js
deleted file mode 100644
index ea2aabb78fe..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import MRWidgetService from './mr_widget_service';
-
-export default MRWidgetService;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js
deleted file mode 100644
index ebef30e3eab..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import getStateKey from './get_state_key';
-
-export default getStateKey;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js
deleted file mode 100644
index 92a07c53f2d..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import MergeRequestStore from './mr_widget_store';
-
-export default MergeRequestStore;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js
deleted file mode 100644
index 50cf9503ea7..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import stateMaps from './state_maps';
-
-export default stateMaps;
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 e5a52c6a7f6..58363f632a9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -1,5 +1,5 @@
import Timeago from 'timeago.js';
-import getStateKey from './ee_switch_get_state_key';
+import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { stateKey } from './state_maps';
import { formatDate } from '../../lib/utils/datetime_utility';
@@ -32,16 +32,18 @@ export default class MergeRequestStore {
this.sourceBranchProtected = data.source_branch_protected;
this.conflictsDocsPath = data.conflicts_docs_path;
this.mergeStatus = data.merge_status;
- this.commitMessage = data.merge_commit_message;
+ this.commitMessage = data.default_merge_commit_message;
this.shortMergeCommitSha = data.short_merge_commit_sha;
this.mergeCommitSha = data.merge_commit_sha;
- this.commitMessageWithDescription = data.merge_commit_message_with_description;
+ this.commitMessageWithDescription = data.default_merge_commit_message_with_description;
this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
this.mergePipeline = data.merge_pipeline || {};
this.deployments = this.deployments || data.deployments || [];
this.postMergeDeployments = this.postMergeDeployments || [];
+ this.commits = data.commits_without_merge_commits || [];
+ this.squashCommitMessage = data.default_squash_commit_message;
this.initRebase(data);
if (data.issues_links) {
diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 0b0cd7b75eb..b57455adaad 100644
--- a/app/assets/javascripts/ide/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -1,45 +1,62 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
import Item from './item.vue';
-import router from '../../ide_router';
-import {
- MAX_FILE_FINDER_RESULTS,
- FILE_FINDER_ROW_HEIGHT,
- FILE_FINDER_EMPTY_ROW_HEIGHT,
-} from '../../constants';
-import {
- UP_KEY_CODE,
- DOWN_KEY_CODE,
- ENTER_KEY_CODE,
- ESC_KEY_CODE,
-} from '../../../lib/utils/keycodes';
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+
+export const MAX_FILE_FINDER_RESULTS = 40;
+export const FILE_FINDER_ROW_HEIGHT = 55;
+export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
+
+const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
Item,
VirtualList,
},
+ props: {
+ files: {
+ type: Array,
+ required: true,
+ },
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ showDiffStats: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clearSearchOnClose: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
data() {
return {
- focusedIndex: 0,
+ focusedIndex: -1,
searchText: '',
mouseOver: false,
cancelMouseOver: false,
};
},
computed: {
- ...mapGetters(['allBlobs']),
- ...mapState(['fileFindVisible', 'loading']),
filteredBlobs() {
const searchText = this.searchText.trim();
if (searchText === '') {
- return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS);
+ return this.files.slice(0, MAX_FILE_FINDER_RESULTS);
}
- return fuzzaldrinPlus.filter(this.allBlobs, searchText, {
+ return fuzzaldrinPlus.filter(this.files, searchText, {
key: 'path',
maxResults: MAX_FILE_FINDER_RESULTS,
});
@@ -58,10 +75,12 @@ export default {
},
},
watch: {
- fileFindVisible() {
+ visible() {
this.$nextTick(() => {
- if (!this.fileFindVisible) {
- this.searchText = '';
+ if (!this.visible) {
+ if (this.clearSearchOnClose) {
+ this.searchText = '';
+ }
} else {
this.focusedIndex = 0;
@@ -72,7 +91,11 @@ export default {
});
},
searchText() {
- this.focusedIndex = 0;
+ this.focusedIndex = -1;
+
+ this.$nextTick(() => {
+ this.focusedIndex = 0;
+ });
},
focusedIndex() {
if (!this.mouseOver) {
@@ -98,8 +121,25 @@ export default {
}
},
},
+ mounted() {
+ if (this.files.length) {
+ this.focusedIndex = 0;
+ }
+
+ Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
+
+ this.toggle(!this.visible);
+ });
+
+ Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
+ },
methods: {
- ...mapActions(['toggleFileFinder']),
+ toggle(visible) {
+ this.$emit('toggle', visible);
+ },
clearSearchInput() {
this.searchText = '';
@@ -139,15 +179,15 @@ export default {
this.openFile(this.filteredBlobs[this.focusedIndex]);
break;
case ESC_KEY_CODE:
- this.toggleFileFinder(false);
+ this.toggle(false);
break;
default:
break;
}
},
openFile(file) {
- this.toggleFileFinder(false);
- router.push(`/project${file.url}`);
+ this.toggle(false);
+ this.$emit('click', file);
},
onMouseOver(index) {
if (!this.cancelMouseOver) {
@@ -159,14 +199,26 @@ export default {
this.cancelMouseOver = false;
this.onMouseOver(index);
},
+ mousetrapStopCallback(e, el, combo) {
+ if (
+ (combo === 't' && el.classList.contains('dropdown-input-field')) ||
+ el.classList.contains('inputarea')
+ ) {
+ return true;
+ } else if (combo === 'command+p' || combo === 'ctrl+p') {
+ return false;
+ }
+
+ return originalStopCallback(e, el, combo);
+ },
},
};
</script>
<template>
- <div class="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false)">
- <div class="dropdown-menu diff-file-changes ide-file-finder show">
- <div class="dropdown-input">
+ <div class="file-finder-overlay" @mousedown.self="toggle(false)">
+ <div class="dropdown-menu diff-file-changes file-finder show">
+ <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input">
<input
ref="searchInput"
v-model="searchText"
@@ -186,9 +238,6 @@ export default {
></i>
<i
:aria-label="__('Clear search input')"
- :class="{
- show: showClearInputButton,
- }"
role="button"
class="fa fa-times dropdown-input-clear"
@click="clearSearchInput"
@@ -203,6 +252,7 @@ export default {
:search-text="searchText"
:focused="index === focusedIndex"
:index="index"
+ :show-diff-stats="showDiffStats"
class="disable-hover"
@click="openFile"
@mouseover="onMouseOver"
@@ -225,3 +275,25 @@ export default {
</div>
</div>
</template>
+
+<style scoped>
+.file-finder-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 200;
+}
+
+.file-finder {
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.diff-file-changes {
+ top: 50px;
+ max-height: 327px;
+}
+</style>
diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 83e80d50aff..73511879ff2 100644
--- a/app/assets/javascripts/ide/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -1,5 +1,6 @@
<script>
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '../../../vue_shared/components/file_icon.vue';
import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue';
@@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60;
export default {
components: {
+ Icon,
ChangedFileIcon,
FileIcon,
},
@@ -27,6 +29,11 @@ export default {
type: Number,
required: true,
},
+ showDiffStats: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pathWithEllipsis() {
@@ -97,8 +104,23 @@ export default {
</span>
</span>
</span>
- <span v-if="file.changed || file.tempFile" class="diff-changed-stats">
- <changed-file-icon :file="file" />
+ <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats">
+ <span v-if="showDiffStats">
+ <span class="cgreen bold">
+ <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }}
+ </span>
+ <span class="cred bold ml-1">
+ <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }}
+ </span>
+ </span>
+ <changed-file-icon v-else :file="file" />
</span>
</button>
</template>
+
+<style scoped>
+.highlighted {
+ color: #1f78d1;
+ font-weight: 600;
+}
+</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index cc07ef46064..3f607aa2a0a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -27,11 +27,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -158,7 +153,7 @@ export default {
this.markdownPreviewLoading = true;
this.markdownPreview = __('Loading…');
this.$http
- .post(this.versionedPreviewPath(), { text })
+ .post(this.markdownPreviewPath, { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
.catch(() => new Flash(__('Error loading markdown preview')));
@@ -186,13 +181,6 @@ export default {
.then(() => $(this.$refs['markdown-preview']).renderGFM())
.catch(() => new Flash(__('Error rendering markdown preview')));
},
-
- versionedPreviewPath() {
- const { markdownPreviewPath, markdownVersion } = this;
- return `${markdownPreviewPath}${
- markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
- }markdown_version=${markdownVersion}`;
- },
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
index 8bdb5bf22c2..13eb46437dd 100644
--- a/app/assets/javascripts/vue_shared/components/pikaday.vue
+++ b/app/assets/javascripts/vue_shared/components/pikaday.vue
@@ -40,6 +40,7 @@ export default {
toString: date => pikadayToString(date),
onSelect: this.selected.bind(this),
onClose: this.toggled.bind(this),
+ firstDay: gon.first_day_of_week,
});
this.$el.append(this.calendar.el);
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index d24fe1b547e..f9773622001 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -28,10 +28,10 @@ export default {
},
computed: {
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;
+ if (this.user.status.emoji && this.user.status.message_html) {
+ return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`;
+ } else if (this.user.status.message_html) {
+ return this.user.status.message_html;
}
return '';
},
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 0fb9bde1785..c5c3b66438c 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -89,6 +89,10 @@ hr {
.str-truncated {
@include str-truncated;
+ &-30 {
+ @include str-truncated(30%);
+ }
+
&-60 {
@include str-truncated(60%);
}
@@ -387,14 +391,26 @@ img.emoji {
.flex-no-shrink { flex-shrink: 0; }
.ws-initial { white-space: initial; }
.overflow-auto { overflow: auto; }
+.d-flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
/** COMMON SIZING CLASSES **/
.w-0 { width: 0; }
-.h-13em { height: 13em; }
+
+.h-12em { height: 12em; }
+
.mw-460 { max-width: 460px; }
.mw-6em { max-width: 6em; }
+
.min-height-0 { min-height: 0; }
+.w-3 { width: #{3 * $grid-size}; }
+
+.h-3 { width: #{3 * $grid-size}; }
+
/** COMMON SPACING CLASSES **/
.gl-pl-0 { padding-left: 0; }
.gl-pl-1 { padding-left: #{0.5 * $grid-size}; }
@@ -420,3 +436,9 @@ img.emoji {
.ms-no-clear ::-ms-clear {
display: none;
}
+
+/** COMMON POSITIONING CLASSES */
+.position-bottom-0 { bottom: 0; }
+.position-left-0 { left: 0; }
+.position-right-0 { right: 0; }
+.position-top-0 { top: 0; }
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 037a5adfb7e..6108eaa1ad0 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -4,6 +4,7 @@
*/
.file-holder {
border: 1px solid $border-color;
+ border-top: 0;
border-radius: $border-radius-default;
&.file-holder-no-border {
@@ -51,6 +52,7 @@
position: absolute;
top: 5px;
right: 15px;
+ margin-left: auto;
.btn {
padding: 0 10px;
@@ -324,10 +326,12 @@ span.idiff {
&,
.file-holder & {
display: flex;
+ flex-wrap: wrap;
align-items: center;
justify-content: space-between;
background-color: $gray-light;
border-bottom: 1px solid $border-color;
+ border-top: 1px solid $border-color;
padding: 5px $gl-padding;
margin: 0;
border-radius: $border-radius-default $border-radius-default 0 0;
@@ -365,16 +369,12 @@ span.idiff {
margin: 0 10px 0 0;
}
- .file-actions {
- white-space: nowrap;
-
- .btn {
- padding: 0 10px;
- font-size: 13px;
- line-height: 28px;
- display: inline-block;
- float: none;
- }
+ .file-actions .btn {
+ padding: 0 10px;
+ font-size: 13px;
+ line-height: 28px;
+ display: inline-block;
+ float: none;
}
@include media-breakpoint-down(xs) {
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index a20920e2503..d78c707192f 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -38,7 +38,10 @@
svg {
fill: currentColor;
+}
+.square,
+svg {
$svg-sizes: 8 10 12 14 16 18 24 32 48 72;
@each $svg-size in $svg-sizes {
&.s#{$svg-size} {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 9eae9a831fa..96dab609a13 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -243,6 +243,7 @@ $gl-padding-8: 8px;
$gl-padding: 16px;
$gl-padding-24: 24px;
$gl-padding-32: 32px;
+$gl-padding-50: 50px;
$gl-col-padding: 15px;
$gl-input-padding: 10px;
$gl-vert-padding: 6px;
@@ -490,6 +491,7 @@ $builds-trace-bg: #111;
*/
$commit-max-width-marker-color: rgba(0, 0, 0, 0);
$commit-message-text-area-bg: rgba(0, 0, 0, 0);
+$commit-stat-summary-height: 36px;
/*
* Common
@@ -664,8 +666,14 @@ $priority-label-empty-state-width: 114px;
Issues Analytics
*/
$issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15);
+
/*
Merge Requests
*/
$mr-tabs-height: 51px;
$mr-version-controls-height: 56px;
+
+/*
+Compare Branches
+*/
+$compare-branches-sticky-header-height: 68px;
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 553cc44fe83..2ac98b5d18f 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -395,6 +395,11 @@ $ide-commit-header-height: 48px;
svg {
vertical-align: sub;
}
+
+ .ide-status-avatar {
+ float: none;
+ margin: 0 0 1px;
+ }
}
.ide-status-file {
@@ -811,26 +816,6 @@ $ide-commit-header-height: 48px;
z-index: 1;
}
-.ide-file-finder-overlay {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- z-index: 100;
-}
-
-.ide-file-finder {
- top: 10px;
- left: 50%;
- transform: translateX(-50%);
-
- .highlighted {
- color: $blue-500;
- font-weight: $gl-font-weight-bold;
- }
-}
-
.ide-commit-message-field {
height: 200px;
background-color: $white-light;
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index ad12cd101b6..809ba6d4953 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -58,6 +58,20 @@
}
}
+.cluster-application-banner {
+ height: 45px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.cluster-application-banner-close {
+ align-self: flex-start;
+ font-weight: 500;
+ font-size: 20px;
+ margin: $gl-padding-8 14px 0 0;
+}
+
.cluster-application-description {
flex: 1;
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 02aac58a475..e3b98b26a11 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -7,22 +7,13 @@
cursor: pointer;
@media (min-width: map-get($grid-breakpoints, md)) {
+ $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height;
+
position: -webkit-sticky;
position: sticky;
- top: $mr-version-controls-height + $header-height + $mr-tabs-height;
- margin-left: -1px;
- border-left: 1px solid $border-color;
+ top: $mr-file-header-top;
z-index: 102;
- &.is-commit {
- top: $header-height + 36px;
-
- .with-performance-bar & {
- top: $header-height + 36px + $performance-bar-height;
-
- }
- }
-
&::before {
content: '';
position: absolute;
@@ -35,7 +26,23 @@
}
.with-performance-bar & {
- top: $header-height + $performance-bar-height + $mr-version-controls-height + $mr-tabs-height;
+ top: $mr-file-header-top + $performance-bar-height;
+ }
+
+ &.is-commit {
+ top: $header-height + $commit-stat-summary-height;
+
+ .with-performance-bar & {
+ top: $header-height + $commit-stat-summary-height + $performance-bar-height;
+ }
+ }
+
+ &.is-compare {
+ top: $header-height + $compare-branches-sticky-header-height;
+
+ .with-performance-bar & {
+ top: $performance-bar-height + $header-height + $compare-branches-sticky-header-height;
+ }
}
}
@@ -501,6 +508,25 @@
}
}
+.diff-stats {
+ align-items: center;
+ padding: 0 .25rem;
+
+ .diff-stats-group {
+ padding: 0 .25rem;
+ }
+
+ svg.diff-stats-icon {
+ vertical-align: text-bottom;
+ }
+
+ &.is-compare-versions-header {
+ .diff-stats-group {
+ padding: 0 .5rem;
+ }
+ }
+}
+
.file-content .diff-file {
margin: 0;
border: 0;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index b6abb792709..61ecf133b02 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -240,18 +240,7 @@
}
.prometheus-graph {
- flex: 1 0 auto;
- min-width: 450px;
- max-width: 100%;
padding: $gl-padding / 2;
-
- h5 {
- font-size: 16px;
- }
-
- @include media-breakpoint-down(sm) {
- min-width: 100%;
- }
}
.prometheus-graph-header {
@@ -261,6 +250,7 @@
margin-bottom: $gl-padding-8;
h5 {
+ font-size: $gl-font-size-large;
margin: 0;
}
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 53afb182b54..135730d71e9 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -38,9 +38,7 @@
}
.mr-widget-section {
- .media {
- align-items: center;
- }
+ border-radius: $border-radius-default $border-radius-default 0 0;
.code-text {
flex: 1;
@@ -56,6 +54,11 @@
.mr-widget-extension {
border-top: 1px solid $border-color;
background-color: $gray-light;
+
+ &.clickable:hover {
+ background-color: $gl-gray-200;
+ cursor: pointer;
+ }
}
.mr-widget-workflow {
@@ -78,6 +81,7 @@
border-top: 0;
}
+.mr-widget-body,
.mr-widget-section,
.mr-widget-content,
.mr-widget-footer {
@@ -87,11 +91,38 @@
.mr-state-widget {
color: $gl-text-color;
+ .commit-message-edit {
+ border-radius: $border-radius-default;
+ }
+
.mr-widget-section,
.mr-widget-footer {
border-top: solid 1px $border-color;
}
+ .mr-fast-forward-message {
+ padding-left: $gl-padding-50;
+ padding-bottom: $gl-padding;
+ }
+
+ .commits-list {
+ > li {
+ padding: $gl-padding;
+
+ @include media-breakpoint-up(md) {
+ padding-left: $gl-padding-50;
+ }
+ }
+ }
+
+ .mr-commit-dropdown {
+ .dropdown-menu {
+ @include media-breakpoint-up(md) {
+ width: 150%;
+ }
+ }
+ }
+
.mr-widget-footer {
padding: 0;
}
@@ -405,7 +436,7 @@
}
.mr-widget-help {
- padding: 10px 16px 10px 48px;
+ padding: 10px 16px 10px $gl-padding-50;
font-style: italic;
}
@@ -423,10 +454,6 @@
}
}
-.mr-widget-body-controls {
- flex-wrap: wrap;
-}
-
.mr_source_commit,
.mr_target_commit {
margin-bottom: 0;
@@ -986,3 +1013,9 @@
width: $ci-action-icon-size-lg;
}
}
+
+.merge-request-details .file-finder-overlay.diff-file-finder {
+ position: fixed;
+ z-index: 99999;
+ background: $black-transparent;
+}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 2342c284a5e..66866aedfba 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -507,12 +507,6 @@
}
.template-option {
- .logo {
- .btn-template-icon {
- width: 40px !important;
- }
- }
-
padding: 16px 0;
&:not(:first-child) {
@@ -551,9 +545,8 @@
}
.selected-icon {
- svg {
+ img {
display: none;
- top: 7px;
height: 20px;
width: 20px;
}
@@ -946,6 +939,11 @@ pre.light-well {
.flex-wrapper {
min-width: 0;
margin-top: -$gl-padding-8; // negative margin required for flex-wrap
+ flex: 1 1 100%;
+
+ .project-title {
+ line-height: 20px;
+ }
}
p,
@@ -984,14 +982,16 @@ pre.light-well {
}
.controls {
- margin-top: $gl-padding-8;
+ @include media-breakpoint-down(xs) {
+ margin-top: $gl-padding-8;
+ }
- @include media-breakpoint-down(md) {
+ @include media-breakpoint-up(sm) {
margin-top: 0;
}
- @include media-breakpoint-down(xs) {
- margin-top: $gl-padding-8;
+ @include media-breakpoint-up(lg) {
+ flex: 1 1 40%;
}
.icon-wrapper {
@@ -1041,7 +1041,7 @@ pre.light-well {
min-height: 40px;
min-width: 40px;
- .identicon.s64 {
+ .identicon.s48 {
font-size: 16px;
}
}
diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss
new file mode 100644
index 00000000000..a5b73492380
--- /dev/null
+++ b/app/assets/stylesheets/pages/serverless.scss
@@ -0,0 +1,3 @@
+.url-text-field {
+ cursor: text;
+}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index c5b9d1f6885..811cc310a8f 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -167,12 +167,14 @@
font-weight: $gl-font-weight-normal;
display: inline-block;
color: $gl-text-color;
+ vertical-align: top;
}
.option-description,
.option-disabled-reason {
margin-left: 30px;
color: $project-option-descr-color;
+ margin-top: -5px;
}
.option-disabled-reason {
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index b9717b97640..3bd91b71d92 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -127,6 +127,7 @@ class Clusters::ClustersController < Clusters::BaseController
params.require(:cluster).permit(
:enabled,
:environment_scope,
+ :base_domain,
platform_kubernetes_attributes: [
:namespace
]
@@ -136,6 +137,7 @@ class Clusters::ClustersController < Clusters::BaseController
:enabled,
:name,
:environment_scope,
+ :base_domain,
platform_kubernetes_attributes: [
:api_url,
:token,
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 3d64ae8b775..cd3fa641e89 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -7,6 +7,9 @@ module IssuableActions
included do
before_action :authorize_destroy_issuable!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
+ before_action only: :show do
+ push_frontend_feature_flag(:reply_to_individual_notes)
+ end
end
def permitted_keys
@@ -58,7 +61,8 @@ module IssuableActions
title_text: issuable.title,
description: view_context.markdown_field(issuable, :description),
description_text: issuable.description,
- task_status: issuable.task_status
+ task_status: issuable.task_status,
+ lock_version: issuable.lock_version
}
if issuable.edited?
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index ca713192c9e..6402e01ddc0 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -35,7 +35,9 @@ module MembershipActions
respond_to do |format|
format.html do
- message = "User was successfully removed from #{source_type}."
+ source = source_type == 'group' ? 'group and any subresources' : source_type
+
+ message = "User was successfully removed from #{source}."
redirect_to members_page_url, notice: message
end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index 4b0f0b8255c..f72d25fc54c 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -16,8 +16,6 @@ module PreviewMarkdown
else {}
end
- markdown_params[:markdown_engine] = result[:markdown_engine]
-
render json: {
body: view_context.markdown(result[:text], markdown_params),
references: {
diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb
new file mode 100644
index 00000000000..372c803278d
--- /dev/null
+++ b/app/controllers/concerns/record_user_last_activity.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# == RecordUserLastActivity
+#
+# Controller concern that updates the `last_activity_on` field of `users`
+# for any authenticated GET request. The DB update will only happen once per day.
+#
+# In order to determine if you should include this concern or not, please check the
+# description and discussion on this issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/54947
+module RecordUserLastActivity
+ include CookiesHelper
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :set_user_last_activity
+ end
+
+ def set_user_last_activity
+ return unless request.get?
+ return unless Feature.enabled?(:set_user_last_activity, default_enabled: true)
+ return if Gitlab::Database.read_only?
+
+ if current_user && current_user.last_activity_on != Date.today
+ Users::ActivityService.new(current_user, "visited #{request.path}").execute
+ end
+ end
+end
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
index 515a9eede8e..9ca54c5519b 100644
--- a/app/controllers/concerns/send_file_upload.rb
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -3,16 +3,19 @@
module SendFileUpload
def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment')
if attachment
+ response_disposition = ::Gitlab::ContentDisposition.format(disposition: 'attachment', filename: attachment)
+
# Response-Content-Type will not override an existing Content-Type in
# Google Cloud Storage, so the metadata needs to be cleared on GCS for
# this to work. However, this override works with AWS.
- redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}",
+ redirect_params[:query] = { "response-content-disposition" => response_disposition,
"response-content-type" => guess_content_type(attachment) }
# By default, Rails will send uploads with an extension of .js with a
# content-type of text/javascript, which will trigger Rails'
# cross-origin JavaScript protection.
send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js'
- send_params.merge!(filename: attachment, disposition: disposition)
+
+ send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment))
end
if file_upload.file_storage?
@@ -25,6 +28,18 @@ module SendFileUpload
end
end
+ # Since Rails 5 doesn't properly support support non-ASCII filenames,
+ # we have to add our own to ensure RFC 5987 compliance. However, Rails
+ # 5 automatically appends `filename#{filename}` here:
+ # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137
+ # Rails 6 will have https://github.com/rails/rails/pull/33829, so we
+ # can get rid of this special case handling when we upgrade.
+ def utf8_encoded_disposition(disposition, filename)
+ content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename)
+
+ "#{disposition}; #{content.utf8_filename}"
+ end
+
def guess_content_type(filename)
types = MIME::Types.type_for(filename)
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index c6ae4fe15bf..48451bedcc2 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -72,7 +72,7 @@ module ServiceParams
dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables
service_params = params.permit(:id, service: allowed_service_params + dynamic_params)
- if service_params[:service].is_a?(Hash)
+ if service_params[:service].is_a?(ActionController::Parameters)
FILTER_BLANK_PARAMS.each do |param|
service_params[:service].delete(param) if service_params[:service][param].blank?
end
diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb
index cee0753a021..0e9fdc60363 100644
--- a/app/controllers/dashboard/application_controller.rb
+++ b/app/controllers/dashboard/application_controller.rb
@@ -2,6 +2,7 @@
class Dashboard::ApplicationController < ApplicationController
include ControllerWithCrossProjectAccessCheck
+ include RecordUserLastActivity
layout 'dashboard'
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index cdc6f53df8e..51fdb6c05fb 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -2,6 +2,7 @@
class Groups::BoardsController < Groups::ApplicationController
include BoardsResponses
+ include RecordUserLastActivity
before_action :assign_endpoint_vars
before_action :boards, only: :index
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 15aadf3f74b..4e50106398a 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -5,6 +5,7 @@ class GroupsController < Groups::ApplicationController
include IssuableCollectionsAction
include ParamsBackwardCompatibility
include PreviewMarkdown
+ include RecordUserLastActivity
respond_to :html
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 1b30b4dda36..2b1395f364f 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -8,7 +8,7 @@ class Import::BitbucketController < Import::BaseController
rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized
def callback
- response = client.auth_code.get_token(params[:code], redirect_uri: callback_import_bitbucket_url)
+ response = client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url)
session[:bitbucket_token] = response.token
session[:bitbucket_expires_at] = response.expires_at
@@ -89,7 +89,7 @@ class Import::BitbucketController < Import::BaseController
end
def go_to_bitbucket_for_permissions
- redirect_to client.auth_code.authorize_url(redirect_uri: callback_import_bitbucket_url)
+ redirect_to client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url)
end
def bitbucket_unauthorized
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 34c7dbdc2fe..3fbc0817e95 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -83,7 +83,7 @@ class Import::GithubController < Import::BaseController
end
def callback_import_url
- public_send("callback_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("users_import_#{provider}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def provider_unauthorized
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index f8e482937d5..cc2bb99f55b 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -4,7 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
include Devise::Controllers::Rememberable
- protect_from_forgery except: [:kerberos, :saml, :cas3], prepend: true
+ protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
def handle_omniauth
omniauth_flow(Gitlab::Auth::OAuth)
@@ -116,8 +116,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
session[:service_tickets][provider] = ticket
end
+ def build_auth_user(auth_user_class)
+ auth_user_class.new(oauth)
+ end
+
def sign_in_user_flow(auth_user_class)
- auth_user = auth_user_class.new(oauth)
+ auth_user = build_auth_user(auth_user_class)
user = auth_user.find_and_update!
if auth_user.valid_sign_in?
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 37ac11dc6a1..94002095739 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -33,12 +33,10 @@ class Profiles::PreferencesController < Profiles::ApplicationController
end
def preferences_params
- params.require(:user).permit(
- :color_scheme_id,
- :layout,
- :dashboard,
- :project_view,
- :theme_id
- )
+ params.require(:user).permit(preferences_param_names)
+ end
+
+ def preferences_param_names
+ [:color_scheme_id, :layout, :dashboard, :project_view, :theme_id, :first_day_of_week]
end
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index a63eea0ca0e..e9cd475a199 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -11,10 +11,6 @@ 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)
@@ -25,11 +21,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: {
- environments: EnvironmentSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .within_folders
- .represent(@environments),
+ environments: serialize_environments(request, response, params[:nested]),
available_count: project.environments.available.count,
stopped_count: project.environments.stopped.count
}
@@ -37,6 +29,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ # Returns all environments for a given folder
# rubocop: disable CodeReuse/ActiveRecord
def folder
folder_environments = project.environments.where(environment_type: params[:id])
@@ -48,10 +41,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
format.html
format.json do
render json: {
- environments: EnvironmentSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .represent(@environments),
+ environments: serialize_environments(request, response),
available_count: folder_environments.available.count,
stopped_count: folder_environments.stopped.count
}
@@ -163,6 +153,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def search
+ respond_to do |format|
+ format.json do
+ environment_names = search_environment_names
+
+ render json: environment_names, status: environment_names.any? ? :ok : :no_content
+ end
+ end
+ end
+
private
def verify_api_request!
@@ -186,6 +186,20 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment ||= project.environments.find(params[:id])
end
+ def search_environment_names
+ return [] unless params[:query]
+
+ project.environments.for_name_like(params[:query]).pluck_names
+ end
+
+ def serialize_environments(request, response, nested = false)
+ EnvironmentSerializer
+ .new(project: @project, current_user: @current_user)
+ .tap { |serializer| serializer.within_folders if nested }
+ .with_pagination(request, response)
+ .represent(@environments)
+ end
+
def authorize_stop_environment!
access_denied! unless can?(current_user, :stop_environment, environment)
end
diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb
index 9e403e1d25b..88d0755f41f 100644
--- a/app/controllers/projects/error_tracking_controller.rb
+++ b/app/controllers/projects/error_tracking_controller.rb
@@ -15,6 +15,14 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
end
end
+ def list_projects
+ respond_to do |format|
+ format.json do
+ render_project_list_json
+ end
+ end
+ end
+
private
def render_index_json
@@ -32,6 +40,32 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
}
end
+ def render_project_list_json
+ service = ErrorTracking::ListProjectsService.new(
+ project,
+ current_user,
+ list_projects_params
+ )
+ result = service.execute
+
+ if result[:status] == :success
+ render json: {
+ projects: serialize_projects(result[:projects])
+ }
+ else
+ return render(
+ status: result[:http_status] || :bad_request,
+ json: {
+ message: result[:message]
+ }
+ )
+ end
+ end
+
+ def list_projects_params
+ params.require(:error_tracking_setting).permit([:api_host, :token])
+ end
+
def set_polling_interval
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
end
@@ -41,4 +75,10 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
.new(project: project, user: current_user)
.represent(errors)
end
+
+ def serialize_projects(projects)
+ ErrorTracking::ProjectSerializer
+ .new(project: project, user: current_user)
+ .represent(projects)
+ end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index fd5f3eeaa99..b9d02a62fc3 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -8,6 +8,7 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuableCollections
include IssuesCalendar
include SpammableActions
+ include RecordUserLastActivity
def self.issue_except_actions
%i[index calendar new create bulk_update import_csv]
@@ -19,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController
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 :authenticate_user!, only: [:new]
prepend_before_action :store_uri, only: [:new, :show]
before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
@@ -246,15 +247,7 @@ class Projects::IssuesController < Projects::ApplicationController
task_num
lock_version
discussion_locked
- ] + [{ label_ids: [], assignee_ids: [] }]
- end
-
- 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
+ ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }]
end
def store_uri
diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb
index babeee48ef3..013e01b82aa 100644
--- a/app/controllers/projects/lfs_storage_controller.rb
+++ b/app/controllers/projects/lfs_storage_controller.rb
@@ -5,7 +5,7 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
include WorkhorseRequest
include SendFileUpload
- skip_before_action :verify_workhorse_api!, only: [:download, :upload_finalize]
+ skip_before_action :verify_workhorse_api!, only: :download
def download
lfs_object = LfsObject.find_by_oid(oid)
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index 368ee89ff5c..6045ee4e171 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -34,13 +34,17 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
:task_num,
:title,
:discussion_locked,
- label_ids: []
+ label_ids: [],
+ update_task: [:index, :checked, :line_number, :line_source]
]
end
def set_pipeline_variables
- @pipelines = @merge_request.all_pipelines
- @pipeline = @merge_request.head_pipeline
- @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0
+ @pipelines =
+ if can?(current_user, :read_pipeline, @project)
+ @merge_request.all_pipelines
+ else
+ Ci::Pipeline.none
+ end
end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index bc0a3d3526d..5cf7fa3422d 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -7,6 +7,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include RendersCommits
include ToggleAwardEmoji
include IssuableCollections
+ include RecordUserLastActivity
skip_before_action :merge_request, only: [:index, :bulk_update]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
@@ -239,7 +240,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def merge_params_attributes
- [:should_remove_source_branch, :commit_message, :squash]
+ [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash]
end
def merge_when_pipeline_succeeds_active?
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index e6d029c356b..6a86f8ca729 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -4,6 +4,7 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :authorize_read_pipeline!
+ before_action :authorize_read_build!, only: [:index]
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 75e590f3f33..f2f63e986bb 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -99,7 +99,9 @@ module Projects
def define_triggers_variables
@triggers = @project.triggers
+ .present(current_user: current_user)
@trigger = ::Ci::Trigger.new
+ .present(current_user: current_user)
end
def define_badges_variables
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index f5fdfb8accc..c7b4ebb2b24 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -66,12 +66,11 @@ class Projects::TriggersController < Projects::ApplicationController
end
def trigger
- @trigger ||= project.triggers.find(params[:id]) || render_404
+ @trigger ||= project.triggers.find(params[:id])
+ .present(current_user: current_user)
end
def trigger_params
- params.require(:trigger).permit(
- :description
- )
+ params.require(:trigger).permit(:description)
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index d3af35723ac..33c6608d321 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -6,6 +6,7 @@ class ProjectsController < Projects::ApplicationController
include ExtractsPath
include PreviewMarkdown
include SendFileUpload
+ include RecordUserLastActivity
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb
index c1ef9dfefa7..f8c7f0c3167 100644
--- a/app/finders/contributed_projects_finder.rb
+++ b/app/finders/contributed_projects_finder.rb
@@ -14,6 +14,9 @@ class ContributedProjectsFinder < UnionFinder
# Returns an ActiveRecord::Relation.
# rubocop: disable CodeReuse/ActiveRecord
def execute(current_user = nil)
+ # Do not show contributed projects if the user profile is private.
+ return Project.none unless can_read_profile?(current_user)
+
segments = all_projects(current_user)
find_union(segments, Project).includes(:namespace).order_id_desc
@@ -22,6 +25,10 @@ class ContributedProjectsFinder < UnionFinder
private
+ def can_read_profile?(current_user)
+ Ability.allowed?(current_user, :read_user_profile, @user)
+ end
+
def all_projects(current_user)
projects = []
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 1a69ec85d18..23af2e0521c 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -18,6 +18,7 @@
# assignee_id: integer or 'None' or 'Any'
# assignee_username: string
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# non_archived: boolean
@@ -56,6 +57,7 @@ class IssuableFinder
milestone_title
my_reaction_emoji
search
+ in
]
end
@@ -303,7 +305,7 @@ class IssuableFinder
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)
+ Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: true)
end
end
@@ -408,7 +410,7 @@ class IssuableFinder
items = klass.with(cte.to_arel).from(klass.table_name)
end
- items.full_search(search)
+ items.full_search(search, matched_columns: params[:in])
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 45e494725d7..a0504ca0879 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -14,6 +14,7 @@
# milestone_title: string
# assignee_id: integer
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# my_reaction_emoji: string
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index e190d5d90c9..b645011a3c5 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -15,6 +15,7 @@
# author_id: integer
# assignee_id: integer
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# non_archived: boolean
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 4ab3c13787a..95e66fb3b7c 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -4,6 +4,10 @@ module Resolvers
class IssuesResolver < BaseResolver
extend ActiveSupport::Concern
+ argument :iids, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The list of IIDs of issues, e.g., [1, 2]'
+
argument :search, GraphQL::STRING_TYPE,
required: false
argument :sort, Types::Sort,
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index fb740b6fb1c..47b915b451e 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -39,7 +39,8 @@ module Types
field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true
field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false
field :diff_head_sha, GraphQL::STRING_TYPE, null: true
- field :merge_commit_message, GraphQL::STRING_TYPE, null: true
+ field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage"
+ field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false
field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index c8e4e2e3df9..e635f608237 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -150,6 +150,7 @@ module ApplicationSettingsHelper
:email_author_in_body,
:enabled_git_access_protocol,
:enforce_terms,
+ :first_day_of_week,
:gitaly_timeout_default,
:gitaly_timeout_medium,
:gitaly_timeout_fast,
@@ -231,7 +232,8 @@ module ApplicationSettingsHelper
:web_ide_clientside_preview_enabled,
:diff_max_patch_bytes,
:commit_email_hostname,
- :protected_ci_variables
+ :protected_ci_variables,
+ :local_markdown_version
]
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 01a0fb34484..2b1d6f49878 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -16,6 +16,13 @@ module AuthHelper
PROVIDERS_WITH_ICONS.include?(name.to_s)
end
+ def qa_class_for_provider(provider)
+ {
+ saml: 'qa-saml-login-button',
+ github: 'qa-github-login-button'
+ }[provider.to_sym]
+ end
+
def auth_providers
Gitlab::Auth::OAuth::Provider.providers
end
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index 516c8a353ea..67e7e475920 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -9,41 +9,4 @@ module AutoDevopsHelper
!project.repository.gitlab_ci_yml &&
!project.ci_service
end
-
- def auto_devops_warning_message(project)
- if missing_auto_devops_service?(project)
- params = {
- kubernetes: link_to('Kubernetes cluster', project_clusters_path(project))
- }
-
- if missing_auto_devops_domain?(project)
- _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params
- else
- _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params
- end
- elsif missing_auto_devops_domain?(project)
- _('Auto Review Apps and Auto Deploy need a domain name to work correctly.')
- end
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def cluster_ingress_ip(project)
- project
- .cluster_ingresses
- .where("external_ip is not null")
- .limit(1)
- .pluck(:external_ip)
- .first
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def missing_auto_devops_domain?(project)
- !(project.auto_devops || project.build_auto_devops)&.has_domain?
- end
-
- def missing_auto_devops_service?(project)
- !project.deployment_platform&.active?
- end
end
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index fa5d3ae474a..dedc58f482b 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -36,6 +36,14 @@ module EmailsHelper
nil
end
+ def sanitize_name(name)
+ if name =~ URI::DEFAULT_PARSER.regexp[:URI_REF]
+ name.tr('.', '_')
+ else
+ name
+ end
+ end
+
def password_reset_token_valid_time
valid_hours = Devise.reset_password_within / 60 / 60
if valid_hours >= 24
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index b3935ae350d..365b94f5a3e 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -11,7 +11,6 @@ module EnvironmentsHelper
{
"endpoint" => folder_project_environments_path(@project, @folder, format: :json),
"folder-name" => @folder,
- "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s
}
end
diff --git a/app/helpers/external_wiki_helper.rb b/app/helpers/external_wiki_helper.rb
deleted file mode 100644
index e36d63b2946..00000000000
--- a/app/helpers/external_wiki_helper.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-module ExternalWikiHelper
- def get_project_wiki_path(project)
- external_wiki_service = project.external_wiki
- if external_wiki_service
- external_wiki_service.properties['external_wiki_url']
- else
- project_wiki_path(project, :home)
- end
- end
-end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index f8176facce9..1a471034972 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -268,7 +268,7 @@ module IssuablesHelper
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
- markdownVersion: issuable.cached_markdown_version,
+ lockVersion: issuable.lock_version,
issuableTemplates: issuable_templates(issuable),
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 0d638b850b4..66f4b7b3f30 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -116,7 +116,6 @@ module MarkupHelper
def markup(file_name, text, context = {})
context[:project] ||= @project
- context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled?
html = context.delete(:rendered) || markup_unsafe(file_name, text, context)
prepare_for_rendering(html, context)
end
@@ -132,7 +131,6 @@ module MarkupHelper
page_slug: wiki_page.slug,
issuable_state_filter_enabled: true
)
- context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled?
html =
case wiki_page.format
@@ -187,10 +185,6 @@ module MarkupHelper
end
end
- def commonmark_for_repositories_enabled?
- Feature.enabled?(:commonmark_for_repositories, default_enabled: true)
- end
-
private
# Return +text+, truncated to +max_chars+ characters, excluding any HTML
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index ab4a1ccc0d1..11d5591d509 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -18,12 +18,13 @@ module MembersHelper
"remove #{member.user.name} from"
end
- "#{text} #{action} the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?"
+ "#{text} #{action} the #{member.source.human_name} #{source_text(member)}?"
end
def remove_member_title(member)
action = member.request? ? 'Deny access request' : 'Remove user'
- "#{action} from #{member.real_source_type.humanize(capitalize: false)}"
+
+ "#{action} from #{source_text(member)}"
end
def leave_confirmation_message(member_source)
@@ -35,4 +36,14 @@ module MembersHelper
options = params.slice(:search, :sort).merge(options).permit!
"#{request.path}?#{options.to_param}"
end
+
+ private
+
+ def source_text(member)
+ type = member.real_source_type.humanize(capitalize: false)
+
+ return type if member.request? || member.invite? || type != 'group'
+
+ 'group and any subresources'
+ end
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 293dd20ad49..aaf38cbfe70 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -171,7 +171,6 @@ module NotesHelper
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'),
- markdownVersion: issuable.cached_markdown_version,
quickActionsDocsPath: help_page_path('user/project/quick_actions'),
closePath: close_issuable_path(issuable),
reopenPath: reopen_issuable_path(issuable),
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index f4f46b0fe96..bc1742e8167 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -43,6 +43,17 @@ module PreferencesHelper
]
end
+ def first_day_of_week_choices
+ [
+ [_('Sunday'), 0],
+ [_('Monday'), 1]
+ ]
+ end
+
+ def first_day_of_week_choices_with_default
+ first_day_of_week_choices.unshift([_('System default (%{default})') % { default: default_first_day_of_week }, nil])
+ end
+
def user_application_theme
@user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class
end
@@ -66,4 +77,8 @@ module PreferencesHelper
def excluded_dashboard_choices
['operations']
end
+
+ def default_first_day_of_week
+ first_day_of_week_choices.rassoc(Gitlab::CurrentSettings.first_day_of_week).first
+ end
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index df318de740a..5a42e581867 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -25,4 +25,8 @@ module ProfilesHelper
end
end
end
+
+ def user_profile?
+ params[:controller] == 'users'
+ end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index eceee054ede..c400302cda3 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -265,10 +265,6 @@ module ProjectsHelper
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
-
def explore_projects_tab?
current_page?(explore_projects_path) ||
current_page?(trending_explore_projects_path) ||
@@ -305,7 +301,8 @@ module ProjectsHelper
nav_tabs << :container_registry
end
- if project.builds_enabled? && can?(current_user, :read_pipeline, project)
+ # Pipelines feature is tied to presence of builds
+ if can?(current_user, :read_build, project)
nav_tabs << :pipelines
end
@@ -313,19 +310,24 @@ module ProjectsHelper
nav_tabs << :operations
end
- if project.external_issue_tracker
- nav_tabs << :external_issue_tracker
- end
-
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
nav_tabs << tab
end
end
+ nav_tabs << external_nav_tabs(project)
+
nav_tabs.flatten
end
+ def external_nav_tabs(project)
+ [].tap do |tabs|
+ tabs << :external_issue_tracker if project.external_issue_tracker
+ tabs << :external_wiki if project.external_wiki
+ end
+ end
+
def tab_ability_map
{
environments: :read_environment,
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 02762897c89..07ec129dea3 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -128,7 +128,9 @@ module SortingHelper
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
+ sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_recently_last_activity => sort_title_recently_last_activity,
+ sort_value_oldest_last_activity => sort_title_oldest_last_activity
}
end
@@ -317,6 +319,14 @@ module SortingHelper
s_('SortOptions|Most stars')
end
+ def sort_title_oldest_last_activity
+ s_('SortOptions|Oldest last activity')
+ end
+
+ def sort_title_recently_last_activity
+ s_('SortOptions|Recent last activity')
+ end
+
# Values.
def sort_value_access_level_asc
'access_level_asc'
@@ -445,4 +455,12 @@ module SortingHelper
def sort_value_most_stars
'stars_desc'
end
+
+ def sort_value_oldest_last_activity
+ 'last_activity_on_asc'
+ end
+
+ def sort_value_recently_last_activity
+ 'last_activity_on_desc'
+ end
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 73c1402eae5..73ca17c6605 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -74,6 +74,15 @@ module UsersHelper
Gitlab.config.gitlab.impersonation_enabled
end
+ def user_badges_in_admin_section(user)
+ [].tap do |badges|
+ badges << { text: s_('AdminUsers|Blocked'), variant: 'danger' } if user.blocked?
+ badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin?
+ badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external?
+ badges << { text: s_("AdminUsers|It's you!"), variant: nil } if current_user == user
+ end
+ end
+
private
def get_profile_tabs
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 29696ab276f..a3d662d8250 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -6,4 +6,18 @@ class ApplicationRecord < ActiveRecord::Base
def self.id_in(ids)
where(id: ids)
end
+
+ def self.safe_find_or_create_by!(*args)
+ safe_find_or_create_by(*args).tap do |record|
+ record.validate! unless record.persisted?
+ end
+ end
+
+ def self.safe_find_or_create_by(*args)
+ transaction(requires_new: true) do
+ find_or_create_by(*args)
+ end
+ rescue ActiveRecord::RecordNotUnique
+ retry
+ end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 88746375c67..daadf9427ba 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -193,6 +193,10 @@ class ApplicationSetting < ActiveRecord::Base
allow_nil: true,
numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds }
+ validates :local_markdown_version,
+ allow_nil: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 65536 }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -246,6 +250,7 @@ class ApplicationSetting < ActiveRecord::Base
dsa_key_restriction: 0,
ecdsa_key_restriction: 0,
ed25519_key_restriction: 0,
+ first_day_of_week: 0,
gitaly_timeout_default: 55,
gitaly_timeout_fast: 10,
gitaly_timeout_medium: 30,
@@ -303,7 +308,8 @@ class ApplicationSetting < ActiveRecord::Base
usage_stats_set_by_user_id: nil,
diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
commit_email_hostname: default_commit_email_hostname,
- protected_ci_variables: false
+ protected_ci_variables: false,
+ local_markdown_version: 0
}
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 84010e40ef4..6b2b7e77180 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -48,13 +48,23 @@ module Ci
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
##
- # The "environment" field for builds is a String, and is the unexpanded name!
+ # Since Gitlab 11.5, deployments records started being created right after
+ # `ci_builds` creation. We can look up a relevant `environment` through
+ # `deployment` relation today. This is much more efficient than expanding
+ # environment name with variables.
+ # (See more https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22380)
#
+ # However, we have to still expand environment name if it's a stop action,
+ # because `deployment` persists information for start action only.
+ #
+ # We will follow up this by persisting expanded name in build metadata or
+ # persisting stop action in database.
def persisted_environment
return unless has_environment?
strong_memoize(:persisted_environment) do
- Environment.find_by(name: expanded_environment_name, project: project)
+ deployment&.environment ||
+ Environment.find_by(name: expanded_environment_name, project: project)
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 55db42162ca..637148c4ce4 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -4,6 +4,7 @@ module Ci
class Trigger < ActiveRecord::Base
extend Gitlab::Ci::Model
include IgnorableColumn
+ include Presentable
ignore_column :deleted_at
@@ -29,7 +30,7 @@ module Ci
end
def short_token
- token[0...4]
+ token[0...4] if token.present?
end
def legacy?
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 26bf73f4dd8..52c440ffb2f 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -53,11 +53,11 @@ module Clusters
end
def upgrade_command(values)
- ::Gitlab::Kubernetes::Helm::UpgradeCommand.new(
- name,
+ ::Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name: name,
version: VERSION,
- chart: chart,
rbac: cluster.platform_kubernetes_rbac?,
+ chart: chart,
files: files_with_replaced_values(values)
)
end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index a2c48973fa5..7025fc2cc02 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -18,6 +18,7 @@ module Clusters
Applications::Knative.application_name => Applications::Knative
}.freeze
DEFAULT_ENVIRONMENT = '*'.freeze
+ KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'.freeze
belongs_to :user
@@ -49,7 +50,7 @@ module Clusters
validates :name, cluster_name: true
validates :cluster_type, presence: true
- validates :domain, allow_nil: true, hostname: { allow_numeric_hostname: true, require_valid_tld: true }
+ validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true, require_valid_tld: true }
validate :restrict_modification, on: :update
validate :no_groups, unless: :group_type?
@@ -65,6 +66,9 @@ module Clusters
delegate :available?, to: :application_ingress, prefix: true, allow_nil: true
delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true
delegate :available?, to: :application_knative, prefix: true, allow_nil: true
+ delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true
+
+ alias_attribute :base_domain, :domain
enum cluster_type: {
instance_type: 1,
@@ -193,8 +197,42 @@ module Clusters
project_type?
end
+ def kube_ingress_domain
+ @kube_ingress_domain ||= domain.presence || instance_domain || legacy_auto_devops_domain
+ end
+
+ def predefined_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless kube_ingress_domain
+
+ variables.append(key: KUBE_INGRESS_BASE_DOMAIN, value: kube_ingress_domain)
+ end
+ end
+
private
+ def instance_domain
+ @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain
+ end
+
+ # To keep backward compatibility with AUTO_DEVOPS_DOMAIN
+ # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN
+ # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options:
+ # ProjectAutoDevops#Domain, project variables or group variables,
+ # as the AUTO_DEVOPS_DOMAIN is needed for CI_ENVIRONMENT_URL
+ #
+ # This method should is scheduled to be removed on
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/56959
+ def legacy_auto_devops_domain
+ if project_type?
+ project&.auto_devops&.domain.presence ||
+ project.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence ||
+ project.group&.variables&.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence
+ elsif group_type?
+ group.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence
+ end
+ end
+
def restrict_modification
if provider&.on_creation?
errors.add(:base, "cannot modify during creation")
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index a556dd5ad8b..5c0164831bc 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -20,7 +20,7 @@ module Clusters
state :update_errored, value: 6
event :make_scheduled do
- transition [:installable, :errored] => :scheduled
+ transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled
end
event :make_installing do
@@ -29,18 +29,16 @@ module Clusters
event :make_installed do
transition [:installing] => :installed
+ transition [:updating] => :updated
end
event :make_errored do
- transition any => :errored
+ transition any - [:updating] => :errored
+ transition [:updating] => :update_errored
end
event :make_updating do
- transition [:installed, :updated, :update_errored] => :updating
- end
-
- event :make_updated do
- transition [:updating] => :updated
+ transition [:installed, :updated, :update_errored, :scheduled] => :updating
end
event :make_update_errored do
@@ -74,6 +72,10 @@ module Clusters
end
end
+ def updateable?
+ installed? || updated? || update_errored?
+ end
+
def available?
installed? || updated?
end
diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb
index ccad74dc35a..db94e8e08c9 100644
--- a/app/models/clusters/concerns/application_version.rb
+++ b/app/models/clusters/concerns/application_version.rb
@@ -7,11 +7,15 @@ module Clusters
included do
state_machine :status do
- after_transition any => [:installing] do |application|
- application.update(version: application.class.const_get(:VERSION))
+ before_transition any => [:installed, :updated] do |application|
+ application.version = application.class.const_get(:VERSION)
end
end
end
+
+ def update_available?
+ version != self.class.const_get(:VERSION)
+ end
end
end
end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 8f3424db295..c8969351ed9 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -98,6 +98,8 @@ module Clusters
.append(key: 'KUBE_NAMESPACE', value: actual_namespace)
.append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
end
+
+ variables.concat(cluster.predefined_variables)
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 01f4c58daa1..f412d252e5c 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -11,6 +11,7 @@ class Commit
include Mentionable
include Referable
include StaticModel
+ include Presentable
include ::Gitlab::Utils::StrongMemoize
attr_mentionable :safe_message, pipeline: :single_line
@@ -304,7 +305,9 @@ class Commit
end
def last_pipeline
- @last_pipeline ||= pipelines.last
+ strong_memoize(:last_pipeline) do
+ pipelines.last
+ end
end
def status(ref = nil)
@@ -376,7 +379,7 @@ class Commit
end
def merge_commit?
- parents.size > 1
+ parent_ids.size > 1
end
def merged_merge_request(current_user)
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index 885f61beb05..42ec5b5e664 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -3,6 +3,7 @@
# A collection of Commit instances for a specific project and Git reference.
class CommitCollection
include Enumerable
+ include Gitlab::Utils::StrongMemoize
attr_reader :project, :ref, :commits
@@ -20,11 +21,17 @@ class CommitCollection
end
def committers
- emails = commits.reject(&:merge_commit?).map(&:committer_email).uniq
+ emails = without_merge_commits.map(&:committer_email).uniq
User.by_any_email(emails)
end
+ def without_merge_commits
+ strong_memoize(:without_merge_commits) do
+ commits.reject(&:merge_commit?)
+ end
+ end
+
# Sets the pipeline status for every commit.
#
# Setting this status ahead of time removes the need for running a query for
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 73a27326f6c..1a8570b80c3 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -13,9 +13,8 @@ module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
- CACHE_REDCARPET_VERSION = 3
CACHE_COMMONMARK_VERSION_START = 10
- CACHE_COMMONMARK_VERSION = 13
+ CACHE_COMMONMARK_VERSION = 14
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
@@ -42,18 +41,6 @@ module CacheMarkdownField
end
end
- class MarkdownEngine
- def self.from_version(version = nil)
- return :common_mark if version.nil? || version == 0
-
- if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
- :redcarpet
- else
- :common_mark
- end
- end
- end
-
def skip_project_check?
false
end
@@ -71,7 +58,7 @@ module CacheMarkdownField
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
- context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version)
+ context[:markdown_engine] = :common_mark
context
end
@@ -128,12 +115,27 @@ module CacheMarkdownField
end
def latest_cached_markdown_version
- return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version
+ @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version
+ end
- if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
- CacheMarkdownField::CACHE_REDCARPET_VERSION
+ def local_version
+ # because local_markdown_version is stored in application_settings which
+ # uses cached_markdown_version too, we check explicitly to avoid
+ # endless loop
+ return local_markdown_version if has_attribute?(:local_markdown_version)
+
+ settings = Gitlab::CurrentSettings.current_application_settings
+
+ # Following migrations are not properly isolated and
+ # use real models (by calling .ghost method), in these migrations
+ # local_markdown_version attribute doesn't exist yet, so we
+ # use a default value:
+ # db/migrate/20170825104051_migrate_issues_to_ghost_user.rb
+ # db/migrate/20171114150259_merge_requests_author_id_foreign_key.rb
+ if settings.respond_to?(:local_markdown_version)
+ settings.local_markdown_version
else
- CacheMarkdownField::CACHE_COMMONMARK_VERSION
+ 0
end
end
@@ -178,7 +180,9 @@ module CacheMarkdownField
# author and project invalidate the cache in all circumstances.
define_method(invalidation_method) do
changed_fields = changed_attributes.keys
- invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
+ invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
+ invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
+
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 0d363ec68b7..0a77fbeba08 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -136,10 +136,18 @@ module Issuable
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
+ # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
#
# Returns an ActiveRecord::Relation.
- def full_search(query)
- fuzzy_search(query, [:title, :description])
+ def full_search(query, matched_columns: 'title,description')
+ allowed_columns = [:title, :description]
+ matched_columns = matched_columns.to_s.split(',').map(&:to_sym)
+ matched_columns &= allowed_columns
+
+ # Matching title or description if the matched_columns did not contain any allowed columns.
+ matched_columns = [:title, :description] if matched_columns.empty?
+
+ fuzzy_search(query, matched_columns)
end
def sort_by_attribute(method, excluded_labels: [])
@@ -270,26 +278,29 @@ module Issuable
def to_hook_data(user, old_associations: {})
changes = previous_changes
- old_labels = old_associations.fetch(:labels, [])
- old_assignees = old_associations.fetch(:assignees, [])
- if old_labels != labels
- changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
- end
+ if old_associations
+ old_labels = old_associations.fetch(:labels, [])
+ old_assignees = old_associations.fetch(:assignees, [])
- if old_assignees != assignees
- if self.is_a?(Issue)
- changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
- else
- changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
+ if old_labels != labels
+ changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
end
- end
- if self.respond_to?(:total_time_spent)
- old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+ if old_assignees != assignees
+ if self.is_a?(Issue)
+ changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
+ else
+ changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
+ end
+ end
- if old_total_time_spent != total_time_spent
- changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ if self.respond_to?(:total_time_spent)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+
+ if old_total_time_spent != total_time_spent
+ changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ end
end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 29476654bf7..3c74034b527 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -1,9 +1,18 @@
# frozen_string_literal: true
module Noteable
- # Names of all implementers of `Noteable` that support resolvable notes.
+ extend ActiveSupport::Concern
+
+ # `Noteable` class names that support resolvable notes.
RESOLVABLE_TYPES = %w(MergeRequest).freeze
+ class_methods do
+ # `Noteable` class names that support replying to individual notes.
+ def replyable_types
+ %w(Issue MergeRequest)
+ end
+ end
+
def base_class_name
self.class.base_class.name
end
@@ -26,6 +35,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
+ def supports_replying_to_individual_notes?
+ supports_discussions? && self.class.replyable_types.include?(base_class_name)
+ end
+
def supports_suggestion?
false
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 603d4d62578..f147ce8ad6b 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -9,9 +9,11 @@ require 'task_list/filter'
#
# Used by MergeRequest and Issue
module Taskable
- COMPLETED = 'completed'.freeze
- INCOMPLETE = 'incomplete'.freeze
- ITEM_PATTERN = %r{
+ COMPLETED = 'completed'.freeze
+ INCOMPLETE = 'incomplete'.freeze
+ COMPLETE_PATTERN = /(\[[xX]\])/.freeze
+ INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze
+ ITEM_PATTERN = %r{
^
\s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list
\s+ # whitespace prefix has to be always presented for a list item
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index dbc7b6e67be..f2678e0597d 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -17,6 +17,8 @@ class Discussion
:for_commit?,
:for_merge_request?,
+ :save,
+
to: :first_note
def project_id
@@ -116,6 +118,10 @@ class Discussion
false
end
+ def can_convert_to_discussion?
+ false
+ end
+
def new_discussion?
notes.length == 1
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index cdfe3b7c023..1fc088b12ae 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -50,6 +50,14 @@ class Environment < ActiveRecord::Base
end
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
+
+ ##
+ # Search environments which have names like the given query.
+ # Do not set a large limit unless you've confirmed that it works on gitlab.com scale.
+ scope :for_name_like, -> (query, limit: 5) do
+ where('name LIKE ?', "#{sanitize_sql_like(query)}%").limit(limit)
+ end
+
scope :for_project, -> (project) { where(project_id: project) }
scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
@@ -70,6 +78,10 @@ class Environment < ActiveRecord::Base
end
end
+ def self.pluck_names
+ pluck(:name)
+ end
+
def predefined_variables
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_ENVIRONMENT_NAME', value: name)
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 7f4947ba27a..31084c54bdc 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -8,9 +8,13 @@ module ErrorTracking
belongs_to :project
- validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true }
+ validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
- validate :validate_api_url_path
+ validates :api_url, presence: true, if: :enabled
+
+ validate :validate_api_url_path, if: :enabled
+
+ validates :token, presence: true, if: :enabled
attr_encrypted :token,
mode: :per_attribute_iv,
@@ -19,6 +23,31 @@ module ErrorTracking
after_save :clear_reactive_cache!
+ def project_name
+ super || project_name_from_slug
+ end
+
+ def organization_name
+ super || organization_name_from_slug
+ end
+
+ def project_slug
+ project_slug_from_api_url
+ end
+
+ def organization_slug
+ organization_slug_from_api_url
+ end
+
+ def self.build_api_url_from(api_host:, project_slug:, organization_slug:)
+ uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/")
+ uri.path = uri.path.squeeze('/')
+
+ uri.to_s
+ rescue Addressable::URI::InvalidURIError
+ api_host
+ end
+
def sentry_client
Sentry::Client.new(api_url, token)
end
@@ -33,6 +62,10 @@ module ErrorTracking
end
end
+ def list_sentry_projects
+ { projects: sentry_client.list_projects }
+ end
+
def calculate_reactive_cache(request, opts)
case request
when 'list_issues'
@@ -47,13 +80,53 @@ module ErrorTracking
url.sub('api/0/projects/', '')
end
+ def api_host
+ return if api_url.blank?
+
+ # This returns http://example.com/
+ Addressable::URI.join(api_url, '/').to_s
+ end
+
private
+ def project_name_from_slug
+ @project_name_from_slug ||= project_slug_from_api_url&.titleize
+ end
+
+ def organization_name_from_slug
+ @organization_name_from_slug ||= organization_slug_from_api_url&.titleize
+ end
+
+ def project_slug_from_api_url
+ extract_slug(:project)
+ end
+
+ def organization_slug_from_api_url
+ extract_slug(:organization)
+ end
+
+ def extract_slug(capture)
+ return if api_url.blank?
+
+ begin
+ url = Addressable::URI.parse(api_url)
+ rescue Addressable::URI::InvalidURIError
+ return nil
+ end
+
+ @slug_match ||= url.path.match(%r{^/api/0/projects/+(?<organization>[^/]+)/+(?<project>[^/|$]+)}) || {}
+ @slug_match[capture]
+ end
+
def validate_api_url_path
- unless URI(api_url).path.starts_with?('/api/0/projects')
- errors.add(:api_url, 'path needs to start with /api/0/projects')
+ return if api_url.blank?
+
+ begin
+ unless Addressable::URI.parse(api_url).path.starts_with?('/api/0/projects')
+ errors.add(:api_url, 'path needs to start with /api/0/projects')
+ end
+ rescue Addressable::URI::InvalidURIError
end
- rescue URI::InvalidURIError
end
end
end
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
index 0816778deae..7f9ff7bbda6 100644
--- a/app/models/gpg_signature.rb
+++ b/app/models/gpg_signature.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GpgSignature < ActiveRecord::Base
+class GpgSignature < ApplicationRecord
include ShaAttribute
sha_attribute :commit_sha
@@ -33,6 +33,11 @@ class GpgSignature < ActiveRecord::Base
)
end
+ def self.safe_create!(attributes)
+ create_with(attributes)
+ .safe_find_or_create_by!(commit_sha: attributes[:commit_sha])
+ end
+
def gpg_key=(model)
case model
when GpgKey
diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb
index 07ee7470ea2..aab0ff93468 100644
--- a/app/models/individual_note_discussion.rb
+++ b/app/models/individual_note_discussion.rb
@@ -13,6 +13,14 @@ class IndividualNoteDiscussion < Discussion
true
end
+ def can_convert_to_discussion?
+ noteable.supports_replying_to_individual_notes? && Feature.enabled?(:reply_to_individual_notes)
+ end
+
+ def convert_to_discussion!
+ first_note.becomes!(Discussion.note_class).to_discussion
+ end
+
def reply_attributes
super.tap { |attrs| attrs.delete(:discussion_id) }
end
diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb
new file mode 100644
index 00000000000..6383f95d546
--- /dev/null
+++ b/app/models/lfs_download_object.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class LfsDownloadObject
+ include ActiveModel::Validations
+
+ attr_accessor :oid, :size, :link
+ delegate :sanitized_url, :credentials, to: :sanitized_uri
+
+ validates :oid, format: { with: /\A\h{64}\z/ }
+ validates :size, numericality: { greater_than_or_equal_to: 0 }
+ validates :link, public_url: { protocols: %w(http https) }
+
+ def initialize(oid:, size:, link:)
+ @oid = oid
+ @size = size
+ @link = link
+ end
+
+ def sanitized_uri
+ @sanitized_uri ||= Gitlab::UrlSanitizer.new(link)
+ end
+end
diff --git a/app/models/member.rb b/app/models/member.rb
index b0f049438eb..8e071a8ff21 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -78,12 +78,15 @@ class Member < ActiveRecord::Base
scope :owners, -> { active.where(access_level: OWNER) }
scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated
+ scope :with_user, -> (user) { where(user: user) }
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }
+ scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
+
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
after_create :send_invite, if: :invite?, unless: :importing?
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index fc49ee7ac8c..2c9e1ba1d80 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -12,6 +12,8 @@ class GroupMember < Member
validates :source_type, format: { with: /\ANamespace\z/ }
default_scope { where(source_type: SOURCE_TYPE) }
+ scope :in_groups, ->(groups) { where(source_id: groups.select(:id)) }
+
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 016c18ce6c8..5372c6084f4 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -12,6 +12,10 @@ class ProjectMember < Member
default_scope { where(source_type: SOURCE_TYPE) }
scope :in_project, ->(project) { where(source_id: project.id) }
+ scope :in_namespaces, ->(groups) do
+ joins('INNER JOIN projects ON projects.id = members.source_id')
+ .where('projects.namespace_id in (?)', groups.select(:id))
+ end
class << self
# Add users to projects with passed access option
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 84cb8e1c50b..2035bffd829 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -939,7 +939,7 @@ class MergeRequest < ActiveRecord::Base
self.target_project.repository.branch_exists?(self.target_branch)
end
- def merge_commit_message(include_description: false)
+ def default_merge_commit_message(include_description: false)
closes_issues_references = visible_closing_issues_for.map do |issue|
issue.to_reference(target_project)
end
@@ -959,6 +959,13 @@ class MergeRequest < ActiveRecord::Base
message.join("\n\n")
end
+ # Returns the oldest multi-line commit message, or the MR title if none found
+ def default_squash_commit_message
+ strong_memoize(:default_squash_commit_message) do
+ commits.without_merge_commits.reverse.find(&:description?)&.safe_message || title
+ end
+ end
+
def reset_merge_when_pipeline_succeeds
return unless merge_when_pipeline_succeeds?
@@ -967,6 +974,7 @@ class MergeRequest < ActiveRecord::Base
if merge_params
merge_params.delete('should_remove_source_branch')
merge_params.delete('commit_message')
+ merge_params.delete('squash_commit_message')
end
self.save
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index a3029a54604..712347e76ed 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -7,6 +7,7 @@ class MergeRequestDiff < ActiveRecord::Base
include IgnorableColumn
include EachBatch
include Gitlab::Utils::StrongMemoize
+ include ObjectStorage::BackgroundMove
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
@@ -15,9 +16,13 @@ class MergeRequestDiff < ActiveRecord::Base
:st_diffs
belongs_to :merge_request
+
manual_inverse_association :merge_request, :merge_request_diff
- has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
+ has_many :merge_request_diff_files,
+ -> { order(:merge_request_diff_id, :relative_order) },
+ inverse_of: :merge_request_diff
+
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
state_machine :state, initial: :empty do
@@ -45,10 +50,14 @@ class MergeRequestDiff < ActiveRecord::Base
scope :recent, -> { order(id: :desc).limit(100) }
+ mount_uploader :external_diff, ExternalDiffUploader
+
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
+ after_save :update_external_diff_store, if: :external_diff_changed?
+
def self.find_by_diff_refs(diff_refs)
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
end
@@ -241,10 +250,97 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
+ # Carrierwave defines `write_uploader` dynamically on this class, so `super`
+ # does not work. Alias the carrierwave method so we can call it when needed
+ alias_method :carrierwave_write_uploader, :write_uploader
+
+ # The `external_diff`, `external_diff_store`, and `stored_externally`
+ # columns were introduced in GitLab 11.8, but some background migration specs
+ # use factories that rely on current code with an old schema. Without these
+ # `has_attribute?` guards, they fail with a `MissingAttributeError`.
+ #
+ # For more details, see: https://gitlab.com/gitlab-org/gitlab-ce/issues/44990
+
+ def write_uploader(column, identifier)
+ carrierwave_write_uploader(column, identifier) if has_attribute?(column)
+ end
+
+ def update_external_diff_store
+ update_column(:external_diff_store, external_diff.object_store) if
+ has_attribute?(:external_diff_store)
+ end
+
+ def external_diff_changed?
+ super if has_attribute?(:external_diff)
+ end
+
+ def stored_externally
+ super if has_attribute?(:stored_externally)
+ end
+ alias_method :stored_externally?, :stored_externally
+
+ # If enabled, yields the external file containing the diff. Otherwise, yields
+ # nil. This method is not thread-safe, but it *is* re-entrant, which allows
+ # multiple merge_request_diff_files to load their data efficiently
+ def opening_external_diff
+ return yield(nil) unless stored_externally?
+ return yield(@external_diff_file) if @external_diff_file
+
+ external_diff.open do |file|
+ begin
+ @external_diff_file = file
+
+ yield(@external_diff_file)
+ ensure
+ @external_diff_file = nil
+ end
+ end
+ end
+
private
def create_merge_request_diff_files(diffs)
- rows = diffs.map.with_index do |diff, index|
+ rows =
+ if has_attribute?(:external_diff) && Gitlab.config.external_diffs.enabled
+ build_external_merge_request_diff_files(diffs)
+ else
+ build_merge_request_diff_files(diffs)
+ end
+
+ # Faster inserts
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ end
+
+ def build_external_merge_request_diff_files(diffs)
+ rows = build_merge_request_diff_files(diffs)
+ tempfile = build_external_diff_tempfile(rows)
+
+ self.external_diff = tempfile
+ self.stored_externally = true
+
+ rows
+ ensure
+ tempfile&.unlink
+ end
+
+ def build_external_diff_tempfile(rows)
+ Tempfile.open(external_diff.filename) do |file|
+ rows.inject(0) do |offset, row|
+ data = row.delete(:diff)
+ row[:external_diff_offset] = offset
+ row[:external_diff_size] = data.size
+
+ file.write(data)
+
+ offset + data.size
+ end
+
+ file
+ end
+ end
+
+ def build_merge_request_diff_files(diffs)
+ diffs.map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
binary: false,
merge_request_diff_id: self.id,
@@ -261,18 +357,20 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
end
-
- Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
end
def load_diffs(options)
- collection = merge_request_diff_files
+ # Ensure all diff files operate on the same external diff file instance if
+ # present. This reduces file open/close overhead.
+ opening_external_diff do
+ collection = merge_request_diff_files
- if paths = options[:paths]
- collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
- end
+ if paths = options[:paths]
+ collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
+ end
- Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ end
end
def load_commits
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index a9f110bec5c..e8d936e265c 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -4,7 +4,7 @@ class MergeRequestDiffFile < ActiveRecord::Base
include Gitlab::EncodingHelper
include DiffFile
- belongs_to :merge_request_diff
+ belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files
def utf8_diff
return '' if diff.blank?
@@ -13,6 +13,16 @@ class MergeRequestDiffFile < ActiveRecord::Base
end
def diff
- binary? ? super.unpack('m0').first : super
+ content =
+ if merge_request_diff&.stored_externally?
+ merge_request_diff.opening_external_diff do |file|
+ file.seek(external_diff_offset)
+ file.read(external_diff_size)
+ end
+ else
+ super
+ end
+
+ binary? ? content.unpack('m0').first : content
end
end
diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb
index 34220c1b450..4635fc72dc7 100644
--- a/app/models/pool_repository.rb
+++ b/app/models/pool_repository.rb
@@ -96,7 +96,9 @@ class PoolRepository < ActiveRecord::Base
@object_pool ||= Gitlab::Git::ObjectPool.new(
shard.name,
disk_path + '.git',
- source_project.repository.raw)
+ source_project.repository.raw,
+ source_project.full_path
+ )
end
def inspect
diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb
index 0e667dac21e..5f0f313b7f9 100644
--- a/app/models/programming_language.rb
+++ b/app/models/programming_language.rb
@@ -3,4 +3,10 @@
class ProgrammingLanguage < ActiveRecord::Base
validates :name, presence: true
validates :color, allow_blank: false, color: true
+
+ # Returns all programming languages which match the given name (case
+ # insensitively).
+ scope :with_name_case_insensitive, ->(name) do
+ where(arel_table[:name].matches(sanitize_sql_like(name)))
+ end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index da77479fe1f..8f746f6e094 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -377,8 +377,10 @@ class Project < ActiveRecord::Base
# "enabled" here means "not disabled". It includes private features!
scope :with_feature_enabled, ->(feature) {
- access_level_attribute = ProjectFeature.access_level_attribute(feature)
- with_project_feature.where(project_features: { access_level_attribute => [nil, ProjectFeature::PRIVATE, ProjectFeature::ENABLED, ProjectFeature::PUBLIC] })
+ access_level_attribute = ProjectFeature.arel_table[ProjectFeature.access_level_attribute(feature)]
+ enabled_feature = access_level_attribute.gt(ProjectFeature::DISABLED).or(access_level_attribute.eq(nil))
+
+ with_project_feature.where(enabled_feature)
}
# Picks a feature where the level is exactly that given.
@@ -387,6 +389,16 @@ class Project < ActiveRecord::Base
with_project_feature.where(project_features: { access_level_attribute => level })
}
+ # Picks projects which use the given programming language
+ scope :with_programming_language, ->(language_name) do
+ lang_id_query = ProgrammingLanguage
+ .with_name_case_insensitive(language_name)
+ .select(:id)
+
+ joins(:repository_languages)
+ .where(repository_languages: { programming_language_id: lang_id_query })
+ end
+
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
@@ -465,7 +477,8 @@ class Project < ActiveRecord::Base
# logged in users to more efficiently get private projects with the given
# feature.
def self.with_feature_available_for_user(feature, user)
- visible = [nil, ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
+ visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
+ min_access_level = ProjectFeature.required_minimum_access_level(feature)
if user&.admin?
with_feature_enabled(feature)
@@ -473,10 +486,15 @@ class Project < ActiveRecord::Base
column = ProjectFeature.quoted_access_level_column(feature)
with_project_feature
- .where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))",
- visible,
- ProjectFeature::PRIVATE,
- user.authorizations_for_projects)
+ .where(
+ "(projects.visibility_level > :private AND (#{column} IS NULL OR #{column} >= (:public_visible) OR (#{column} = :private_visible AND EXISTS(:authorizations))))"\
+ " OR (projects.visibility_level = :private AND (#{column} IS NULL OR #{column} >= :private_visible) AND EXISTS(:authorizations))",
+ {
+ private: Gitlab::VisibilityLevel::PRIVATE,
+ public_visible: ProjectFeature::ENABLED,
+ private_visible: ProjectFeature::PRIVATE,
+ authorizations: user.authorizations_for_projects(min_access_level: min_access_level)
+ })
else
with_feature_access_level(feature, visible)
end
@@ -530,6 +548,7 @@ class Project < ActiveRecord::Base
def reference_pattern
%r{
+ (?<!#{Gitlab::PathRegex::PATH_START_CHAR})
((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)?
(?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX})
}x
@@ -569,6 +588,14 @@ class Project < ActiveRecord::Base
end
end
+ def all_pipelines
+ if builds_enabled?
+ super
+ else
+ super.external
+ end
+ end
+
# 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, hierarchy_order: nil)
@@ -721,11 +748,13 @@ class Project < ActiveRecord::Base
end
def import_url=(value)
- return super(value) unless Gitlab::UrlSanitizer.valid?(value)
-
- import_url = Gitlab::UrlSanitizer.new(value)
- super(import_url.sanitized_url)
- create_or_update_import_data(credentials: import_url.credentials)
+ if Gitlab::UrlSanitizer.valid?(value)
+ import_url = Gitlab::UrlSanitizer.new(value)
+ super(import_url.sanitized_url)
+ create_or_update_import_data(credentials: import_url.credentials)
+ else
+ super(value)
+ end
end
def import_url
@@ -1049,7 +1078,7 @@ class Project < ActiveRecord::Base
# rubocop: disable CodeReuse/ServiceClass
def create_labels
Label.templates.each do |label|
- params = label.attributes.except('id', 'template', 'created_at', 'updated_at')
+ params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type')
Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true)
end
end
@@ -1259,7 +1288,7 @@ class Project < ActiveRecord::Base
# Forked import is handled asynchronously
return if forked? && !force
- if gitlab_shell.create_repository(repository_storage, disk_path)
+ if gitlab_shell.create_project_repository(self)
repository.after_create
true
else
diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb
index 2253ad7b543..e353a6443c4 100644
--- a/app/models/project_auto_devops.rb
+++ b/app/models/project_auto_devops.rb
@@ -24,6 +24,12 @@ class ProjectAutoDevops < ActiveRecord::Base
domain.present? || instance_domain.present?
end
+ # From 11.8, AUTO_DEVOPS_DOMAIN has been replaced by KUBE_INGRESS_BASE_DOMAIN.
+ # See Clusters::Cluster#predefined_variables and https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580
+ # for more info.
+ #
+ # Suppport AUTO_DEVOPS_DOMAIN is scheduled to be removed on
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/52363
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
if has_domain?
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 39f2b8fe0de..f700090a493 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -23,11 +23,11 @@ class ProjectFeature < ActiveRecord::Base
PUBLIC = 30
FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze
+ PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
class << self
def access_level_attribute(feature)
- feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name)
- raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature)
+ feature = ensure_feature!(feature)
"#{feature}_access_level".to_sym
end
@@ -38,6 +38,21 @@ class ProjectFeature < ActiveRecord::Base
"#{table}.#{attribute}"
end
+
+ def required_minimum_access_level(feature)
+ feature = ensure_feature!(feature)
+
+ PRIVATE_FEATURES_MIN_ACCESS_LEVEL.fetch(feature, Gitlab::Access::GUEST)
+ end
+
+ private
+
+ def ensure_feature!(feature)
+ feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name)
+ raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature)
+
+ feature
+ end
end
# Default scopes force us to unscope here since a service may need to check
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 33bc6a561f9..aeba2843e5d 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -74,6 +74,14 @@ class ProjectTeam
end
alias_method :users, :members
+ # `members` method uses project_authorizations table which
+ # is updated asynchronously, on project move it still contains
+ # old members who may not have access to the new location,
+ # so we filter out only members of project or project's group
+ def members_in_project_and_ancestors
+ members.where(id: member_user_ids)
+ end
+
def guests
@guests ||= fetch_members(Gitlab::Access::GUEST)
end
@@ -191,4 +199,8 @@ class ProjectTeam
def group
project.group
end
+
+ def member_user_ids
+ Member.on_project_and_ancestors(project).select(:user_id)
+ end
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 559e4f99294..c43bd45a62f 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -60,7 +60,7 @@ class ProjectWiki
def wiki
@wiki ||= begin
gl_repository = Gitlab::GlRepository.gl_repository(project, true)
- raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository)
+ raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path)
create_repo!(raw_repository) unless raw_repository.exists?
@@ -175,7 +175,7 @@ class ProjectWiki
private
def create_repo!(raw_repository)
- gitlab_shell.create_repository(project.repository_storage, disk_path)
+ gitlab_shell.create_wiki_repository(project)
raise CouldNotCreateWikiError unless raw_repository.exists?
diff --git a/app/models/repository.rb b/app/models/repository.rb
index b47238b52f1..7c50b4488e5 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -525,6 +525,8 @@ class Repository
# items is an Array like: [[oid, path], [oid1, path1]]
def blobs_at(items)
+ return [] unless exists?
+
raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) }
end
@@ -612,7 +614,6 @@ class Repository
return unless readme
context = { project: project }
- context[:markdown_engine] = :redcarpet unless MarkupHelper.commonmark_for_repositories_enabled?
MarkupHelper.markup_unsafe(readme.name, readme.data, context)
end
@@ -1029,12 +1030,12 @@ class Repository
remote_branch: merge_request.target_branch)
end
- def squash(user, merge_request)
+ def squash(user, merge_request, message)
raw.squash(user, merge_request.id, branch: merge_request.target_branch,
start_sha: merge_request.diff_start_sha,
end_sha: merge_request.diff_head_sha,
author: merge_request.author,
- message: merge_request.title)
+ message: message)
end
def update_submodule(user, submodule, commit_sha, message:, branch:)
@@ -1103,6 +1104,9 @@ class Repository
end
def initialize_raw_repository
- Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki))
+ Gitlab::Git::Repository.new(project.repository_storage,
+ disk_path + '.git',
+ Gitlab::GlRepository.gl_repository(project, is_wiki),
+ project.full_path)
end
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index e65b3df0fb6..6caab24143b 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -48,7 +48,7 @@ class SentNotification < ActiveRecord::Base
end
def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
- attrs[:in_reply_to_discussion_id] = note.discussion_id
+ attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion?
record(note.noteable, recipient_id, reply_key, attrs)
end
@@ -99,29 +99,12 @@ class SentNotification < ActiveRecord::Base
private
def reply_params
- attrs = {
+ {
noteable_type: self.noteable_type,
noteable_id: self.noteable_id,
- commit_id: self.commit_id
+ commit_id: self.commit_id,
+ in_reply_to_discussion_id: self.in_reply_to_discussion_id
}
-
- if self.in_reply_to_discussion_id.present?
- attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id
- else
- # Remove in GitLab 10.0, when we will not support replying to SentNotifications
- # that don't have `in_reply_to_discussion_id` anymore.
- attrs.merge!(
- type: self.note_type,
-
- # LegacyDiffNote
- line_code: self.line_code,
-
- # DiffNote
- position: self.position.to_json
- )
- end
-
- attrs
end
def note_valid
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index 99a0c54a26a..fd23cc9ac87 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -26,6 +26,7 @@ class SshHostKey
self.reactive_cache_lifetime = 10.minutes
def self.find_by(opts = {})
+ opts = HashWithIndifferentAccess.new(opts)
return nil unless opts.key?(:id)
project_id, url = opts[:id].split(':', 2)
@@ -54,7 +55,7 @@ class SshHostKey
# Needed for reactive caching
def self.primary_key
- 'id'
+ :id
end
def id
diff --git a/app/models/user.rb b/app/models/user.rb
index f8ac230852f..24101eda0b1 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -228,6 +228,9 @@ class User < ApplicationRecord
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :notes_filter_for, to: :user_preference
delegate :set_notes_filter, to: :user_preference
+ delegate :first_day_of_week, :first_day_of_week=, to: :user_preference
+
+ accepts_nested_attributes_for :user_preference, update_only: true
state_machine :state, initial: :active do
event :block do
@@ -267,6 +270,8 @@ class User < ApplicationRecord
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
+ scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
+ scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
@@ -337,6 +342,8 @@ class User < ApplicationRecord
case order_method.to_s
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
+ when 'last_activity_on_desc' then order_recent_last_activity
+ when 'last_activity_on_asc' then order_oldest_last_activity
else
order_by(order_method)
end
@@ -754,8 +761,12 @@ class User < ApplicationRecord
#
# Example use:
# `Project.where('EXISTS(?)', user.authorizations_for_projects)`
- def authorizations_for_projects
- project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
+ def authorizations_for_projects(min_access_level: nil)
+ authorizations = project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
+
+ return authorizations unless min_access_level.present?
+
+ authorizations.where('project_authorizations.access_level >= ?', min_access_level)
end
# Returns the projects this user has reporter (or greater) access to, limited
diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb
index e42d78f47c5..2c90b8a73cd 100644
--- a/app/policies/ci/pipeline_policy.rb
+++ b/app/policies/ci/pipeline_policy.rb
@@ -10,6 +10,15 @@ module Ci
@subject.project.branch_allows_collaboration?(@user, @subject.ref)
end
+ condition(:external_pipeline, scope: :subject, score: 0) do
+ @subject.external?
+ end
+
+ # Disallow users without permissions from accessing internal pipelines
+ rule { ~can?(:read_build) & ~external_pipeline }.policy do
+ prevent :read_pipeline
+ end
+
rule { protected_ref }.prevent :update_pipeline
rule { can?(:public_access) & branch_allows_collaboration }.policy do
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index a0706eaa46c..dd8c5d49cf4 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -18,6 +18,7 @@ class IssuePolicy < IssuablePolicy
prevent :read_issue_iid
prevent :update_issue
prevent :admin_issue
+ prevent :create_note
end
rule { locked }.policy do
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index f22843b6463..8d23e3abed3 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -18,6 +18,7 @@ class NotePolicy < BasePolicy
prevent :read_note
prevent :admin_note
prevent :resolve_note
+ prevent :award_emoji
end
rule { is_author }.policy do
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index 040b5a73415..2b5cca76c20 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -28,7 +28,10 @@ class PersonalSnippetPolicy < BasePolicy
rule { anonymous }.prevent :comment_personal_snippet
- rule { can?(:comment_personal_snippet) }.enable :award_emoji
+ rule { can?(:comment_personal_snippet) }.policy do
+ enable :create_note
+ enable :award_emoji
+ end
rule { full_private_access }.enable :read_personal_snippet
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 12f9f29dcc1..cadbc5ae009 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -108,6 +108,10 @@ class ProjectPolicy < BasePolicy
condition(:has_clusters, scope: :subject) { clusterable_has_clusters? }
condition(:can_have_multiple_clusters) { multiple_clusters_available? }
+ condition(:internal_builds_disabled) do
+ !@subject.builds_enabled?
+ end
+
features = %w[
merge_requests
issues
@@ -196,7 +200,6 @@ class ProjectPolicy < BasePolicy
enable :read_build
enable :read_container_image
enable :read_pipeline
- enable :read_pipeline_schedule
enable :read_environment
enable :read_deployment
enable :read_merge_request
@@ -235,6 +238,7 @@ class ProjectPolicy < BasePolicy
enable :update_build
enable :create_pipeline
enable :update_pipeline
+ enable :read_pipeline_schedule
enable :create_pipeline_schedule
enable :create_merge_request_from
enable :create_wiki
@@ -314,13 +318,12 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:project_snippet))
end
- rule { wiki_disabled & ~has_external_wiki }.policy do
+ rule { wiki_disabled }.policy do
prevent(*create_read_update_admin_destroy(:wiki))
prevent(:download_wiki_code)
end
rule { builds_disabled | repository_disabled }.policy do
- prevent(*create_update_admin_destroy(:pipeline))
prevent(*create_read_update_admin_destroy(:build))
prevent(*create_read_update_admin_destroy(:pipeline_schedule))
prevent(*create_read_update_admin_destroy(:environment))
@@ -328,11 +331,22 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:deployment))
end
+ # There's two separate cases when builds_disabled is true:
+ # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled
+ # - We do not prevent the user from accessing Pipelines to allow him to access external CI
+ # 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled
+ # - We prevent the user from accessing Pipelines
+ rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:pipeline))
+ prevent(*create_read_update_admin_destroy(:commit_status))
+ end
+
rule { repository_disabled }.policy do
prevent :push_code
prevent :download_code
prevent :fork_project
prevent :read_commit_status
+ prevent :read_pipeline
prevent(*create_read_update_admin_destroy(:release))
end
@@ -359,7 +373,6 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
enable :read_note
enable :read_pipeline
- enable :read_pipeline_schedule
enable :read_commit_status
enable :read_container_image
enable :download_code
@@ -378,7 +391,6 @@ class ProjectPolicy < BasePolicy
rule { public_builds & can?(:guest_access) }.policy do
enable :read_pipeline
- enable :read_pipeline_schedule
end
# These rules are included to allow maintainers of projects to push to certain
@@ -393,7 +405,7 @@ class ProjectPolicy < BasePolicy
end.enable :read_issue_iid
rule do
- (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request)
+ (~guest & can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request)
end.enable :read_merge_request_iid
rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index 7dafa33bb99..e5e005cee6d 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -43,4 +43,6 @@ class ProjectSnippetPolicy < BasePolicy
enable :update_project_snippet
enable :admin_project_snippet
end
+
+ rule { ~can?(:read_project_snippet) }.prevent :create_note
end
diff --git a/app/presenters/ci/trigger_presenter.rb b/app/presenters/ci/trigger_presenter.rb
new file mode 100644
index 00000000000..605c8f328a4
--- /dev/null
+++ b/app/presenters/ci/trigger_presenter.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ class TriggerPresenter < Gitlab::View::Presenter::Delegated
+ presents :trigger
+
+ def has_token_exposed?
+ can?(current_user, :admin_trigger, trigger)
+ end
+
+ def token
+ if has_token_exposed?
+ trigger.token
+ else
+ trigger.short_token
+ end
+ end
+ end
+end
diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb
new file mode 100644
index 00000000000..05adbe1d4f5
--- /dev/null
+++ b/app/presenters/commit_presenter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class CommitPresenter < Gitlab::View::Presenter::Simple
+ presents :commit
+
+ def status_for(ref)
+ can?(current_user, :read_commit_status, commit.project) && commit.status(ref)
+ end
+
+ def any_pipelines?
+ can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any?
+ end
+end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 44b6ca299ae..c59e73f824c 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -170,6 +170,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
end
+ def can_read_pipeline?
+ pipeline && can?(current_user, :read_pipeline, pipeline)
+ end
+
def mergeable_discussions_state
# This avoids calling MergeRequest#mergeable_discussions_state without
# considering the state of the MR first. If a MR isn't mergeable, we can
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 62b23a889c8..02df1480828 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -8,4 +8,5 @@ class ClusterApplicationEntity < Grape::Entity
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) }
+ expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) }
end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index aa1d9e6292c..34ae06278c8 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -24,6 +24,12 @@ class DeploymentEntity < Grape::Entity
expose :user, using: UserEntity
expose :commit, using: CommitEntity
expose :deployable, using: JobEntity
- expose :manual_actions, using: JobEntity
- expose :scheduled_actions, using: JobEntity
+ expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? }
+ expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? }
+
+ private
+
+ def can_create_deployment?
+ can?(request.current_user, :create_deployment, request.project)
+ end
end
diff --git a/app/serializers/error_tracking/project_entity.rb b/app/serializers/error_tracking/project_entity.rb
new file mode 100644
index 00000000000..405d87ca0d0
--- /dev/null
+++ b/app/serializers/error_tracking/project_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ProjectEntity < Grape::Entity
+ expose(*Gitlab::ErrorTracking::Project::ACCESSORS)
+ end
+end
diff --git a/app/serializers/error_tracking/project_serializer.rb b/app/serializers/error_tracking/project_serializer.rb
new file mode 100644
index 00000000000..68724088fff
--- /dev/null
+++ b/app/serializers/error_tracking/project_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ProjectSerializer < BaseSerializer
+ entity ErrorTracking::ProjectEntity
+ end
+end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index 084627f9dbe..178e72f4f0a 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -11,4 +11,5 @@ class MergeRequestBasicEntity < Grape::Entity
expose :labels, using: LabelEntity
expose :assignee, using: API::Entities::UserBasic
expose :task_status, :task_status_short
+ expose :lock_version, :lock_version
end
diff --git a/app/serializers/merge_request_widget_commit_entity.rb b/app/serializers/merge_request_widget_commit_entity.rb
new file mode 100644
index 00000000000..50a5c44a6ad
--- /dev/null
+++ b/app/serializers/merge_request_widget_commit_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestWidgetCommitEntity < Grape::Entity
+ expose :safe_message, as: :message
+ expose :short_id
+ expose :title
+end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 9361c9f987b..2142ceb6122 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -56,10 +56,23 @@ class MergeRequestWidgetEntity < IssuableEntity
merge_request.diff_head_sha.presence
end
- expose :merge_commit_message
- expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline
+ expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? }
+
expose :merge_pipeline, with: PipelineDetailsEntity, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)}
+ expose :default_squash_commit_message
+ expose :default_merge_commit_message
+
+ expose :default_merge_commit_message_with_description do |merge_request|
+ merge_request.default_merge_commit_message(include_description: true)
+ end
+
+ expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request|
+ merge_request.commits.without_merge_commits
+ end
+
+ expose :commits_count
+
# Booleans
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
@@ -77,7 +90,6 @@ class MergeRequestWidgetEntity < IssuableEntity
end
expose :branch_missing?, as: :branch_missing
- expose :commits_count
expose :cannot_be_merged?, as: :has_conflicts
expose :can_be_merged?, as: :can_be_merged
expose :mergeable?, as: :mergeable
@@ -205,10 +217,6 @@ class MergeRequestWidgetEntity < IssuableEntity
ci_environments_status_project_merge_request_path(merge_request.project, merge_request)
end
- expose :merge_commit_message_with_description do |merge_request|
- merge_request.merge_commit_message(include_description: true)
- end
-
expose :diverged_commits_count do |merge_request|
if merge_request.open? && merge_request.diverged_from_target_branch?
merge_request.diverged_commits_count
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index f764536e762..e95ba09c006 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -160,7 +160,8 @@ module Auth
##
# We still support legacy pipeline triggers which do not have associated
# actor. New permissions model and new triggers are always associated with
- # an actor, so this should be improved in 10.0 version of GitLab.
+ # an actor. So this should be improved once
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/37452 is resolved.
#
def build_can_push?(requested_project)
# Build can push only to the project from which it originates
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index f8d8ef04001..699b3e8555e 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -7,14 +7,17 @@ module Ci
CreateError = Class.new(StandardError)
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
+ Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
Gitlab::Ci::Pipeline::Chain::Validate::Repository,
Gitlab::Ci::Pipeline::Chain::Validate::Config,
Gitlab::Ci::Pipeline::Chain::Skip,
+ Gitlab::Ci::Pipeline::Chain::Limit::Size,
Gitlab::Ci::Pipeline::Chain::Populate,
- Gitlab::Ci::Pipeline::Chain::Create].freeze
+ Gitlab::Ci::Pipeline::Chain::Create,
+ Gitlab::Ci::Pipeline::Chain::Limit::Activity].freeze
- def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block)
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, **options, &block)
@pipeline = Ci::Pipeline.new
command = Gitlab::Ci::Pipeline::Chain::Command.new(
@@ -32,7 +35,8 @@ module Ci
variables_attributes: params[:variables_attributes],
project: project,
current_user: current_user,
- push_options: params[:push_options])
+ push_options: params[:push_options],
+ **extra_options(**options))
sequence = Gitlab::Ci::Pipeline::Chain::Sequence
.new(pipeline, command, SEQUENCE)
@@ -103,5 +107,9 @@ module Ci
pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def extra_options
+ {} # overriden in EE
+ end
end
end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index f54574b026b..4ba3f5fb8ba 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -7,6 +7,8 @@ module Ci
def execute
if trigger_from_token
create_pipeline_from_trigger(trigger_from_token)
+ elsif job_from_token
+ create_pipeline_from_job(job_from_token)
end
end
@@ -35,6 +37,14 @@ module Ci
end
end
+ def create_pipeline_from_job(job)
+ # overriden in EE
+ end
+
+ def job_from_token
+ # overriden in EE
+ end
+
def variables
params[:variables].to_h.map do |key, value|
{ key: key, value: value }
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
index 21ec26ea233..c592d608b89 100644
--- a/app/services/clusters/applications/check_installation_progress_service.rb
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -4,7 +4,7 @@ module Clusters
module Applications
class CheckInstallationProgressService < BaseHelmService
def execute
- return unless app.installing?
+ return unless operation_in_progress?
case installation_phase
when Gitlab::Kubernetes::Pod::SUCCEEDED
@@ -16,11 +16,16 @@ module Clusters
end
rescue Kubeclient::HttpError => e
log_error(e)
- app.make_errored!("Kubernetes error: #{e.error_code}") unless app.errored?
+
+ app.make_errored!("Kubernetes error: #{e.error_code}")
end
private
+ def operation_in_progress?
+ app.installing? || app.updating?
+ end
+
def on_success
app.make_installed!
ensure
@@ -28,13 +33,13 @@ module Clusters
end
def on_failed
- app.make_errored!("Installation failed. Check pod logs for #{install_command.pod_name} for more details.")
+ app.make_errored!("Operation failed. Check pod logs for #{pod_name} for more details.")
end
def check_timeout
if timeouted?
begin
- app.make_errored!("Installation timed out. Check pod logs for #{install_command.pod_name} for more details.")
+ app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.")
end
else
ClusterWaitForAppInstallationWorker.perform_in(
@@ -42,20 +47,24 @@ module Clusters
end
end
+ def pod_name
+ install_command.pod_name
+ end
+
def timeouted?
Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
end
def remove_installation_pod
- helm_api.delete_pod!(install_command.pod_name)
+ helm_api.delete_pod!(pod_name)
end
def installation_phase
- helm_api.status(install_command.pod_name)
+ helm_api.status(pod_name)
end
def installation_errors
- helm_api.log(install_command.pod_name)
+ helm_api.log(pod_name)
end
end
end
diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb
index d75ba70c27e..15c93f1e79b 100644
--- a/app/services/clusters/applications/schedule_installation_service.rb
+++ b/app/services/clusters/applications/schedule_installation_service.rb
@@ -10,6 +10,18 @@ module Clusters
end
def execute
+ application.updateable? ? schedule_upgrade : schedule_install
+ end
+
+ private
+
+ def schedule_upgrade
+ application.make_scheduled!
+
+ ClusterUpgradeAppWorker.perform_async(application.name, application.id)
+ end
+
+ def schedule_install
application.make_scheduled!
ClusterInstallAppWorker.perform_async(application.name, application.id)
diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb
new file mode 100644
index 00000000000..a0ece1d2635
--- /dev/null
+++ b/app/services/clusters/applications/upgrade_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UpgradeService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ begin
+ app.make_updating!
+
+ # install_command works with upgrades too
+ # as it basically does `helm upgrade --install`
+ helm_api.update(install_command)
+
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_update_errored!("Kubernetes error: #{e.error_code}")
+ rescue StandardError => e
+ log_error(e)
+ app.make_update_errored!("Can't start upgrade process.")
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb
new file mode 100644
index 00000000000..c6e8be0f2be
--- /dev/null
+++ b/app/services/error_tracking/list_projects_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ListProjectsService < ::BaseService
+ def execute
+ return error('access denied') unless can_read?
+
+ setting = project_error_tracking_setting
+
+ unless setting.valid?
+ return error(setting.errors.full_messages.join(', '), :bad_request)
+ end
+
+ begin
+ result = setting.list_sentry_projects
+ rescue Sentry::Client::Error => e
+ return error(e.message, :bad_request)
+ rescue Sentry::Client::SentryError => e
+ return error(e.message, :unprocessable_entity)
+ end
+
+ success(projects: result[:projects])
+ end
+
+ private
+
+ def project_error_tracking_setting
+ (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting|
+ setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
+ api_host: params[:api_host],
+ organization_slug: nil,
+ project_slug: nil
+ )
+
+ setting.token = params[:token]
+ setting.enabled = true
+ end
+ end
+
+ def can_read?
+ can?(current_user, :read_sentry_issue, project)
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 24d8400c625..55a3b9fa7b1 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -10,6 +10,8 @@ module Groups
def execute
@group = Group.new(params)
+ after_build_hook(@group, params)
+
unless can_use_visibility_level? && can_create_group?
return @group
end
@@ -30,6 +32,10 @@ module Groups
private
+ def after_build_hook(group, params)
+ # overriden in EE
+ end
+
def create_chat_team?
Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil?
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index de78a3f7b27..9ff1da270e2 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -11,6 +11,8 @@ module Groups
return false unless valid_share_with_group_lock_change?
+ before_assignment_hook(group, params)
+
group.assign_attributes(params)
begin
@@ -28,6 +30,10 @@ module Groups
private
+ def before_assignment_hook(group, params)
+ # overriden in EE
+ end
+
def after_update
if group.previous_changes.include?(:visibility_level) && group.private?
# don't enqueue immediately to prevent todos removal in case of a mistake
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index 885e14bba8f..77f38f8882e 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -20,7 +20,7 @@ module Issuable
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
+ create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
end
private
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 805bb5b510d..ef991eaf234 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -235,6 +235,61 @@ class IssuableBaseService < BaseService
issuable
end
+ def update_task(issuable)
+ filter_params(issuable)
+
+ if issuable.changed? || params.present?
+ issuable.assign_attributes(params.merge(updated_by: current_user,
+ last_edited_at: Time.now,
+ last_edited_by: current_user))
+
+ before_update(issuable)
+
+ if issuable.with_transaction_returning_status { issuable.save }
+ # We do not touch as it will affect a update on updated_at field
+ ActiveRecord::Base.no_touching do
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil)
+ end
+
+ handle_task_changes(issuable)
+ invalidate_cache_counts(issuable, users: issuable.assignees.to_a)
+ after_update(issuable)
+ execute_hooks(issuable, 'update', old_associations: nil)
+ end
+ end
+
+ issuable
+ end
+
+ # Handle the `update_task` event sent from UI. Attempts to update a specific
+ # line in the markdown and cached html, bypassing any unnecessary updates or checks.
+ def update_task_event(issuable)
+ update_task_params = params.delete(:update_task)
+ return unless update_task_params
+
+ tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html,
+ line_source: update_task_params[:line_source],
+ line_number: update_task_params[:line_number].to_i,
+ toggle_as_checked: update_task_params[:checked])
+
+ unless tasklist_toggler.execute
+ # if we make it here, the data is much newer than we thought it was - fail fast
+ raise ActiveRecord::StaleObjectError
+ end
+
+ # by updating the description_html field at the same time,
+ # the markdown cache won't be considered invalid
+ params[:description] = tasklist_toggler.updated_markdown
+ params[:description_html] = tasklist_toggler.updated_markdown_html
+
+ # since we're updating a very specific line, we don't care whether
+ # the `lock_version` sent from the FE is the same or not. Just
+ # make sure the data hasn't changed since we queried it
+ params[:lock_version] = issuable.lock_version
+
+ update_task(issuable)
+ end
+
def labels_changing?(old_label_ids, new_label_ids)
old_label_ids.sort != new_label_ids.sort
end
@@ -318,6 +373,10 @@ class IssuableBaseService < BaseService
end
# override if needed
+ def handle_task_changes(issuable)
+ end
+
+ # override if needed
def execute_hooks(issuable, action = 'open', params = {})
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index e992d682c79..cec5b5734c0 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -8,7 +8,7 @@ module Issues
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
- move_issue_to_new_project(issue) || update(issue)
+ move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
end
def update(issue)
@@ -63,6 +63,11 @@ module Issues
end
end
+ def handle_task_changes(issuable)
+ todo_service.mark_pending_todos_as_done(issuable, current_user)
+ todo_service.update_issue(issuable, current_user)
+ end
+
def handle_move_between_ids(issue)
return unless params[:move_between_ids]
@@ -78,6 +83,8 @@ module Issues
# rubocop: disable CodeReuse/ActiveRecord
def change_issue_duplicate(issue)
canonical_issue_id = params.delete(:canonical_issue_id)
+ return unless canonical_issue_id
+
canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id)
if canonical_issue
diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb
index e563447c64c..be33947d0eb 100644
--- a/app/services/labels/update_service.rb
+++ b/app/services/labels/update_service.rb
@@ -8,6 +8,7 @@ module Labels
# returns the updated label
def execute(label)
+ params[:name] = params.delete(:new_name) if params.key?(:new_name)
params[:color] = convert_color_name_to_hex if params[:color].present?
label.update(params)
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 714b8586737..cf710fef52b 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -19,9 +19,19 @@ module Members
current_user: current_user
)
- members.each { |member| after_execute(member: member) }
+ errors = []
- success
+ members.each do |member|
+ if member.errors.any?
+ errors << "#{member.user.username}: #{member.errors.full_messages.to_sentence}"
+ else
+ after_execute(member: member)
+ end
+ end
+
+ return success unless errors.any?
+
+ error(errors.to_sentence)
end
private
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index ae0c644e6c0..f9717a9426b 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -2,9 +2,11 @@
module Members
class DestroyService < Members::BaseService
- def execute(member, skip_authorization: false)
+ def execute(member, skip_authorization: false, skip_subresources: false)
raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member)
+ @skip_auth = skip_authorization
+
return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
member.destroy
@@ -15,6 +17,7 @@ module Members
notification_service.decline_access_request(member)
end
+ delete_subresources(member) unless skip_subresources
enqueue_delete_todos(member)
after_execute(member: member)
@@ -24,6 +27,29 @@ module Members
private
+ def delete_subresources(member)
+ return unless member.is_a?(GroupMember) && member.user && member.group
+
+ delete_project_members(member)
+ delete_subgroup_members(member) if Group.supports_nested_objects?
+ end
+
+ def delete_project_members(member)
+ groups = member.group.self_and_descendants
+
+ ProjectMember.in_namespaces(groups).with_user(member.user).each do |project_member|
+ self.class.new(current_user).execute(project_member, skip_authorization: @skip_auth)
+ end
+ end
+
+ def delete_subgroup_members(member)
+ groups = member.group.descendants
+
+ GroupMember.in_groups(groups).with_user(member.user).each do |group_member|
+ self.class.new(current_user).execute(group_member, skip_authorization: @skip_auth, skip_subresources: true)
+ end
+ end
+
def can_destroy_member?(member)
can?(current_user, destroy_member_permission(member), member)
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index fe19abf50f6..ac51fee0b3f 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -63,6 +63,7 @@ module MergeRequests
# UpdateMergeRequestsWorker could be retried by an exception.
# MR pipelines should not be recreated in such case.
return if merge_request.merge_request_pipeline_exists?
+ return if merge_request.has_no_commits?
Ci::CreatePipelineService
.new(merge_request.source_project, user, ref: merge_request.source_branch)
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 70a67baa01c..449997bcf07 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -8,6 +8,8 @@ module MergeRequests
# Executed when you do merge via GitLab UI
#
class MergeService < MergeRequests::BaseService
+ include Gitlab::Utils::StrongMemoize
+
MergeError = Class.new(StandardError)
attr_reader :merge_request, :source
@@ -37,15 +39,10 @@ module MergeRequests
end
def source
- return merge_request.diff_head_sha unless merge_request.squash
-
- squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute(merge_request)
-
- case squash_result[:status]
- when :success
- squash_result[:squash_sha]
- when :error
- raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
+ if merge_request.squash
+ squash_sha!
+ else
+ merge_request.diff_head_sha
end
end
@@ -82,8 +79,22 @@ module MergeRequests
merge_request.update!(merge_commit_sha: commit_id)
end
+ def squash_sha!
+ strong_memoize(:squash_sha) do
+ params[:merge_request] = merge_request
+ squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute
+
+ case squash_result[:status]
+ when :success
+ squash_result[:squash_sha]
+ when :error
+ raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
+ end
+ end
+ end
+
def try_merge
- message = params[:commit_message] || merge_request.merge_commit_message
+ message = params[:commit_message] || merge_request.default_merge_commit_message
repository.merge(current_user, source, merge_request, message)
rescue Gitlab::Git::PreReceiveError => e
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index a439a380255..9d1a5d5e6d4 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -2,15 +2,10 @@
module MergeRequests
class SquashService < MergeRequests::WorkingCopyBaseService
- def execute(merge_request)
- @merge_request = merge_request
- @repository = target_project.repository
-
- squash || error('Failed to squash. Should be done manually.')
- end
-
- def squash
- if merge_request.commits_count < 2
+ def execute
+ # If performing a squash would result in no change, then
+ # immediately return a success message without performing a squash
+ if merge_request.commits_count < 2 && message.nil?
return success(squash_sha: merge_request.diff_head_sha)
end
@@ -18,7 +13,13 @@ module MergeRequests
return error('Squash task canceled: another squash is already in progress.')
end
- squash_sha = repository.squash(current_user, merge_request)
+ squash! || error('Failed to squash. Should be done manually.')
+ end
+
+ private
+
+ def squash!
+ squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message)
success(squash_sha: squash_sha)
rescue => e
@@ -26,5 +27,17 @@ module MergeRequests
log_error(e.message)
false
end
+
+ def repository
+ target_project.repository
+ end
+
+ def merge_request
+ params[:merge_request]
+ end
+
+ def message
+ params[:squash_commit_message].presence
+ end
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 86a04587f79..8112c2a4299 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -21,7 +21,7 @@ module MergeRequests
end
handle_wip_event(merge_request)
- update(merge_request)
+ update_task_event(merge_request) || update(merge_request)
end
# rubocop:disable Metrics/AbcSize
@@ -83,6 +83,11 @@ module MergeRequests
end
# rubocop:enable Metrics/AbcSize
+ def handle_task_changes(merge_request)
+ todo_service.mark_pending_todos_as_done(merge_request, current_user)
+ todo_service.update_merge_request(merge_request, current_user)
+ end
+
def merge_from_quick_action(merge_request)
last_diff_sha = params.delete(:merge)
return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index 7b92fe6fe14..541f3e0d23c 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -9,12 +9,14 @@ module Notes
if in_reply_to_discussion_id.present?
discussion = find_discussion(in_reply_to_discussion_id)
- unless discussion
+ unless discussion && can?(current_user, :create_note, discussion.noteable)
note = Note.new
note.errors.add(:base, 'Discussion to reply to cannot be found')
return note
end
+ discussion = discussion.convert_to_discussion! if discussion.can_convert_to_discussion?
+
params.merge!(discussion.reply_attributes)
should_resolve = discussion.resolved?
end
@@ -34,19 +36,8 @@ module Notes
if project
project.notes.find_discussion(discussion_id)
else
- discussion = Note.find_discussion(discussion_id)
- noteable = discussion.noteable
-
- return nil unless noteable_without_project?(noteable)
-
- discussion
+ Note.find_discussion(discussion_id)
end
end
-
- def noteable_without_project?(noteable)
- return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable)
-
- false
- end
end
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index c4546f30235..b975c3a8cb6 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -34,6 +34,10 @@ module Notes
end
if !only_commands && note.save
+ if note.part_of_discussion? && note.discussion.can_convert_to_discussion?
+ note.discussion.convert_to_discussion!.save(touch: false)
+ end
+
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
Suggestions::CreateService.new(note).execute
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index e1cf327209b..1a65561dd70 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -373,7 +373,8 @@ class NotificationService
end
def project_was_moved(project, old_path_with_namespace)
- recipients = notifiable_users(project.team.members, :mention, project: project)
+ recipients = project.private? ? project.team.members_in_project_and_ancestors : project.team.members
+ recipients = notifiable_users(recipients, :mention, project: project)
recipients.each do |recipient|
mailer.project_was_moved_email(
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index a449a5dc3e9..c1655c38095 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -10,8 +10,7 @@ class PreviewMarkdownService < BaseService
text: text,
users: users,
suggestions: suggestions,
- commands: commands.join(' '),
- markdown_engine: markdown_engine
+ commands: commands.join(' ')
)
end
@@ -49,12 +48,4 @@ class PreviewMarkdownService < BaseService
def commands_target_id
params[:quick_actions_target_id]
end
-
- def markdown_engine
- if params[:legacy_render]
- :redcarpet
- else
- CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i)
- end
- end
end
diff --git a/app/services/projects/import_error_filter.rb b/app/services/projects/import_error_filter.rb
new file mode 100644
index 00000000000..a0fc5149bb4
--- /dev/null
+++ b/app/services/projects/import_error_filter.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Projects
+ # Used by project imports, it removes any potential paths
+ # included in an error message that could be stored in the DB
+ class ImportErrorFilter
+ ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/
+ FILTER_MESSAGE = '[FILTERED]'
+
+ def self.filter_message(message)
+ message.gsub(ERROR_MESSAGE_FILTER, FILTER_MESSAGE)
+ end
+ end
+end
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index 0c426faa22d..7214e9efaf6 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -24,8 +24,16 @@ module Projects
import_data
success
- rescue => e
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type })
+
error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{e.message}")
+ rescue => e
+ message = Projects::ImportErrorFilter.filter_message(e.message)
+
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type })
+
+ error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{message}")
end
private
@@ -35,7 +43,7 @@ module Projects
begin
Gitlab::UrlBlocker.validate!(project.import_url, ports: Project::VALID_IMPORT_PORTS)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
- raise Error, "Blocked import URL: #{e.message}"
+ raise e, "Blocked import URL: #{e.message}"
end
end
@@ -65,7 +73,7 @@ module Projects
project.ensure_repository
project.repository.fetch_as_mirror(project.import_url, refmap: refmap)
else
- gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url)
+ gitlab_shell.import_project_repository(project)
end
rescue Gitlab::Shell::Error => e
# Expire cache to prevent scenarios such as:
@@ -86,11 +94,11 @@ module Projects
return unless project.lfs_enabled?
- oids_to_download = Projects::LfsPointers::LfsImportService.new(project).execute
- download_service = Projects::LfsPointers::LfsDownloadService.new(project)
+ lfs_objects_to_download = Projects::LfsPointers::LfsImportService.new(project).execute
- oids_to_download.each do |oid, link|
- download_service.execute(oid, link)
+ lfs_objects_to_download.each do |lfs_download_object|
+ Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object)
+ .execute
end
rescue => e
# Right now, to avoid aborting the importing process, we silently fail
diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
index a837ea82e38..7998976b00a 100644
--- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
@@ -41,16 +41,17 @@ module Projects
end
def parse_response_links(objects_response)
- objects_response.each_with_object({}) do |entry, link_list|
+ objects_response.each_with_object([]) do |entry, link_list|
begin
- oid = entry['oid']
link = entry.dig('actions', DOWNLOAD_ACTION, 'href')
raise DownloadLinkNotFound unless link
- link_list[oid] = add_credentials(link)
- rescue DownloadLinkNotFound, URI::InvalidURIError
- Rails.logger.error("Link for Lfs Object with oid #{oid} not found or invalid.")
+ link_list << LfsDownloadObject.new(oid: entry['oid'],
+ size: entry['size'],
+ link: add_credentials(link))
+ rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError
+ log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.")
end
end
end
@@ -70,7 +71,7 @@ module Projects
end
def add_credentials(link)
- uri = URI.parse(link)
+ uri = Addressable::URI.parse(link)
if should_add_credentials?(uri)
uri.user = remote_uri.user
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index b5128443435..398f00a598d 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -4,68 +4,93 @@
module Projects
module LfsPointers
class LfsDownloadService < BaseService
- VALID_PROTOCOLS = %w[http https].freeze
+ SizeError = Class.new(StandardError)
+ OidError = Class.new(StandardError)
- # rubocop: disable CodeReuse/ActiveRecord
- def execute(oid, url)
- return unless project&.lfs_enabled? && oid.present? && url.present?
+ attr_reader :lfs_download_object
+ delegate :oid, :size, :credentials, :sanitized_url, to: :lfs_download_object, prefix: :lfs
- return if LfsObject.exists?(oid: oid)
+ def initialize(project, lfs_download_object)
+ super(project)
- sanitized_uri = sanitize_url!(url)
+ @lfs_download_object = lfs_download_object
+ end
- with_tmp_file(oid) do |file|
- download_and_save_file(file, sanitized_uri)
- lfs_object = LfsObject.new(oid: oid, size: file.size, file: file)
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ return unless project&.lfs_enabled? && lfs_download_object
+ return error("LFS file with oid #{lfs_oid} has invalid attributes") unless lfs_download_object.valid?
+ return if LfsObject.exists?(oid: lfs_oid)
- project.all_lfs_objects << lfs_object
+ wrap_download_errors do
+ download_lfs_file!
end
- rescue Gitlab::UrlBlocker::BlockedUrlError => e
- Rails.logger.error("LFS file with oid #{oid} couldn't be downloaded: #{e.message}")
- rescue StandardError => e
- Rails.logger.error("LFS file with oid #{oid} couldn't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}")
end
# rubocop: enable CodeReuse/ActiveRecord
private
- def sanitize_url!(url)
- Gitlab::UrlSanitizer.new(url).tap do |sanitized_uri|
- # Just validate that HTTP/HTTPS protocols are used. The
- # subsequent Gitlab::HTTP.get call will do network checks
- # based on the settings.
- Gitlab::UrlBlocker.validate!(sanitized_uri.sanitized_url,
- protocols: VALID_PROTOCOLS)
+ def wrap_download_errors(&block)
+ yield
+ rescue SizeError, OidError, StandardError => e
+ error("LFS file with oid #{lfs_oid} could't be downloaded from #{lfs_sanitized_url}: #{e.message}")
+ end
+
+ def download_lfs_file!
+ with_tmp_file do |tmp_file|
+ download_and_save_file!(tmp_file)
+ project.all_lfs_objects << LfsObject.new(oid: lfs_oid,
+ size: lfs_size,
+ file: tmp_file)
+
+ success
end
end
- def download_and_save_file(file, sanitized_uri)
- response = Gitlab::HTTP.get(sanitized_uri.sanitized_url, headers(sanitized_uri)) do |fragment|
+ def download_and_save_file!(file)
+ digester = Digest::SHA256.new
+ response = Gitlab::HTTP.get(lfs_sanitized_url, download_headers) do |fragment|
+ digester << fragment
file.write(fragment)
+
+ raise_size_error! if file.size > lfs_size
end
raise StandardError, "Received error code #{response.code}" unless response.success?
- end
- def headers(sanitized_uri)
- query_options.tap do |headers|
- credentials = sanitized_uri.credentials
+ raise_size_error! if file.size != lfs_size
+ raise_oid_error! if digester.hexdigest != lfs_oid
+ end
- if credentials[:user].present? || credentials[:password].present?
+ def download_headers
+ { stream_body: true }.tap do |headers|
+ if lfs_credentials[:user].present? || lfs_credentials[:password].present?
# Using authentication headers in the request
- headers[:http_basic_authentication] = [credentials[:user], credentials[:password]]
+ headers[:basic_auth] = { username: lfs_credentials[:user], password: lfs_credentials[:password] }
end
end
end
- def query_options
- { stream_body: true }
- end
-
- def with_tmp_file(oid)
+ def with_tmp_file
create_tmp_storage_dir
- File.open(File.join(tmp_storage_dir, oid), 'wb') { |file| yield file }
+ File.open(tmp_filename, 'wb') do |file|
+ begin
+ yield file
+ rescue StandardError => e
+ # If the lfs file is successfully downloaded it will be removed
+ # when it is added to the project's lfs files.
+ # Nevertheless if any excetion raises the file would remain
+ # in the file system. Here we ensure to remove it
+ File.unlink(file) if File.exist?(file)
+
+ raise e
+ end
+ end
+ end
+
+ def tmp_filename
+ File.join(tmp_storage_dir, lfs_oid)
end
def create_tmp_storage_dir
@@ -79,6 +104,20 @@ module Projects
def storage_dir
@storage_dir ||= Gitlab.config.lfs.storage_path
end
+
+ def raise_size_error!
+ raise SizeError, 'Size mistmatch'
+ end
+
+ def raise_oid_error!
+ raise OidError, 'Oid mismatch'
+ end
+
+ def error(message, http_status = nil)
+ log_error(message)
+
+ super
+ end
end
end
end
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index abf40b3ad7a..674071ad92a 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -2,6 +2,8 @@
module Projects
class UpdatePagesConfigurationService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
attr_reader :project
def initialize(project)
@@ -9,15 +11,25 @@ module Projects
end
def execute
- update_file(pages_config_file, pages_config.to_json)
+ if file_equals?(pages_config_file, pages_config_json)
+ return success(reload: false)
+ end
+
+ update_file(pages_config_file, pages_config_json)
reload_daemon
- success
+ success(reload: true)
rescue => e
error(e.message)
end
private
+ def pages_config_json
+ strong_memoize(:pages_config_json) do
+ pages_config.to_json
+ end
+ end
+
def pages_config
{
domains: pages_domains_config,
@@ -67,11 +79,6 @@ module Projects
end
def update_file(file, data)
- unless data
- FileUtils.remove(file, force: true)
- return
- end
-
temp_file = "#{file}.#{SecureRandom.hex(16)}"
File.open(temp_file, 'w') do |f|
f.write(data)
@@ -81,5 +88,18 @@ module Projects
# In case if the updating fails
FileUtils.remove(temp_file, force: true)
end
+
+ def file_equals?(file, data)
+ existing_data = read_file(file)
+ data == existing_data.to_s
+ end
+
+ def read_file(file)
+ File.open(file, 'r') do |f|
+ f.read
+ end
+ rescue
+ nil
+ end
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index eb2478be3cf..5caeb4cfa5f 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -7,7 +7,11 @@ module Projects
BLOCK_SIZE = 32.kilobytes
MAX_SIZE = 1.terabyte
- SITE_PATH = 'public/'.freeze
+ PUBLIC_DIR = 'public'.freeze
+
+ # this has to be invalid group name,
+ # as it shares the namespace with groups
+ TMP_EXTRACT_PATH = '@pages.tmp'.freeze
attr_reader :build
@@ -27,12 +31,11 @@ module Projects
raise InvalidStateError, 'pages are outdated' unless latest?
# Create temporary directory in which we will extract the artifacts
- FileUtils.mkdir_p(tmp_path)
- Dir.mktmpdir(nil, tmp_path) do |archive_path|
+ make_secure_tmp_dir(tmp_path) do |archive_path|
extract_archive!(archive_path)
# Check if we did extract public directory
- archive_public_path = File.join(archive_path, 'public')
+ archive_public_path = File.join(archive_path, PUBLIC_DIR)
raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
raise InvalidStateError, 'pages are outdated' unless latest?
@@ -85,22 +88,18 @@ module Projects
raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata?
# Calculate page size after extract
- public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true)
+ public_entry = build.artifacts_metadata_entry(PUBLIC_DIR + '/', recursive: true)
if public_entry.total_size > max_size
raise InvalidStateError, "artifacts for pages are too large: #{public_entry.total_size}"
end
- # Requires UnZip at least 6.00 Info-ZIP.
- # -qq be (very) quiet
- # -n never overwrite existing files
- # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories
- site_path = File.join(SITE_PATH, '*')
build.artifacts_file.use_file do |artifacts_path|
- unless system(*%W(unzip -n #{artifacts_path} #{site_path} -d #{temp_path}))
- raise FailedToExtractError, 'pages failed to extract'
- end
+ SafeZip::Extract.new(artifacts_path)
+ .extract(directories: [PUBLIC_DIR], to: temp_path)
end
+ rescue SafeZip::Extract::Error => e
+ raise FailedToExtractError, e.message
end
def deploy_page!(archive_public_path)
@@ -139,7 +138,7 @@ module Projects
end
def tmp_path
- @tmp_path ||= File.join(::Settings.pages.path, 'tmp')
+ @tmp_path ||= File.join(::Settings.pages.path, TMP_EXTRACT_PATH)
end
def pages_path
@@ -147,11 +146,11 @@ module Projects
end
def public_path
- @public_path ||= File.join(pages_path, 'public')
+ @public_path ||= File.join(pages_path, PUBLIC_DIR)
end
def previous_public_path
- @previous_public_path ||= File.join(pages_path, "public.#{SecureRandom.hex}")
+ @previous_public_path ||= File.join(pages_path, "#{PUBLIC_DIR}.#{SecureRandom.hex}")
end
def ref
@@ -188,5 +187,15 @@ module Projects
def pages_deployments_failed_total_counter
@pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed")
end
+
+ def make_secure_tmp_dir(tmp_path)
+ FileUtils.mkdir_p(tmp_path)
+ path = Dir.mktmpdir(nil, tmp_path)
+ begin
+ yield(path)
+ ensure
+ FileUtils.remove_entry_secure(path)
+ end
+ end
end
end
diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb
index 4340d3e8260..9b85e13107b 100644
--- a/app/services/protected_branches/api_service.rb
+++ b/app/services/protected_branches/api_service.rb
@@ -6,8 +6,6 @@ module ProtectedBranches
@push_params = AccessLevelParams.new(:push, params)
@merge_params = AccessLevelParams.new(:merge, params)
- verify_params!
-
protected_branch_params = {
name: params[:name],
push_access_levels_attributes: @push_params.access_levels,
@@ -16,11 +14,5 @@ module ProtectedBranches
::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute
end
-
- private
-
- def verify_params!
- # EE-only
- end
end
end
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index cb1bf0a03a5..d6af26d949d 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -2,6 +2,8 @@
module Search
class GlobalService
+ include Gitlab::Utils::StrongMemoize
+
attr_accessor :current_user, :params
attr_reader :default_project_filter
@@ -19,11 +21,15 @@ module Search
@projects ||= ProjectsFinder.new(current_user: current_user).execute
end
- def scope
- @scope ||= begin
- allowed_scopes = %w[issues merge_requests milestones]
+ def allowed_scopes
+ strong_memoize(:allowed_scopes) do
+ %w[issues merge_requests milestones]
+ end
+ end
- allowed_scopes.delete(params[:scope]) { 'projects' }
+ def scope
+ strong_memoize(:scope) do
+ allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects'
end
end
end
diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb
new file mode 100644
index 00000000000..cfe187d9b12
--- /dev/null
+++ b/app/services/task_list_toggle_service.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+# Finds the correct checkbox in the passed in markdown/html and toggles it's state,
+# returning the updated markdown/html.
+# We don't care if the text has changed above or below the specific checkbox, as long
+# the checkbox still exists at exactly the same line number and the text is equal.
+# If successful, new values are available in `updated_markdown` and `updated_markdown_html`
+class TaskListToggleService
+ attr_reader :updated_markdown, :updated_markdown_html
+
+ def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:)
+ @markdown, @markdown_html = markdown, markdown_html
+ @line_source, @line_number = line_source, line_number
+ @toggle_as_checked = toggle_as_checked
+
+ @updated_markdown, @updated_markdown_html = nil
+ end
+
+ def execute
+ return false unless markdown && markdown_html
+
+ toggle_markdown && toggle_markdown_html
+ end
+
+ private
+
+ attr_reader :markdown, :markdown_html, :toggle_as_checked
+ attr_reader :line_source, :line_number
+
+ def toggle_markdown
+ source_lines = markdown.split("\n")
+ source_line_index = line_number - 1
+ markdown_task = source_lines[source_line_index]
+
+ # The source in the DB could be using either \n or \r\n line endings
+ return unless markdown_task == line_source || markdown_task == line_source + "\r"
+ return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task)
+
+ currently_checked = TaskList::Item.new(source_checkbox[1]).complete?
+
+ # Check `toggle_as_checked` to make sure we don't accidentally replace
+ # any `[ ]` or `[x]` in the middle of the text
+ if currently_checked
+ markdown_task.sub!(Taskable::COMPLETE_PATTERN, '[ ]') unless toggle_as_checked
+ else
+ markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') if toggle_as_checked
+ end
+
+ source_lines[source_line_index] = markdown_task
+ @updated_markdown = source_lines.join("\n")
+ end
+
+ def toggle_markdown_html
+ html = Nokogiri::HTML.fragment(markdown_html)
+ html_checkbox = get_html_checkbox(html)
+ return unless html_checkbox
+
+ if toggle_as_checked
+ html_checkbox[:checked] = 'checked'
+ else
+ html_checkbox.remove_attribute('checked')
+ end
+
+ @updated_markdown_html = html.to_html
+ end
+
+ # When using CommonMark, we should be able to use the embedded `sourcepos` attribute to
+ # target the exact line in the DOM.
+ def get_html_checkbox(html)
+ html.css(".task-list-item[data-sourcepos^='#{line_number}:'] > input.task-list-item-checkbox").first
+ end
+end
diff --git a/app/uploaders/external_diff_uploader.rb b/app/uploaders/external_diff_uploader.rb
new file mode 100644
index 00000000000..d2707cd0777
--- /dev/null
+++ b/app/uploaders/external_diff_uploader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ExternalDiffUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.external_diffs
+
+ alias_method :upload, :model
+
+ def filename
+ "diff-#{model.id}"
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.model_name.plural, "mr-#{model.merge_request_id}")
+ end
+end
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index 544f09048f5..77e84abd76e 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -1,86 +1,96 @@
-= form_for @appearance, url: admin_appearances_path do |f|
+= form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f|
= form_errors(@appearance)
- %fieldset.app_logo
- %legend
- Navigation bar:
- .form-group.row
- = f.label :header_logo, 'Header logo', class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.header_logo?
- = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :header_logo_cache
- = f.file_field :header_logo, class: ""
- .hint
- Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
- %fieldset.app_logo
- %legend
- Favicon:
- .form-group.row
- = f.label :favicon, 'Favicon', class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.favicon?
- = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :favicon_cache
- = f.file_field :favicon, class: ''
- .hint
- Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}.
- %br
- Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Navigation bar
+
+ .col-lg-8
+ .form-group
+ = f.label :header_logo, 'Header logo', class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.header_logo?
+ = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :header_logo_cache
+ = f.file_field :header_logo, class: ""
+ .hint
+ Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Favicon
+
+ .col-lg-8
+ .form-group
+ = f.label :favicon, 'Favicon', class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.favicon?
+ = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :favicon_cache
+ = f.file_field :favicon, class: ''
+ .hint
+ Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}.
+ %br
+ Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.
- %fieldset.sign-in
- %legend
- Sign in/Sign up pages:
- .form-group.row
- = f.label :title, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_field :title, class: "form-control"
- .form-group.row
- = f.label :description, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_area :description, class: "form-control", rows: 10
- .hint
- Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
- .form-group.row
- = f.label :logo, class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.logo?
- = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :logo_cache
- = f.file_field :logo, class: ""
- .hint
- Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
- %fieldset
- %legend
- New project pages:
- .form-group.row
- = f.label :new_project_guidelines, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_area :new_project_guidelines, class: "form-control", rows: 10
- .hint
- Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Sign in/Sign up pages
- .form-actions
- = f.submit 'Save', class: 'btn btn-success append-right-10'
- - if @appearance.persisted?
- Preview last save:
- = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ .col-lg-8
+ .form-group
+ = f.label :title, class: 'col-form-label label-bold'
+ = f.text_field :title, class: "form-control"
+ .form-group
+ = f.label :description, class: 'col-form-label label-bold'
+ = f.text_area :description, class: "form-control", rows: 10
+ .hint
+ Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ .form-group
+ = f.label :logo, class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.logo?
+ = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :logo_cache
+ = f.file_field :logo, class: ""
+ .hint
+ Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
+
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 New project pages
+
+ .col-lg-8
+ .form-group
+ = f.label :new_project_guidelines, class: 'col-form-label label-bold'
+ %p
+ = f.text_area :new_project_guidelines, class: "form-control", rows: 10
+ .hint
+ Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+
+ .prepend-top-default.append-bottom-default
+ = f.submit 'Update appearance settings', class: 'btn btn-success'
+ - if @appearance.persisted?
+ Preview last save:
+ = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- - if @appearance.updated_at
- %span.float-right
- Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
+ - if @appearance.updated_at
+ %span.float-right
+ Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml
index 454b779842c..ccf6f960cf2 100644
--- a/app/views/admin/appearances/show.html.haml
+++ b/app/views/admin/appearances/show.html.haml
@@ -1,9 +1,4 @@
- page_title "Appearance"
-
-%h3.page-title
- Appearance settings
-%p.light
- You can modify the look and feel of GitLab here
-%hr
+- @content_class = "limit-container-width" unless fluid_layout
= render 'form'
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 10bc3452d8b..65a24854583 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -15,7 +15,7 @@
= f.number_field :max_attachment_size, class: 'form-control'
.form-group
= f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light'
- = f.number_field :receive_max_input_size, class: 'form-control'
+ = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field'
.form-group
= f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light'
= f.number_field :session_expire_delay, class: 'form-control'
@@ -46,4 +46,4 @@
= f.label :user_show_add_ssh_key_message, class: 'form-check-label' do
Inform users without uploaded SSH keys that they can't push over SSH until one is added
- = f.submit 'Save changes', class: 'btn btn-success'
+ = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
new file mode 100644
index 00000000000..95d016a94a5
--- /dev/null
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -0,0 +1,11 @@
+= form_for @application_setting, url: admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :first_day_of_week, _('Default first day of the week'), class: 'label-bold'
+ = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control'
+ .form-text.text-muted
+ = _('Default first day of the week in calendars and date pickers.')
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml
index 00000b86ab7..c468d69d7b8 100644
--- a/app/views/admin/application_settings/preferences.html.haml
+++ b/app/views/admin/application_settings/preferences.html.haml
@@ -56,3 +56,14 @@
= _('Configure Gitaly timeouts.')
.settings-content
= render 'gitaly'
+
+%section.settings.as-localization.no-animate#js-localization-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Localization')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Various localization settings.')
+ .settings-content
+ = render 'localization'
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index 65e4723afe6..fc9dd29b8ca 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -13,7 +13,7 @@
.settings-content
= render 'visibility_and_access'
-%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) }
+%section.settings.qa-account-and-limit-settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
= _('Account and limit')
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index b75454b33d7..ec57eb1ed08 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -18,12 +18,12 @@
.table-mobile-content
= link_to runner.short_sha, admin_runner_path(runner)
- .table-section.section-15
+ .table-section.section-20
.table-mobile-header{ role: 'rowheader' }= _('Description')
.table-mobile-content.str-truncated.has-tooltip{ title: runner.description }
= runner.description
- .table-section.section-15
+ .table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Version')
.table-mobile-content.str-truncated.has-tooltip{ title: runner.version }
= runner.version
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index e9e4e0847d3..81380587fd2 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -106,8 +106,8 @@
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type')
.table-section.section-10{ role: 'rowheader' }= _('Runner token')
- .table-section.section-15{ role: 'rowheader' }= _('Description')
- .table-section.section-15{ role: 'rowheader' }= _('Version')
+ .table-section.section-20{ role: 'rowheader' }= _('Description')
+ .table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index a4e2c3252af..be7bfa958b2 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -1,36 +1,37 @@
-%li.flex-row
- .user-avatar
- = image_tag avatar_icon_for_user(user), class: "avatar", alt: ''
- .row-main-content
- .user-name.row-title.str-truncated-100
- = link_to user.name, [:admin, user], class: "js-user-link", data: { user_id: user.id }
- - if user.blocked?
- %span.badge.badge-danger blocked
- - if user.admin?
- %span.badge.badge-success Admin
- - if user.external?
- %span.badge.badge-secondary External
- - if user == current_user
- %span It's you!
- .row-second-line.str-truncated-100
- = mail_to user.email, user.email
- .controls
- = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn'
- - unless user == current_user
- .dropdown.inline
- %a.dropdown-new.btn.btn-default#project-settings-button{ href: '#', data: { toggle: 'dropdown' } }
+.gl-responsive-table-row{ role: 'row' }
+ .table-section.section-40
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Name')
+ .table-mobile-content
+ = render 'user_detail', user: user
+ .table-section.section-25
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Created on')
+ .table-mobile-content
+ = l(user.created_at.to_date, format: :admin)
+ .table-section.section-15
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Last activity')
+ .table-mobile-content
+ = user.last_activity_on.nil? ? _('Never') : l(user.last_activity_on, format: :admin)
+ .table-section.section-20.table-button-footer
+ .table-action-buttons
+ = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default'
+ - unless user == current_user
+ %button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } }
= icon('cog')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
- Settings
+ = _('Settings')
%li
- if user.ldap_blocked?
- %span.small Cannot unblock LDAP blocked users
+ %span.small
+ = s_('AdminUsers|Cannot unblock LDAP blocked users')
- elsif user.blocked?
- = link_to 'Unblock', unblock_admin_user_path(user), method: :put
+ = link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else
- = link_to 'Block', block_admin_user_path(user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put
+ = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put
- if user.access_locked?
%li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
@@ -42,7 +43,7 @@
target: '#delete-user-modal',
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
- username: user.name,
+ username: sanitize_name(user.name),
delete_contributions: false }, type: 'button' }
= s_('AdminUsers|Delete user')
@@ -51,6 +52,6 @@
target: '#delete-user-modal',
delete_user_url: admin_user_path(user, hard_delete: true),
block_user_url: block_admin_user_path(user),
- username: user.name,
+ username: sanitize_name(user.name),
delete_contributions: true }, type: 'button' }
= s_('AdminUsers|Delete user and contributions')
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
new file mode 100644
index 00000000000..3319b4bad3a
--- /dev/null
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -0,0 +1,17 @@
+.flex-list
+ .flex-row
+ = image_tag avatar_icon_for_user(user), class: 'avatar s32 d-none d-md-flex', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
+ .row-main-content
+ .row-title.str-truncated-100
+ = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
+ = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id }
+
+ = render_if_exists 'admin/users/user_detail_note', user: user
+
+ - user_badges_in_admin_section(user).each do |badge|
+ - css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present?
+ %span{ class: css_badge }
+ = badge[:text]
+
+ .row-second-line.str-truncated-100
+ = mail_to user.email, user.email, class: 'text-secondary'
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 600120c4f05..c3d5ce0fe70 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -2,72 +2,78 @@
- page_title "Users"
%div{ class: container_class }
- .prepend-top-default
+ .top-area.scrolling-tabs-container.inner-page-scroll-tabs
+ .fade-left
+ = icon('angle-left')
+ .fade-right
+ = icon('angle-right')
+ %ul.nav-links.nav.nav-tabs.scrolling-tabs
+ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
+ = link_to admin_users_path do
+ = s_('AdminUsers|Active')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.active)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
+ = link_to admin_users_path(filter: "admins") do
+ = s_('AdminUsers|Admins')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
+ = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
+ = link_to admin_users_path(filter: 'two_factor_enabled') do
+ = s_('AdminUsers|2FA Enabled')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
+ = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
+ = link_to admin_users_path(filter: 'two_factor_disabled') do
+ = s_('AdminUsers|2FA Disabled')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
+ = link_to admin_users_path(filter: 'external') do
+ = s_('AdminUsers|External')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.external)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
+ = link_to admin_users_path(filter: "blocked") do
+ = s_('AdminUsers|Blocked')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
+ = link_to admin_users_path(filter: "wop") do
+ = s_('AdminUsers|Without projects')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
+ .nav-controls
+ = render_if_exists 'admin/users/admin_email_users'
+ = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn btn-success btn-search float-right'
+
+ .filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
- if params[:filter].present?
= hidden_field_tag "filter", h(params[:filter])
.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
+ = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false
- if @sort.present?
= hidden_field_tag :sort, @sort
= icon("search", class: "search-icon")
- = button_tag 'Search users' if Rails.env.test?
+ = button_tag s_('AdminUsers|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
+ = s_('AdminUsers|Sort by')
%li
- 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
- .fade-left
- = icon('angle-left')
- .fade-right
- = icon('angle-right')
- %ul.nav-links.nav.nav-tabs.scrolling-tabs
- = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
- = link_to admin_users_path do
- Active
- %small.badge.badge-pill= limited_counter_with_delimiter(User.active)
- = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
- = link_to admin_users_path(filter: "admins") do
- Admins
- %small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
- = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
- = link_to admin_users_path(filter: 'two_factor_enabled') do
- 2FA Enabled
- %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
- = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
- = link_to admin_users_path(filter: 'two_factor_disabled') do
- 2FA Disabled
- %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
- = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
- = link_to admin_users_path(filter: 'external') do
- External
- %small.badge.badge-pill= limited_counter_with_delimiter(User.external)
- = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
- = link_to admin_users_path(filter: "blocked") do
- Blocked
- %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
- = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
- = link_to admin_users_path(filter: "wop") do
- Without projects
- %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
+ - if @users.empty?
+ .nothing-here-block.border-top-0
+ = s_('AdminUsers|No users found')
+ - else
+ .table-holder
+ .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-40{ role: 'rowheader' }= _('Name')
+ .table-section.section-25{ role: 'rowheader' }= _('Created on')
+ .table-section.section-15{ role: 'rowheader' }= _('Last activity')
- %ul.flex-list.content-list
- - if @users.empty?
- %li
- .nothing-here-block No users found.
- - else
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
#delete-user-modal
-
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index a758a63dfb3..8d9c083d223 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -3,7 +3,7 @@
- awards_sort(grouped_emojis).each do |emoji, awards|
%button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
class: [(award_state_class(awardable, awards, current_user))],
- data: { placement: "bottom", title: award_user_list(awards, current_user) } }
+ data: { title: award_user_list(awards, current_user) } }
= emoji_icon(emoji)
%span.award-control-text.js-counter
= awards.count
@@ -12,7 +12,7 @@
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': _('Add reaction'),
- data: { title: _('Add reaction'), placement: "bottom" } }
+ data: { title: _('Add reaction') } }
%span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
%span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
index 4c47e11927e..7acd9ce0562 100644
--- a/app/views/clusters/clusters/_form.html.haml
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -20,12 +20,27 @@
.form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
- else
= text_field_tag :environment_scope, '*', class: 'col-md-6 form-control disabled', placeholder: s_('ClusterIntegration|Environment scope'), disabled: true
- - environment_scope_url = 'https://docs.gitlab.com/ee/user/project/clusters/#setting-the-environment-scope-premium'
+ - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain')
- environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url }
.form-text.text-muted
%code *
= s_("ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe }
+ .form-group
+ %h5= s_('ClusterIntegration|Base domain')
+ = field.text_field :base_domain, class: 'col-md-6 form-control js-select-on-focus'
+ .form-text.text-muted
+ - auto_devops_url = help_page_path('topics/autodevops/index')
+ - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
+ = s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
+ - if @cluster.application_ingress_external_ip.present?
+ = s_('ClusterIntegration|Alternatively')
+ %code #{@cluster.application_ingress_external_ip}.nip.io
+ = s_('ClusterIntegration| can be used instead of a custom domain.')
+ - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-cluster-ip')
+ - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url }
+ = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe }
+
- if can?(current_user, :update_cluster, @cluster)
.form-group
= field.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
index 3d0a1f622a5..ccc3e734276 100644
--- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
@@ -1,5 +1,5 @@
#content
- = email_default_heading("#{@resource.user.name}, you've added an additional email!")
+ = email_default_heading("#{sanitize_name(@resource.user.name)}, you've added an additional email!")
%p Click the link below to confirm your email address (#{@resource.email})
#cta
= link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token)
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 796c0cadda8..f856773526d 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -1,3 +1,5 @@
+- server = local_assigns.fetch(:server)
+
= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
.form-group
= label_tag :username, "#{server['label']} Username"
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 12271ee5adb..1b583ea85d6 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -5,7 +5,7 @@
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'btn d-flex align-items-center omniauth-btn text-left oauth-login qa-saml-login-button', id: "oauth-login-#{provider}" do
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
- if has_icon
= provider_image_tag(provider)
%span
diff --git a/app/views/email_rejection_mailer/rejection.html.haml b/app/views/email_rejection_mailer/rejection.html.haml
index c4ae7befe4e..666dab61f40 100644
--- a/app/views/email_rejection_mailer/rejection.html.haml
+++ b/app/views/email_rejection_mailer/rejection.html.haml
@@ -1,5 +1,5 @@
%p
- Unfortunately, your email message to GitLab could not be processed.
+ = _("Unfortunately, your email message to GitLab could not be processed.")
= markdown @reason
= render_if_exists 'shared/additional_email_text'
diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml
index 0e13b2a6473..8d940ef1293 100644
--- a/app/views/email_rejection_mailer/rejection.text.haml
+++ b/app/views/email_rejection_mailer/rejection.text.haml
@@ -1,4 +1,4 @@
-Unfortunately, your email message to GitLab could not be processed.
+= _("Unfortunately, your email message to GitLab could not be processed.")
\
= @reason
= render_if_exists 'shared/additional_email_text'
diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml
index 6ae4c334f7f..e1b7804c5a7 100644
--- a/app/views/events/_events.html.haml
+++ b/app/views/events/_events.html.haml
@@ -1,4 +1,18 @@
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
+- visitor_empty_message = _('No activities found')
+
- if @events.present?
= render partial: 'events/event', collection: @events
- else
- .nothing-here-block= _("No activities found")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml
index 33a4dab1e00..a4256e23979 100644
--- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_callout.html.haml
@@ -2,12 +2,12 @@
.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
- 'aria-label' => 'Dismiss ConvDev introduction' }
+ 'aria-label' => _('Dismiss ConvDev introduction') }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
.user-callout-copy
%h4
- Introducing Your Conversational Development Index
+ = _('Introducing Your Conversational Development Index')
%p
- Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.
+ = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.')
.svg-container.convdev
= custom_icon('convdev_overview')
diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/conversational_development_index/_card.html.haml
index 57eda06630b..76af55dcf7a 100644
--- a/app/views/instance_statistics/conversational_development_index/_card.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_card.html.haml
@@ -9,11 +9,11 @@
.board-card-score
.board-card-score-value
= format_score(card.instance_score)
- .board-card-score-name You
+ .board-card-score-name= _('You')
.board-card-score
.board-card-score-value
= format_score(card.leader_score)
- .board-card-score-name Lead
+ .board-card-score-name= _('Lead')
.board-card-score-big
= number_to_percentage(card.percentage_score, precision: 1)
.board-card-buttons
diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
index dd795aee135..4e8f34cd574 100644
--- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
@@ -1,7 +1,7 @@
.container.convdev-empty
.col-sm-12.justify-content-center.text-center
= custom_icon('convdev_no_data')
- %h4 Data is still calculating...
+ %h4= _('Data is still calculating...')
%p
- In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.
- = link_to 'Learn more', help_page_path('user/instance_statistics/convdev'), target: '_blank'
+ = _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.')
+ = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank'
diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/conversational_development_index/index.html.haml
index 1e7db4982d6..23f90b876a0 100644
--- a/app/views/instance_statistics/conversational_development_index/index.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/index.html.haml
@@ -17,9 +17,9 @@
%h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
= number_to_percentage(@metric.average_percentage_score, precision: 1)
.convdev-header-subtitle
- index
+ = _('index')
%br
- score
+ = _('score')
= link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev')
.convdev-cards.board-card-container
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 5f15ba87729..2fdd65f639b 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -207,7 +207,7 @@
= _('Settings')
%li.divider.fly-out-top-item
= nav_link(path: 'application_settings#show') do
- = link_to admin_application_settings_path, title: _('General') do
+ = link_to admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
%span
= _('General')
= nav_link(path: 'application_settings#integrations') do
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index bf475c07711..3fbaaafe89e 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -36,7 +36,7 @@
%span
= _('Activity')
- = render_if_exists 'groups/sidebar/security_dashboard'
+ = render_if_exists 'groups/sidebar/security_dashboard' # EE-specific
- if group_sidebar_link?(:contribution_analytics)
= nav_link(path: 'analytics#show') do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 207c08ee5bb..dd7833647b7 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -281,19 +281,34 @@
%strong.fly-out-top-item-name
= _('Registry')
- - if project_nav_tab? :wiki
+ - if project_nav_tab?(:wiki)
+ - wiki_url = project_wiki_path(@project, :home)
= nav_link(controller: :wikis) do
- = link_to get_project_wiki_path(@project), class: 'shortcuts-wiki qa-wiki-link' do
+ = link_to wiki_url, class: 'shortcuts-wiki qa-wiki-link' do
.nav-icon-container
= sprite_icon('book')
%span.nav-item-name
= _('Wiki')
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do
- = link_to get_project_wiki_path(@project) do
+ = link_to wiki_url do
%strong.fly-out-top-item-name
= _('Wiki')
+ - if project_nav_tab?(:external_wiki)
+ - external_wiki_url = @project.external_wiki.external_wiki_url
+ = nav_link do
+ = link_to external_wiki_url, class: 'shortcuts-external_wiki' do
+ .nav-icon-container
+ = sprite_icon('issue-external')
+ %span.nav-item-name
+ = _('External Wiki')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: "fly-out-top-item" } ) do
+ = link_to external_wiki_url do
+ %strong.fly-out-top-item-name
+ = _('External Wiki')
+
- if project_nav_tab? :snippets
= nav_link(controller: :snippets) do
= link_to project_snippets_path(@project), class: 'shortcuts-snippets' do
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index 50209c46ed1..5a67214059c 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -3,7 +3,7 @@
<% discussion = note.discussion if note.part_of_discussion? -%>
<% if discussion && !discussion.individual_note? -%>
-<%= note.author_name -%>
+<%= sanitize_name(note.author_name) -%>
<% if discussion.new_discussion? -%>
<%= " started a new discussion" -%>
<% else -%>
@@ -16,7 +16,7 @@
<% elsif Gitlab::CurrentSettings.email_author_in_body -%>
-<%= "#{note.author_name} commented:" -%>
+<%= "#{sanitize_name(note.author_name)} commented:" -%>
<% end -%>
diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb
index 695780c3145..bf863952478 100644
--- a/app/views/notify/autodevops_disabled_email.text.erb
+++ b/app/views/notify/autodevops_disabled_email.text.erb
@@ -3,7 +3,7 @@ Auto DevOps pipeline was disabled for <%= @project.name %>
The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_url(@pipeline) %>) and has been disabled for <%= @project.name %>. In order to use the Auto DevOps pipeline with your project, please review the currently supported languagues (https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages), adjust your project accordingly, and turn on the Auto DevOps pipeline within your CI/CD project settings (<%= project_settings_ci_cd_url(@project) %>).
<% if @pipeline.user -%>
- Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> )
+ Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml
index b7284dd819b..eb148d72da1 100644
--- a/app/views/notify/closed_issue_email.html.haml
+++ b/app/views/notify/closed_issue_email.html.haml
@@ -1,2 +1,2 @@
%p
- Issue was closed by #{@updated_by.name}
+ Issue was closed by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml
index b35d4b7502d..b1f0a3f37ec 100644
--- a/app/views/notify/closed_issue_email.text.haml
+++ b/app/views/notify/closed_issue_email.text.haml
@@ -1,3 +1,3 @@
-Issue was closed by #{@updated_by.name}
+Issue was closed by #{sanitize_name(@updated_by.name)}
Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)}
diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml
index 44e018304e1..2aa753e0d55 100644
--- a/app/views/notify/closed_merge_request_email.html.haml
+++ b/app/views/notify/closed_merge_request_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name}
+ Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index c4e06cb3cb1..1094d584a1c 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -1,8 +1,8 @@
-Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name}
+Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+Assignee: #{sanitize_name(@merge_request.assignee_name)}
diff --git a/app/views/notify/issue_status_changed_email.html.haml b/app/views/notify/issue_status_changed_email.html.haml
index b6051b11cea..66e73a9b03f 100644
--- a/app/views/notify/issue_status_changed_email.html.haml
+++ b/app/views/notify/issue_status_changed_email.html.haml
@@ -1,2 +1,2 @@
%p
- Issue was #{@issue_status} by #{@updated_by.name}
+ Issue was #{@issue_status} by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb
index 4200881f7e8..f38b09e9820 100644
--- a/app/views/notify/issue_status_changed_email.text.erb
+++ b/app/views/notify/issue_status_changed_email.text.erb
@@ -1,4 +1,4 @@
-Issue was <%= @issue_status %> by <%= @updated_by.name %>
+Issue was <%= @issue_status %> by <%= sanitize_name(@updated_by.name) %>
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb
index 9c5ee0eaf26..ddb4a7b3d2c 100644
--- a/app/views/notify/member_access_requested_email.text.erb
+++ b/app/views/notify/member_access_requested_email.text.erb
@@ -1,3 +1,3 @@
-<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
+<%= sanitize_name(member.user.name) %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= polymorphic_url([member_source, :members]) %>
diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb
index cef87101427..c824533eac2 100644
--- a/app/views/notify/member_invite_accepted_email.text.erb
+++ b/app/views/notify/member_invite_accepted_email.text.erb
@@ -1,3 +1,3 @@
-<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
+<%= member.invite_email %>, now known as <%= sanitize_name(member.user.name) %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= member_source.web_url %>
diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb
index 0a6393355be..d944c3b4a50 100644
--- a/app/views/notify/member_invited_email.text.erb
+++ b/app/views/notify/member_invited_email.text.erb
@@ -1,4 +1,4 @@
-You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>.
+You have been invited <%= "by #{sanitize_name(member.created_by.name)} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>.
Accept invitation: <%= invite_url(@token) %>
Decline invitation: <%= decline_invite_url(@token) %>
diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml
index b487e26b122..ffb416abf72 100644
--- a/app/views/notify/merge_request_status_email.html.haml
+++ b/app/views/notify/merge_request_status_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name}
+ Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index ae2a2933865..b9b9e0c3ad7 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,8 +1,8 @@
-Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name}
+Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+Assignee: #{sanitize_name(@merge_request.assignee_name)}
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index dcdd6db69d6..0c7bf1bb044 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+Assignee: #{sanitize_name(@merge_request.assignee_name)}
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 661c23bcbe2..045a43cbc84 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+Assignee: #{sanitize_name(@merge_request.assignee_name)}
diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml
index 4b9350c4e88..b857705e01f 100644
--- a/app/views/notify/new_gpg_key_email.html.haml
+++ b/app/views/notify/new_gpg_key_email.html.haml
@@ -1,5 +1,5 @@
%p
- Hi #{@user.name}!
+ Hi #{sanitize_name(@user.name)}!
%p
A new GPG key was added to your account:
%p
diff --git a/app/views/notify/new_gpg_key_email.text.erb b/app/views/notify/new_gpg_key_email.text.erb
index 80b5a1fd7ff..92ea851eee4 100644
--- a/app/views/notify/new_gpg_key_email.text.erb
+++ b/app/views/notify/new_gpg_key_email.text.erb
@@ -1,4 +1,4 @@
-Hi <%= @user.name %>!
+Hi <%= sanitize_name(@user.name) %>!
A new GPG key was added to your account:
diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb
index 3c716f77296..58a2bcbe5eb 100644
--- a/app/views/notify/new_issue_email.text.erb
+++ b/app/views/notify/new_issue_email.text.erb
@@ -1,7 +1,7 @@
New Issue was created.
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
-Author: <%= @issue.author_name %>
+Author: <%= sanitize_name(@issue.author_name) %>
Assignee: <%= @issue.assignee_list %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb
index 23213106c5b..173091e4a80 100644
--- a/app/views/notify/new_mention_in_issue_email.text.erb
+++ b/app/views/notify/new_mention_in_issue_email.text.erb
@@ -1,7 +1,7 @@
You have been mentioned in an issue.
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
-Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_list %>
+Author: <%= sanitize_name(@issue.author_name) %>
+Assignee: <%= sanitize_name(@issue.assignee_list) %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 6fcebb22fc4..96a4f3f9eac 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -3,7 +3,7 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %>
<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
<%= merge_path_description(@merge_request, 'to') %>
-Author: <%= @merge_request.author_name %>
-Assignee: <%= @merge_request.assignee_name %>
+Author: <%= sanitize_name(@merge_request.author_name) %>
+Assignee: <%= sanitize_name(@merge_request.assignee_name) %>
<%= @merge_request.description %>
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 5acd45b74a7..db23447dd39 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -7,7 +7,7 @@
- if @merge_request.assignee_id.present?
%p
- Assignee: #{@merge_request.assignee_name}
+ Assignee: #{sanitize_name(@merge_request.assignee_name)}
= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
diff --git a/app/views/notify/new_ssh_key_email.html.haml b/app/views/notify/new_ssh_key_email.html.haml
index 63b0cbbd205..d031842be95 100644
--- a/app/views/notify/new_ssh_key_email.html.haml
+++ b/app/views/notify/new_ssh_key_email.html.haml
@@ -1,5 +1,5 @@
%p
- Hi #{@user.name}!
+ Hi #{sanitize_name(@user.name)}!
%p
A new public key was added to your account:
%p
diff --git a/app/views/notify/new_ssh_key_email.text.erb b/app/views/notify/new_ssh_key_email.text.erb
index 05b551c89a0..690357d69ed 100644
--- a/app/views/notify/new_ssh_key_email.text.erb
+++ b/app/views/notify/new_ssh_key_email.text.erb
@@ -1,4 +1,4 @@
-Hi <%= @user.name %>!
+Hi <%= sanitize_name(@user.name) %>!
A new public key was added to your account:
diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml
index db4424a01f9..dfbb5c75bd3 100644
--- a/app/views/notify/new_user_email.html.haml
+++ b/app/views/notify/new_user_email.html.haml
@@ -1,5 +1,5 @@
%p
- Hi #{@user['name']}!
+ Hi #{sanitize_name(@user['name'])}!
%p
- if Gitlab::CurrentSettings.allow_signup?
Your account has been created successfully.
diff --git a/app/views/notify/new_user_email.text.erb b/app/views/notify/new_user_email.text.erb
index dd9b71e3b84..f3f20f3bfba 100644
--- a/app/views/notify/new_user_email.text.erb
+++ b/app/views/notify/new_user_email.text.erb
@@ -1,4 +1,4 @@
-Hi <%= @user.name %>!
+Hi <%= sanitize_name(@user.name) %>!
The Administrator created an account for you. Now you are a member of the company GitLab application.
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index 294238eee51..722eedf90be 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -10,20 +10,20 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> )
Commit Message: <%= @pipeline.git_commit_message.truncate(50) %>
<% commit = @pipeline.commit -%>
<% if commit.author -%>
-Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> )
+Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> )
<% else -%>
Commit Author: <%= commit.author_name %>
<% end -%>
<% if commit.different_committer? -%>
<% if commit.committer -%>
-Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> )
+Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> )
<% else -%>
Committed by: <%= commit.committer_name %>
<% end -%>
<% end -%>
<% if @pipeline.user -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> )
+Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb
index 39622cf7f02..9aadf380f79 100644
--- a/app/views/notify/pipeline_success_email.text.erb
+++ b/app/views/notify/pipeline_success_email.text.erb
@@ -10,13 +10,13 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> )
Commit Message: <%= @pipeline.git_commit_message.truncate(50) %>
<% commit = @pipeline.commit -%>
<% if commit.author -%>
-Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> )
+Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> )
<% else -%>
Commit Author: <%= commit.author_name %>
<% end -%>
<% if commit.different_committer? -%>
<% if commit.committer -%>
-Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> )
+Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> )
<% else -%>
Committed by: <%= commit.committer_name %>
<% end -%>
@@ -25,7 +25,7 @@ Committed by: <%= commit.committer_name %>
<% job_count = @pipeline.total_size -%>
<% stage_count = @pipeline.stages_count -%>
<% if @pipeline.user -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> )
+Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml
index 67744ec1cee..97258833cfc 100644
--- a/app/views/notify/push_to_merge_request_email.html.haml
+++ b/app/views/notify/push_to_merge_request_email.html.haml
@@ -1,5 +1,5 @@
%h3
- = @updated_by_user.name
+ = sanitize_name(@updated_by_user.name)
pushed new commits to merge request
= link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request))
diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml
index 95759d127e2..10c8e158846 100644
--- a/app/views/notify/push_to_merge_request_email.text.haml
+++ b/app/views/notify/push_to_merge_request_email.text.haml
@@ -1,4 +1,4 @@
-#{@updated_by_user.name} pushed new commits to merge request #{@merge_request.to_reference}
+#{sanitize_name(@updated_by_user.name)} pushed new commits to merge request #{@merge_request.to_reference}
\
#{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))}
\
diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml
index ee2f40e1683..6d25488a7e2 100644
--- a/app/views/notify/reassigned_issue_email.html.haml
+++ b/app/views/notify/reassigned_issue_email.html.haml
@@ -2,7 +2,7 @@
Assignee changed
- if @previous_assignees.any?
from
- %strong= @previous_assignees.map(&:name).to_sentence
+ %strong= sanitize_name(@previous_assignees.map(&:name).to_sentence)
to
- if @issue.assignees.any?
%strong= @issue.assignee_list
diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb
index 6c357f1074a..7bf2e8e6ce3 100644
--- a/app/views/notify/reassigned_issue_email.text.erb
+++ b/app/views/notify/reassigned_issue_email.text.erb
@@ -2,5 +2,5 @@ Reassigned Issue <%= @issue.iid %>
<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %>
-Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%>
+Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml
index 24c2b08810b..e4f19bc3200 100644
--- a/app/views/notify/reassigned_merge_request_email.html.haml
+++ b/app/views/notify/reassigned_merge_request_email.html.haml
@@ -2,9 +2,9 @@
Assignee changed
- if @previous_assignee
from
- %strong= @previous_assignee.name
+ %strong= sanitize_name(@previous_assignee.name)
to
- if @merge_request.assignee_id
- %strong= @merge_request.assignee_name
+ %strong= sanitize_name(@merge_request.assignee_name)
- else
%strong Unassigned
diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb
index 998a40fefde..96c770b5219 100644
--- a/app/views/notify/reassigned_merge_request_email.text.erb
+++ b/app/views/notify/reassigned_merge_request_email.text.erb
@@ -2,5 +2,5 @@ Reassigned Merge Request <%= @merge_request.iid %>
<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %>
-Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
- to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %>
+Assignee changed <%= "from #{sanitize_name(@previous_assignee.name)}" if @previous_assignee -%>
+ to <%= "#{@merge_request.assignee_id ? sanitize_name(@merge_request.assignee_name) : 'Unassigned'}" %>
diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml
index 522421b7cc3..502b8f21e35 100644
--- a/app/views/notify/resolved_all_discussions_email.html.haml
+++ b/app/views/notify/resolved_all_discussions_email.html.haml
@@ -1,2 +1,2 @@
%p
- All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name}
+ All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{sanitize_name(@resolved_by.name)}
diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb
index 2881f3e699e..c4b36bfe1a8 100644
--- a/app/views/notify/resolved_all_discussions_email.text.erb
+++ b/app/views/notify/resolved_all_discussions_email.text.erb
@@ -1,3 +1,3 @@
-All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
+All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= sanitize_name(@resolved_by.name) %>
<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 7c378633667..1a9aca1f6bf 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -51,11 +51,30 @@
= f.label :dashboard, class: 'label-bold' do
Default dashboard
= f.select :dashboard, dashboard_choices, {}, class: 'form-control'
+
+ = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
+
.form-group
= f.label :project_view, class: 'label-bold' do
Project overview content
= f.select :project_view, project_view_choices, {}, class: 'form-control'
.form-text.text-muted
Choose what content you want to see on a project’s overview page.
+
+ .col-sm-12
+ %hr
+
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ = _('Localization')
+ %p
+ = _('Customize language and region related settings.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank'
+ .col-lg-8
+ .form-group
+ = f.label :first_day_of_week, class: 'label-bold' do
+ = _('First day of the week')
+ = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control'
.form-group
- = f.submit 'Save changes', class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 2629b374e7c..753316b27e2 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -2,7 +2,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
-= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@user)
.row
@@ -77,10 +77,10 @@
.col-lg-8
.row
- if @user.read_only_attribute?(:name)
- = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' },
+ = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' },
help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) }
- else
- = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
+ = f.text_field :name, label: 'Full name', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
= f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
- if @user.read_only_attribute?(:email)
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 7694217eb28..0be41b5888c 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -21,9 +21,14 @@
- if @project.tag_list.present?
%span.home-panel-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil }
= sprite_icon('tag', size: 16, css_class: 'icon append-right-4')
- = @project.topics_to_show
+
+ - @project.topics_to_show.each do |topic|
+ %a{ class: 'badge badge-pill badge-secondary append-right-5 str-truncated-30', href: explore_projects_path(tag: topic) }
+ = topic.titleize
+
- if @project.has_extra_topics?
- = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown }
+ .text-nowrap
+ = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown }
.project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 45e1d32980c..de4653dad2c 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -2,7 +2,7 @@
%div{ class: container_class }
.prepend-top-default.append-bottom-default
.wiki
- = render_wiki_content(@wiki_home, legacy_render_context(params))
+ = render_wiki_content(@wiki_home)
- else
- can_create_wiki = can?(current_user, :create_wiki, @project)
.landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] }
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 3f2d96b70e5..4520cca8cf5 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -21,7 +21,7 @@
Write
%li
- = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do
+ = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do
= editing_preview_title(@blob.name)
= form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths(@project)) do
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index ff460a3831c..66687f087ff 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -2,7 +2,7 @@
.diff-content
- if markup?(@blob.name)
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
- = markup(@blob.name, @content, legacy_render_context(params))
+ = markup(@blob.name, @content)
- else
.file-content.code.js-syntax-highlight
- unless @diff_lines.empty?
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index 6edbfd91b21..1a77eb078be 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -1,6 +1,4 @@
- blob = viewer.blob
-- context = legacy_render_context(params)
-- unless context[:markdown_engine] == :redcarpet
- - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
+- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {}
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml
index d8492abc638..c2329a7aa66 100644
--- a/app/views/projects/blob/viewers/_readme.html.haml
+++ b/app/views/projects/blob/viewers/_readme.html.haml
@@ -1,4 +1,4 @@
= icon('info-circle fw')
= succeed '.' do
To learn more about this project, read
- = link_to "the wiki", get_project_wiki_path(viewer.project)
+ = link_to "the wiki", project_wiki_path(viewer.project, :home)
diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml
index f6666921a25..8b6e3e42ea1 100644
--- a/app/views/projects/commit/_ci_menu.html.haml
+++ b/app/views/projects/commit/_ci_menu.html.haml
@@ -1,9 +1,11 @@
+- any_pipelines = @commit.present(current_user: current_user).any_pipelines?
+
%ul.nav-links.no-top.no-bottom.commit-ci-menu.nav.nav-tabs
= nav_link(path: 'commit#show') do
= link_to project_commit_path(@project, @commit.id) do
Changes
%span.badge.badge-pill= @diffs.size
- - if can?(current_user, :read_pipeline, @project)
+ - if any_pipelines
= nav_link(path: 'commit#pipelines') do
= link_to pipelines_project_commit_path(@project, @commit.id) do
Pipelines
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index a389261136a..90fee2d70be 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -74,8 +74,8 @@
%span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) }
= icon('spinner spin')
- - if @commit.last_pipeline
- - last_pipeline = @commit.last_pipeline
+ - last_pipeline = @commit.last_pipeline
+ - if can?(current_user, :read_pipeline, last_pipeline)
.well-segment.pipeline-info
.status-icon-container
= link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 79e32949db9..fe9a8ac4182 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -9,11 +9,8 @@
.container-fluid{ class: [limited_container_width, container_class] }
= render "commit_box"
- - if @commit.status
- = render "ci_menu"
- - else
- .block-connector
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, is_commit: true
+ = render "ci_menu"
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-commit"
.limited-width-notes
= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 1a74b120c26..0d3c6e7027c 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -6,6 +6,7 @@
- merge_request = local_assigns.fetch(:merge_request, nil)
- project = local_assigns.fetch(:project) { merge_request&.project }
- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
+- commit_status = commit.present(current_user: current_user).status_for(ref)
- link = commit_path(project, commit, merge_request: merge_request)
%li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" }
@@ -22,7 +23,7 @@
%span.commit-row-message.d-block.d-sm-none
&middot;
= commit.short_id
- - if commit.status(ref)
+ - if commit_status
.d-block.d-sm-none
= render_commit_status(commit, ref: ref)
- if commit.description?
@@ -45,7 +46,7 @@
- else
= render partial: 'projects/commit/ajax_signature', locals: { commit: commit }
- - if commit.status(ref)
+ - if commit_status
= render_commit_status(commit, ref: ref)
.js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index b6bebbabed0..5774b48a054 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -8,7 +8,7 @@
- if @commits.present?
= render "projects/commits/commit_list"
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-compare"
- else
.card.bg-light
.center
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index cc2d0d3b2d8..2dba3fcd664 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -2,7 +2,7 @@
- show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true)
- can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project)
- diff_files = diffs.diff_files
-- is_commit = local_assigns.fetch(:is_commit, false)
+- diff_page_context = local_assigns.fetch(:diff_page_context, nil)
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
.files-changed-inner
@@ -25,4 +25,4 @@
= render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } }
- = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, is_commit: is_commit }
+ = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context }
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 5565ae1d98b..855b719dc45 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -1,11 +1,11 @@
- environment = local_assigns.fetch(:environment, nil)
-- is_commit = local_assigns.fetch(:is_commit, false)
+- diff_page_context = local_assigns.fetch(:diff_page_context, nil)
- file_hash = hexdigest(diff_file.file_path)
- image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image'
- image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha
.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) }
- .js-file-title.file-title-flex-parent{ class: is_commit ? "is-commit" : "" }
+ .js-file-title.file-title-flex-parent{ class: diff_page_context }
.file-header-content
= render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}"
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index d66de7ab698..99cbbc11acd 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -2,7 +2,6 @@
- page_title _("Environments")
#environments-list-view{ data: { environments_data: environments_list_data,
- "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
"can-create-environment" => can?(current_user, :create_environment, @project).to_s,
"new-environment-path" => new_project_environment_path(@project),
diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml
index 1e4e9450ffa..1be1087b36f 100644
--- a/app/views/projects/issues/_form.html.haml
+++ b/app/views/projects/issues/_form.html.haml
@@ -1,4 +1,3 @@
= form_for [@project.namespace.becomes(Namespace), @project, @issue],
- html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' },
- data: { markdown_version: @issue.cached_markdown_version } do |f|
+ html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f|
= render 'shared/issuable/form', f: f, issuable: @issue
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
index c73d167303f..310e339ac8d 100644
--- a/app/views/projects/issues/_merge_requests.html.haml
+++ b/app/views/projects/issues/_merge_requests.html.haml
@@ -12,6 +12,7 @@
%ul.content-list.related-items-list
- has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id)
- @merge_requests.each do |merge_request|
+ - merge_request = merge_request.present(current_user: current_user)
%li.list-item.py-0.px-0
.item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3
.item-contents
@@ -25,7 +26,7 @@
= merge_request.target_project.full_path
= merge_request.to_reference
%span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2
- - if merge_request.head_pipeline
+ - if merge_request.can_read_pipeline?
= render_pipeline_status(merge_request.head_pipeline, tooltip_placement: 'bottom')
- elsif has_any_head_pipeline
= icon('blank fw')
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 1df38db9fd4..ffdd96870ef 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -6,7 +6,7 @@
%li
- target = @project.repository.find_branch(branch).dereferenced_target
- pipeline = @project.pipeline_for(branch, target.sha) if target
- - if pipeline
+ - if can?(current_user, :read_pipeline, pipeline)
%span.related-branch-ci-status
= render_pipeline_status(pipeline)
%span.related-branch-info
diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml
index 13b967beba1..a7c9e54506d 100644
--- a/app/views/projects/merge_requests/_form.html.haml
+++ b/app/views/projects/merge_requests/_form.html.haml
@@ -1,4 +1,3 @@
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request],
- html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' },
- data: { markdown_version: @merge_request.cached_markdown_version } do |f|
+ html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 02d2dbf0d61..ac29cd8f679 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -46,7 +46,7 @@
%li.issuable-status.d-none.d-sm-inline-block
= icon('ban')
CLOSED
- - if merge_request.head_pipeline
+ - if can?(current_user, :read_pipeline, merge_request.head_pipeline)
%li.issuable-pipeline-status.d-none.d-sm-inline-block
= render_pipeline_status(merge_request.head_pipeline)
- if merge_request.open? && merge_request.broken?
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index d6f340d0ee2..5111c9fab8d 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -7,7 +7,7 @@
- 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) } }
+.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), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
@@ -59,6 +59,7 @@
#js-vue-discussion-counter
.tab-content#diff-notes-app
+ #js-diff-file-finder
#notes.notes.tab-pane.voting_notes
.row
%section.col-md-12
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 19f5bba75c4..5cc6b5a173b 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,6 +1,5 @@
= form_for [@project.namespace.becomes(Namespace), @project, @milestone],
- html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' },
- data: { markdown_version: @milestone.cached_markdown_version } do |f|
+ html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone)
.row
.col-md-6
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 8275996b522..ff7c36c2d5b 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -16,6 +16,9 @@
= _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link }
%p
= _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.')
+ %p
+ - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank'
+ = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide }
.md
= brand_new_project_guidelines
%p
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 0f0114d513c..69a47faabed 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -6,23 +6,22 @@
= preserve(markdown(commit.description, pipeline: :single_line))
.info-well
- - if commit.status
- .well-segment.pipeline-info
- .icon-container
- = icon('clock-o')
- = pluralize @pipeline.total_size, "job"
- - if @pipeline.ref
- from
- - if @pipeline.ref_exists?
- = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
- - else
- %span.ref-name
- = @pipeline.ref
- - if @pipeline.duration
- in
- = time_interval_in_words(@pipeline.duration)
- - if @pipeline.queued_duration
- = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
+ .well-segment.pipeline-info
+ .icon-container
+ = icon('clock-o')
+ = pluralize @pipeline.total_size, "job"
+ - if @pipeline.ref
+ from
+ - if @pipeline.ref_exists?
+ = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
+ - else
+ %span.ref-name
+ = @pipeline.ref
+ - if @pipeline.duration
+ in
+ = time_interval_in_words(@pipeline.duration)
+ - if @pipeline.queued_duration
+ = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
.well-segment
.icon-container
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 2a0ce4bd16b..6159f1c3542 100644
--- a/app/views/projects/project_templates/_built_in_templates.html.haml
+++ b/app/views/projects/project_templates/_built_in_templates.html.haml
@@ -1,7 +1,7 @@
- Gitlab::ProjectTemplate.all.each do |template|
.template-option.d-flex.align-items-center
- .logo.append-right-10
- = custom_icon(template.logo, size: 40)
+ .logo.append-right-10.px-1
+ = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}"
.description
%strong
= template.title
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
index 9d4574c4590..6aafa85e99a 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -8,3 +8,5 @@
.col-lg-9
= render 'projects/services/prometheus/metrics', project: @project
+
+= render_if_exists 'projects/services/prometheus/external_alerts', project: @project
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 5ec5a06396e..8c4d1c32ebe 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -4,10 +4,6 @@
= form_errors(@project)
%fieldset.builds-feature.js-auto-devops-settings
.form-group
- - message = auto_devops_warning_message(@project)
- - if message
- %p.auto-devops-warning-message.settings-message.text-center
- = message.html_safe
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
.card.auto-devops-card
.card-body
@@ -21,19 +17,12 @@
= s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
= link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
.card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' }
- = form.label :domain do
- %strong= _('Domain')
- = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
- .form-text.text-muted
- = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.')
- - if cluster_ingress_ip = cluster_ingress_ip(@project)
- = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe }
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank'
-
+ %p.settings-message.text-center
+ - kubernetes_cluster_link = help_page_path('user/project/clusters/index')
+ - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link }
+ = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe }
%label.prepend-top-10
%strong= s_('CICD|Deployment strategy')
- %p.settings-message.text-center
- = s_('CICD|Deployment strategy needs a domain name to work correctly.')
.form-check
= form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input'
= form.label :deploy_strategy_continuous, class: 'form-check-label' do
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index bb328f5344c..bfb275b9ef5 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -110,6 +110,9 @@
%li
go test -cover (Go)
%code coverage: \d+.\d+% of statements
+ %li
+ nyc npm test (NodeJS) -
+ %code All files[^|]*\|[^|]*\s+([\d\.]+)
= f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index 52c6c7ec424..e4efeed04f0 100644
--- a/app/views/projects/tags/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
@@ -12,8 +12,7 @@
= form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name),
- html: { class: 'common-note-form release-form js-quick-submit' },
- data: { markdown_version: @release.cached_markdown_version }) do |f|
+ html: { class: 'common-note-form release-form js-quick-submit' }) do |f|
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…"
= render 'shared/notes/hints'
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 7e4618e1a88..6f6f1e5e0c5 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -1,6 +1,6 @@
%tr
%td
- - if can?(current_user, :admin_trigger, trigger)
+ - if trigger.has_token_exposed?
%span= trigger.token
= clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard")
- else
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index 7d8826e540c..5bb69563b51 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -1,13 +1,9 @@
- commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}")
- commit_message = commit_message % { page_title: @page.title }
-- if params[:legacy_render] || !commonmark_for_repositories_enabled?
- - markdown_version = CacheMarkdownField::CACHE_REDCARPET_VERSION
-- else
- - markdown_version = 0
= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post,
html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' },
- data: { markdown_version: markdown_version, uploads_path: uploads_path } do |f|
+ data: { uploads_path: uploads_path } do |f|
= form_errors(@page)
- if @page.persisted?
@@ -16,7 +12,7 @@
.form-group.row
.col-sm-12= f.label :title, class: 'control-label-full-width'
.col-sm-12
- = f.text_field :title, class: 'form-control', value: @page.title
+ = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title
- if @page.persisted?
%span.edit-wiki-page-slug-tip
= icon('lightbulb-o')
@@ -31,7 +27,7 @@
.col-sm-12= f.label :content, class: 'control-label-full-width'
.col-sm-12
= render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do
- = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here…")
+ = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…")
= render 'shared/notes/hints'
.clearfix
@@ -47,14 +43,14 @@
.form-group.row
.col-sm-12= f.label :commit_message, class: 'control-label-full-width'
- .col-sm-12= f.text_field :message, class: 'form-control', rows: 18, value: commit_message
+ .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: commit_message
.form-actions
- if @page && @page.persisted?
- = f.submit _("Save changes"), class: 'btn-success btn'
+ = f.submit _("Save changes"), class: 'btn-success btn qa-save-changes-button'
.float-right
= link_to _("Cancel"), project_wiki_path(@project, @page), class: 'btn btn-cancel btn-grouped'
- else
- = f.submit s_("Wiki|Create page"), class: 'btn-success btn'
+ = f.submit s_("Wiki|Create page"), class: 'btn-success btn qa-create-page-button'
.float-right
= link_to _("Cancel"), project_wiki_path(@project, :home), class: 'btn btn-cancel'
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml
index 02c5a6ea55c..28353927135 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -12,7 +12,7 @@
.blocks-container
.block.block-first
- if @sidebar_page
- = render_wiki_content(@sidebar_page, legacy_render_context(params))
+ = render_wiki_content(@sidebar_page)
- else
%ul.wiki-pages
= render @sidebar_wiki_entries, context: 'sidebar'
diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml
index aeef64fd7eb..94267b6e0cf 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/projects/wikis/pages.html.haml
@@ -1,5 +1,5 @@
- @no_container = true
-- add_to_breadcrumbs "Wiki", get_project_wiki_path(@project)
+- add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home)
- breadcrumb_title s_("Wiki|Pages")
- page_title s_("Wiki|Pages"), _("Wiki")
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index 4d5fd55364c..8e1c054b50c 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -2,7 +2,7 @@
- breadcrumb_title @page.human_title
- wiki_breadcrumb_dropdown_links(@page.slug)
- page_title @page.human_title, _("Wiki")
-- add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project)
+- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, :home)
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
@@ -27,6 +27,6 @@
.prepend-top-default.append-bottom-default
.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
- = render_wiki_content(@page, legacy_render_context(params))
+ = render_wiki_content(@page)
= render 'sidebar'
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index e0130f9a4b5..a60a4501557 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -21,7 +21,7 @@
.file-content.wiki
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
- = markup(snippet.file_name, chunk[:data], legacy_render_context(params))
+ = markup(snippet.file_name, chunk[:data])
- else
.file-content.code
.nothing-here-block= _("Empty file")
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
new file mode 100644
index 00000000000..6da40e1b059
--- /dev/null
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -0,0 +1,19 @@
+- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil)
+- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil)
+
+.nothing-here-block
+ .svg-content
+ = image_tag illustration_path, size: '75'
+ .text-content
+ - if user_profile? and current_user.present? and current_user.username == params[:username]
+ %h5= current_user_empty_message_header
+
+ - if current_user_empty_message_description.present?
+ %p= current_user_empty_message_description
+
+ - if secondary_button_link.present?
+ = link_to secondary_button_label, secondary_button_link, class: 'btn btn-create btn-inverted'
+
+ = link_to primary_button_label, primary_button_link, class: 'btn btn-success'
+ - else
+ %h5= visitor_empty_message
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index df3308abe0d..73eedcc1dc9 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -2,7 +2,7 @@
- if can?(current_user, :create_wiki, @project)
- create_path = project_wiki_path(@project, params[:id], { view: 'create' })
- - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success', title: s_('WikiEmpty|Create your first page')
+ - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success qa-create-first-page-link', title: s_('WikiEmpty|Create your first page')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
%h4.text-left
diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml
index a5f100e3469..d44017299b8 100644
--- a/app/views/shared/empty_states/_wikis_layout.html.haml
+++ b/app/views/shared/empty_states/_wikis_layout.html.haml
@@ -1,6 +1,6 @@
.row.empty-state
.col-12
- .svg-content
+ .svg-content.qa-svg-content
= image_tag image_path
.col-12
.text-content.text-center
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index f50a6bd4d6a..c5b39c7db08 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -1,3 +1,10 @@
+- illustration_path = 'illustrations/profile-page/groups.svg'
+- current_user_empty_message_header = s_('UserProfile|You can create a group for several dependent projects.')
+- current_user_empty_message_description = s_('UserProfile|Groups are the best way to manage projects and members.')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- visitor_empty_message = s_('GroupsEmptyState|No groups found')
+
- if groups.any?
- user = local_assigns[:user]
@@ -5,4 +12,9 @@
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group, user: user
- else
- .nothing-here-block= s_("GroupsEmptyState|No groups found")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/shared/icons/_express.svg b/app/views/shared/icons/_express.svg
deleted file mode 100644
index a51e81e5568..00000000000
--- a/app/views/shared/icons/_express.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="27" height="32" viewBox="0 0 27 32" class="btn-template-icon icon-node-express"><g fill="none" fill-rule="evenodd"><path d="M-3 0h32v32H-3z"/><path fill="#353535" d="M1.192 16.267c.04 2.065.288 3.982.745 5.75.456 1.767 1.16 3.307 2.115 4.618.953 1.31 2.185 2.343 3.694 3.098 1.51.755 3.357 1.132 5.54 1.132 3.22 0 5.89-.844 8.016-2.532 2.125-1.69 3.446-4.22 3.962-7.597h1.192c-.437 3.575-1.847 6.345-4.23 8.312-2.384 1.966-5.324 2.95-8.82 2.95-2.383.04-4.42-.338-6.107-1.133-1.69-.794-3.07-1.917-4.142-3.367-1.073-1.45-1.867-3.158-2.383-5.124C.258 20.408 0 18.294 0 16.028c0-2.542.377-4.806 1.132-6.792C1.887 7.25 2.88 5.57 4.112 4.2 5.34 2.83 6.77 1.79 8.4 1.074 10.03.358 11.698 0 13.406 0c2.383 0 4.44.457 6.167 1.37 1.728.914 3.138 2.126 4.23 3.635 1.093 1.51 1.887 3.238 2.384 5.184.496 1.945.705 3.97.625 6.077H1.193zm24.43-1.192c0-1.867-.26-3.645-.775-5.333-.516-1.688-1.28-3.168-2.294-4.44-1.013-1.27-2.274-2.273-3.784-3.008-1.51-.735-3.258-1.102-5.244-1.102-1.67 0-3.228.317-4.678.953-1.45.636-2.72 1.56-3.813 2.77-1.092 1.212-1.976 2.672-2.652 4.38-.675 1.708-1.072 3.635-1.19 5.78h24.43z"/></g></svg>
diff --git a/app/views/shared/icons/_rails.svg b/app/views/shared/icons/_rails.svg
deleted file mode 100644
index 852bd183cc7..00000000000
--- a/app/views/shared/icons/_rails.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20" viewBox="0 0 32 20" class="btn-template-icon icon-rails"><g fill="none" fill-rule="evenodd"><path d="M0-6h32v32H0z"/><path fill="#c00" fill-rule="nonzero" d="M.985 19.636s.422-4.163 3.375-9.087c2.954-4.924 7.99-8.65 12.083-9.017 8.144-.816 15.46 6.485 15.46 6.485s-.24.168-.494.38C23.42 2.49 18.54 5.274 17.005 6.02c-7.033 3.925-4.91 13.616-4.91 13.616H.987zM24.137 2.32c-.45-.182-.9-.35-1.364-.505l.056-.93c.885.254 1.237.423 1.363.493l-.056.943zM22.8 5.304c.45.028.915.084 1.393.183l-.056.872c-.464-.1-.928-.155-1.392-.17l.056-.885zM17.597.913c-.407 0-.815.015-1.223.058l-.268-.83c.465-.056.915-.084 1.35-.084l.282.858h-.14zm.676 5.178c.35-.154.76-.31 1.237-.45l.31.93c-.41.125-.817.294-1.225.49l-.323-.97zm-6.386-3.7c-.366.184-.718.395-1.083.62l-.647-.985c.38-.225.745-.42 1.097-.604l.633.97zm2.883 6.33c.252-.323.548-.646.87-.942l.634.957c-.31.323-.59.647-.83 1L14.77 8.72zm-2.04 4.53c.112-.506.24-1.027.422-1.547l1.012.802c-.14.548-.24 1.097-.295 1.645l-1.14-.9zM6.57 6.57c-.34.35-.662.73-.958 1.11L4.53 6.752c.323-.352.674-.704 1.04-1.055l1 .872zm-4.25 6.286c-.224.52-.52 1.21-.702 1.688L0 13.954c.14-.38.436-1.084.703-1.69l1.618.592zm10.2 3.967l1.518.548c.084.663.21 1.28.337 1.83l-1.688-.605c-.07-.422-.14-1.027-.168-1.772z"/></g></svg>
diff --git a/app/views/shared/icons/_spring.svg b/app/views/shared/icons/_spring.svg
deleted file mode 100644
index ccf18749029..00000000000
--- a/app/views/shared/icons/_spring.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" class="btn-template-icon icon-java-spring"><g fill="none" fill-rule="evenodd"><path d="M0 0h32v32H0z"/><path fill="#70AD51" d="M5.466 27.993c.586.473 1.446.385 1.918-.202.475-.585.386-1.445-.2-1.92-.585-.474-1.444-.383-1.92.202-.45.555-.392 1.356.115 1.844l-.266-.234C1.972 24.762 0 20.597 0 15.978 0 7.168 7.168 0 15.98 0c4.48 0 8.53 1.857 11.435 4.836.66-.898 1.232-1.902 1.7-3.015 2.036 6.118 3.233 11.26 2.795 15.31-.592 8.274-7.508 14.83-15.93 14.83-3.912 0-7.496-1.416-10.276-3.757l-.238-.21zm23.58-4.982c4.01-5.336 1.775-13.965-.085-19.48-1.657 3.453-5.738 6.094-9.262 6.93-3.303.788-6.226.142-9.283 1.318-6.97 2.68-6.86 10.992-3.02 12.86.002 0 .23.124.227.12 0-.002 5.644-1.122 8.764-2.274 4.56-1.684 9.566-5.835 11.213-10.657-.877 5.015-5.182 9.84-9.507 12.056-2.302 1.182-4.092 1.445-7.88 2.756-.464.158-.828.314-.828.314.96-.16 1.917-.212 1.917-.212 5.393-.255 13.807 1.516 17.745-3.73z"/></g></svg>
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index cb5c64fb91d..41d6ae79c81 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -54,7 +54,7 @@
.note-text.md
= markdown_field(note, :note)
= edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago')
- .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore, markdown_version: note.cached_markdown_version } }
+ .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
#{note.note}
- if note_editable
= render 'shared/notes/edit', note: note
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 7d90d9ca4a5..13847cd9be1 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -14,6 +14,17 @@
- 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?}"
+- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg'
+- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
+- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
+- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
+- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
+- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
+- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects')
+- primary_button_label = _('New project')
+- primary_button_link = new_project_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
.js-projects-list-holder
- if any_projects?(projects)
@@ -33,9 +44,18 @@
%span &nbsp;you have no access to.
= paginate_collection(projects, remote: remote) unless skip_pagination
- else
- .nothing-here-block
- .svg-content.svg-130
- = image_tag 'illustrations/profile-page/personal-project.svg'
- %div
- %span
- = s_('UserProfile|This user doesn\'t have any personal projects')
+ - if @contributed_projects
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path,
+ current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: contributed_projects_visitor_empty_message }
+ - else
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path,
+ current_user_empty_message_header: own_projects_current_user_empty_message_header,
+ current_user_empty_message_description: own_projects_current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: own_projects_visitor_empty_message }
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index fea7e17be3d..df17ae95e2a 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -12,21 +12,20 @@
- 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"
+- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between"
%li.project-row.d-flex{ class: css_class }
= cache(cache_key) do
- if avatar
- .avatar-container.s64.flex-grow-0.flex-shrink-0
+ .avatar-container.s48.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, 64), class: "avatar s65", alt:''
+ = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s65", alt:''
- else
- = 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
+ = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48)
+ .project-details.d-sm-flex.flex-sm-fill.align-items-center
+ .flex-wrapper
+ .d-flex.align-items-center.flex-wrap.project-title
%h2.d-flex.prepend-top-8
= link_to project_path(project), class: 'text-plain' do
%span.project-full-name.append-right-8><
@@ -52,13 +51,13 @@
%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
+ .description.d-none.d-sm-block.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
+ .description.d-none.d-sm-block.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 }
+ .controls.d-flex.flex-sm-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
@@ -74,34 +73,18 @@
= number_with_delimiter(project.forks_count)
- if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
= link_to project_merge_requests_path(project),
- class: "d-none d-lg-flex align-items-center icon-wrapper merge-requests has-tooltip",
+ class: "d-none d-xl-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?(disabled: !issues, compact_mode: compact_mode)
= link_to project_issues_path(project),
- class: "d-none d-lg-flex align-items-center icon-wrapper issues has-tooltip",
+ class: "d-none d-xl-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?
+ - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
%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/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index cf9c3055499..3007da0c189 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -3,8 +3,7 @@
.snippet-form-holder
= form_for @snippet, url: url,
- html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
- data: { markdown_version: @snippet.cached_markdown_version } do |f|
+ html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
.form-group.row
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index 69d41f8fe5e..dab247da251 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,10 +1,21 @@
- link_project = local_assigns.fetch(:link_project, false)
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.')
+- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.')
+- primary_button_label = _('New snippet')
+- primary_button_link = new_snippet_path
+- visitor_empty_message = s_('UserProfile|No snippets found.')
.snippets-list-holder
%ul.content-list
= render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project }
- if @snippets.empty?
%li
- .nothing-here-block= _("Nothing here.")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
= paginate @snippets, theme: 'gitlab'
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 85c123c2704..410411b1294 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -23,6 +23,7 @@
- cronjob:prune_web_hook_logs
- gcp_cluster:cluster_install_app
+- gcp_cluster:cluster_upgrade_app
- gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:wait_for_cluster_creation
diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb
new file mode 100644
index 00000000000..d1a538859b4
--- /dev/null
+++ b/app/workers/cluster_upgrade_app_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ClusterUpgradeAppWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::UpgradeService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
index c8ccaf0c487..421fbf04e28 100644
--- a/app/workers/mail_scheduler/notification_service_worker.rb
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -23,7 +23,7 @@ module MailScheduler
end
def self.perform_async(*args)
- super(*ActiveJob::Arguments.serialize(args))
+ super(*Arguments.serialize(args))
end
private
@@ -38,5 +38,34 @@ module MailScheduler
end
end
end
+
+ # Permit ActionController::Parameters for serializable Hash
+ #
+ # Port of
+ # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663
+ module Arguments
+ include ActiveJob::Arguments
+ extend self
+
+ private
+
+ def serialize_argument(argument)
+ case argument
+ when -> (arg) { arg.respond_to?(:permitted?) }
+ serialize_hash(argument.to_h).tap do |result|
+ result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true)
+ end
+ else
+ super
+ end
+ end
+ end
+
+ # Make sure we remove this patch starting with Rails 6.0.
+ if Rails.version.start_with?('6.0')
+ raise <<~MSG
+ Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again.
+ MSG
+ end
end
end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index 7eae07d3f6b..a9b88a133be 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -15,19 +15,19 @@ class RepositoryForkWorker
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)
+ fork_repository(target_project, source_project)
end
private
- def fork_repository(target_project, source_repository_storage_name, source_disk_path)
+ def fork_repository(target_project, source_project)
return unless start_fork(target_project)
Gitlab::Metrics.add_event(:fork_repository)
- result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path,
- target_project.repository_storage, target_project.disk_path)
- raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result
+ result = gitlab_shell.fork_repository(source_project, target_project)
+
+ raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}" unless result
target_project.after_import
end
diff --git a/bin/secpick b/bin/secpick
index be120a304c9..8f956d300a7 100755
--- a/bin/secpick
+++ b/bin/secpick
@@ -10,6 +10,7 @@ using Rainbow
module Secpick
BRANCH_PREFIX = 'security'.freeze
+ STABLE_SUFFIX = 'stable'.freeze
DEFAULT_REMOTE = 'dev'.freeze
NEW_MR_URL = 'https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/new'.freeze
@@ -36,16 +37,16 @@ module Secpick
branch.freeze
end
- def security_branch
- "#{BRANCH_PREFIX}-#{@options[:version]}".tap do |name|
+ def stable_branch
+ "#{@options[:version]}-#{STABLE_SUFFIX}".tap do |name|
name << "-ee" if ee?
end.freeze
end
def git_commands
- ["git fetch #{@options[:remote]} #{security_branch}",
- "git checkout #{security_branch}",
- "git pull #{@options[:remote]} #{security_branch}",
+ ["git fetch #{@options[:remote]} #{stable_branch}",
+ "git checkout #{stable_branch}",
+ "git pull #{@options[:remote]} #{stable_branch}",
"git checkout -B #{source_branch}",
"git cherry-pick #{@options[:sha]}",
"git push #{@options[:remote]} #{source_branch}",
@@ -56,9 +57,8 @@ module Secpick
{
merge_request: {
source_branch: source_branch,
- target_branch: security_branch,
- title: "[#{@options[:version].tr('-', '.')}] ",
- description: '/label ~security ~"Merge into Security"'
+ target_branch: stable_branch,
+ description: '/label ~security'
}
}
end
diff --git a/changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml b/changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml
new file mode 100644
index 00000000000..b1177e1717e
--- /dev/null
+++ b/changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml
@@ -0,0 +1,5 @@
+---
+title: Increase reliability and performance of toggling task items
+merge_request: 23938
+author:
+type: fixed
diff --git a/changelogs/unreleased/2105-add-setting-for-first-day-of-the-week.yml b/changelogs/unreleased/2105-add-setting-for-first-day-of-the-week.yml
new file mode 100644
index 00000000000..f4a52b1aacd
--- /dev/null
+++ b/changelogs/unreleased/2105-add-setting-for-first-day-of-the-week.yml
@@ -0,0 +1,5 @@
+---
+title: Add setting for first day of the week
+merge_request: 22755
+author: Fabian Schneider @fabsrc
+type: added
diff --git a/changelogs/unreleased/24875-label.yml b/changelogs/unreleased/24875-label.yml
new file mode 100644
index 00000000000..1f9d2222edf
--- /dev/null
+++ b/changelogs/unreleased/24875-label.yml
@@ -0,0 +1,5 @@
+---
+title: Append prioritized label before pagination
+merge_request: 24815
+author:
+type: fixed
diff --git a/changelogs/unreleased/28500-empty-states-for-profile-page.yml b/changelogs/unreleased/28500-empty-states-for-profile-page.yml
new file mode 100644
index 00000000000..53f840521ae
--- /dev/null
+++ b/changelogs/unreleased/28500-empty-states-for-profile-page.yml
@@ -0,0 +1,5 @@
+---
+title: Refresh empty states for profile page tabs
+merge_request: 24549
+author:
+type: changed
diff --git a/changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml b/changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml
new file mode 100644
index 00000000000..0fbf6314a27
--- /dev/null
+++ b/changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml
@@ -0,0 +1,5 @@
+---
+title: Display last activity and created at datetimes for users
+merge_request: 24181
+author:
+type: added
diff --git a/changelogs/unreleased/44332-add-openid-profile-scopes.yml b/changelogs/unreleased/44332-add-openid-profile-scopes.yml
new file mode 100644
index 00000000000..b554fab5139
--- /dev/null
+++ b/changelogs/unreleased/44332-add-openid-profile-scopes.yml
@@ -0,0 +1,5 @@
+---
+title: GitLab now supports the profile and email scopes from OpenID Connect
+merge_request: 24335
+author: Goten Xiao
+type: added
diff --git a/changelogs/unreleased/45791-number-of-repositories-usage-ping.yml b/changelogs/unreleased/45791-number-of-repositories-usage-ping.yml
new file mode 100644
index 00000000000..8d1f5df56ea
--- /dev/null
+++ b/changelogs/unreleased/45791-number-of-repositories-usage-ping.yml
@@ -0,0 +1,5 @@
+---
+title: Add repositories count to usage ping data
+merge_request: 24823
+author:
+type: added
diff --git a/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml
new file mode 100644
index 00000000000..4ce6787570a
--- /dev/null
+++ b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml
@@ -0,0 +1,5 @@
+---
+title: Display timestamps to messages printed by gitlab:backup:restore rake tasks
+merge_request:
+author: Will Chandler
+type: changed
diff --git a/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml b/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml
new file mode 100644
index 00000000000..04caf8262c6
--- /dev/null
+++ b/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml
@@ -0,0 +1,5 @@
+---
+title: Block emojis and symbol characters from users full names
+merge_request: 24523
+author:
+type: other
diff --git a/changelogs/unreleased/51759-filter-by-language.yml b/changelogs/unreleased/51759-filter-by-language.yml
new file mode 100644
index 00000000000..6b5bedd6b2d
--- /dev/null
+++ b/changelogs/unreleased/51759-filter-by-language.yml
@@ -0,0 +1,5 @@
+---
+title: Add `with_programming_language` filter for projects to API
+merge_request: 24377
+author: Dylan MacKenzie
+type: added
diff --git a/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml b/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml
new file mode 100644
index 00000000000..9d72efdd52a
--- /dev/null
+++ b/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml
@@ -0,0 +1,5 @@
+---
+title: 'API: Support username with dots'
+merge_request: 24395
+author: Robert Schilling
+type: fixed
diff --git a/changelogs/unreleased/52347-lines-changed-statistics-is-not-easily-visible-in-mr-changes-view.yml b/changelogs/unreleased/52347-lines-changed-statistics-is-not-easily-visible-in-mr-changes-view.yml
new file mode 100644
index 00000000000..cf1c4378f18
--- /dev/null
+++ b/changelogs/unreleased/52347-lines-changed-statistics-is-not-easily-visible-in-mr-changes-view.yml
@@ -0,0 +1,5 @@
+---
+title: Show MR statistics in diff comparisons
+merge_request: !24569
+author:
+type: changed
diff --git a/changelogs/unreleased/52363-ui-changes-to-cluster-and-ado-pages.yml b/changelogs/unreleased/52363-ui-changes-to-cluster-and-ado-pages.yml
new file mode 100644
index 00000000000..eb4851971fb
--- /dev/null
+++ b/changelogs/unreleased/52363-ui-changes-to-cluster-and-ado-pages.yml
@@ -0,0 +1,5 @@
+---
+title: Moves domain setting from Auto DevOps to Cluster's page
+merge_request: 24580
+author:
+type: added
diff --git a/changelogs/unreleased/52568-external-mr-diffs.yml b/changelogs/unreleased/52568-external-mr-diffs.yml
new file mode 100644
index 00000000000..b1c9d5cb809
--- /dev/null
+++ b/changelogs/unreleased/52568-external-mr-diffs.yml
@@ -0,0 +1,5 @@
+---
+title: Allow merge request diffs to be placed into an object store
+merge_request: 24276
+author:
+type: added
diff --git a/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml b/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml
new file mode 100644
index 00000000000..de12c66e9ef
--- /dev/null
+++ b/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml
@@ -0,0 +1,5 @@
+---
+title: Update project topics styling to use badges design
+merge_request: 24415
+author:
+type: changed
diff --git a/changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml b/changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml
new file mode 100644
index 00000000000..f22524ef4b2
--- /dev/null
+++ b/changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve UI bug adding group members with lower permissions
+merge_request: 24820
+author:
+type: fixed
diff --git a/changelogs/unreleased/56014-api-merge-request-squash-commit-messages.yml b/changelogs/unreleased/56014-api-merge-request-squash-commit-messages.yml
new file mode 100644
index 00000000000..e324baa94a3
--- /dev/null
+++ b/changelogs/unreleased/56014-api-merge-request-squash-commit-messages.yml
@@ -0,0 +1,5 @@
+---
+title: API allows setting the squash commit message when squashing a merge request
+merge_request: 24784
+author:
+type: added
diff --git a/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml b/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml
new file mode 100644
index 00000000000..19ff408ddf4
--- /dev/null
+++ b/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml
@@ -0,0 +1,5 @@
+---
+title: Fix cluster installation processing spinner
+merge_request: 24814
+author:
+type: fixed
diff --git a/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml b/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml
deleted file mode 100644
index b19b4d650fd..00000000000
--- a/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix form functionality for edit tag page
-merge_request: 24645
-author:
-type: fixed
diff --git a/changelogs/unreleased/56543-project-lists-further-iteration-improvements.yml b/changelogs/unreleased/56543-project-lists-further-iteration-improvements.yml
new file mode 100644
index 00000000000..388ff1d062a
--- /dev/null
+++ b/changelogs/unreleased/56543-project-lists-further-iteration-improvements.yml
@@ -0,0 +1,5 @@
+---
+title: Project list UI improvements
+merge_request: 24855
+author:
+type: other
diff --git a/changelogs/unreleased/56788-unicorn-metric-labels.yml b/changelogs/unreleased/56788-unicorn-metric-labels.yml
new file mode 100644
index 00000000000..824c981780c
--- /dev/null
+++ b/changelogs/unreleased/56788-unicorn-metric-labels.yml
@@ -0,0 +1,5 @@
+---
+title: Clean up unicorn sampler metric labels
+merge_request: 24626
+author: bjk-gitlab
+type: fixed
diff --git a/changelogs/unreleased/56938-diff-file-headers-on-compare-not-quite-right.yml b/changelogs/unreleased/56938-diff-file-headers-on-compare-not-quite-right.yml
new file mode 100644
index 00000000000..f619a009a63
--- /dev/null
+++ b/changelogs/unreleased/56938-diff-file-headers-on-compare-not-quite-right.yml
@@ -0,0 +1,5 @@
+---
+title: Correct spacing for comparison page
+merge_request: !24783
+author:
+type: fixed
diff --git a/changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml b/changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml
new file mode 100644
index 00000000000..b05ab07e14c
--- /dev/null
+++ b/changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Add argument iids for issues in GraphQL
+merge_request: 24802
+author:
+type: added
diff --git a/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml b/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml
new file mode 100644
index 00000000000..3a663ce2132
--- /dev/null
+++ b/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml
@@ -0,0 +1,5 @@
+---
+title: Fix potential Addressable::URI::InvalidURIError
+merge_request: 24908
+author:
+type: fixed
diff --git a/changelogs/unreleased/adriel-remove-feature-flag.yml b/changelogs/unreleased/adriel-remove-feature-flag.yml
new file mode 100644
index 00000000000..d442e120d60
--- /dev/null
+++ b/changelogs/unreleased/adriel-remove-feature-flag.yml
@@ -0,0 +1,5 @@
+---
+title: Update metrics dashboard graph design
+merge_request: 24653
+author:
+type: changed
diff --git a/changelogs/unreleased/api-group-labels.yml b/changelogs/unreleased/api-group-labels.yml
new file mode 100644
index 00000000000..0df6f15a9b6
--- /dev/null
+++ b/changelogs/unreleased/api-group-labels.yml
@@ -0,0 +1,5 @@
+---
+title: 'API: Add support for group labels'
+merge_request: 21368
+author: Robert Schilling
+type: added
diff --git a/changelogs/unreleased/bvl-fix-race-condition-creating-signature.yml b/changelogs/unreleased/bvl-fix-race-condition-creating-signature.yml
new file mode 100644
index 00000000000..307b4f526bb
--- /dev/null
+++ b/changelogs/unreleased/bvl-fix-race-condition-creating-signature.yml
@@ -0,0 +1,5 @@
+---
+title: Avoid race conditions when creating GpgSignature
+merge_request: 24939
+author:
+type: fixed
diff --git a/changelogs/unreleased/chore-update-js-regex.yml b/changelogs/unreleased/chore-update-js-regex.yml
new file mode 100644
index 00000000000..d45d0b47457
--- /dev/null
+++ b/changelogs/unreleased/chore-update-js-regex.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade js-regex gem to version 3.1
+merge_request: 24433
+author: rroger
+type: changed
diff --git a/changelogs/unreleased/cluster_application_version_updated.yml b/changelogs/unreleased/cluster_application_version_updated.yml
new file mode 100644
index 00000000000..34fe55dcc5e
--- /dev/null
+++ b/changelogs/unreleased/cluster_application_version_updated.yml
@@ -0,0 +1,5 @@
+---
+title: Update cluster application version on updated and installed status
+merge_request: 24810
+author:
+type: other
diff --git a/changelogs/unreleased/diff-file-finder.yml b/changelogs/unreleased/diff-file-finder.yml
new file mode 100644
index 00000000000..3160e9fc91b
--- /dev/null
+++ b/changelogs/unreleased/diff-file-finder.yml
@@ -0,0 +1,5 @@
+---
+title: Added fuzzy file finder to merge requests
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/fix-39759-new-project-icon-vertical-align.yml b/changelogs/unreleased/fix-39759-new-project-icon-vertical-align.yml
new file mode 100644
index 00000000000..3d87807dbc1
--- /dev/null
+++ b/changelogs/unreleased/fix-39759-new-project-icon-vertical-align.yml
@@ -0,0 +1,5 @@
+---
+title: Adjust vertical alignment for project visibility icons
+merge_request: 24511
+author: Martin Hobert
+type: fixed
diff --git a/changelogs/unreleased/fix-49388.yml b/changelogs/unreleased/fix-49388.yml
new file mode 100644
index 00000000000..f8b5e3e1943
--- /dev/null
+++ b/changelogs/unreleased/fix-49388.yml
@@ -0,0 +1,5 @@
+---
+title: Update metrics environment dropdown to show complete option set
+merge_request: 24441
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-repo-settings-file-upload-error.yml b/changelogs/unreleased/fix-repo-settings-file-upload-error.yml
new file mode 100644
index 00000000000..b219fdfaa1e
--- /dev/null
+++ b/changelogs/unreleased/fix-repo-settings-file-upload-error.yml
@@ -0,0 +1,5 @@
+---
+title: Fix bug causing repository mirror settings UI to break
+merge_request: 23712
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix_jira_integration_VCS1019.yml b/changelogs/unreleased/fix_jira_integration_VCS1019.yml
new file mode 100644
index 00000000000..3582ec1fe0f
--- /dev/null
+++ b/changelogs/unreleased/fix_jira_integration_VCS1019.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Jira Service password validation on project integration services.
+merge_request: 24896
+author: Daniel Juarez
+type: fixed
diff --git a/changelogs/unreleased/gitaly-update-1.18.0.yml b/changelogs/unreleased/gitaly-update-1.18.0.yml
new file mode 100644
index 00000000000..392527f5e5d
--- /dev/null
+++ b/changelogs/unreleased/gitaly-update-1.18.0.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade gitaly to 1.18.0
+merge_request: 24981
+author:
+type: other
diff --git a/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml b/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml
new file mode 100644
index 00000000000..8f6fbdceb54
--- /dev/null
+++ b/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings from `/app/views/email_rejection_mailer`
+merge_request: 24869
+author: George Tsiolis
+type: other
diff --git a/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml b/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml
new file mode 100644
index 00000000000..a3bf54a1339
--- /dev/null
+++ b/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings from `/app/views/instance_statistics`
+merge_request: 24809
+author: George Tsiolis
+type: other
diff --git a/changelogs/unreleased/hnk-master-patch-61932.yml b/changelogs/unreleased/hnk-master-patch-61932.yml
new file mode 100644
index 00000000000..8cc9d0057a9
--- /dev/null
+++ b/changelogs/unreleased/hnk-master-patch-61932.yml
@@ -0,0 +1,5 @@
+---
+title: Update runner admin page to make description field larger
+merge_request: 23593
+author: Sascha Reynolds
+type: fixed
diff --git a/changelogs/unreleased/introduce-environment-search-endpoint.yml b/changelogs/unreleased/introduce-environment-search-endpoint.yml
new file mode 100644
index 00000000000..01851ba7d27
--- /dev/null
+++ b/changelogs/unreleased/introduce-environment-search-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Introduce Internal API for searching environment names
+merge_request: 24923
+author:
+type: added
diff --git a/changelogs/unreleased/issue_55744.yml b/changelogs/unreleased/issue_55744.yml
new file mode 100644
index 00000000000..6a643732b18
--- /dev/null
+++ b/changelogs/unreleased/issue_55744.yml
@@ -0,0 +1,5 @@
+---
+title: Fix template labels not being created on new projects
+merge_request: 24803
+author:
+type: fixed
diff --git a/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml b/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml
new file mode 100644
index 00000000000..18cced2906a
--- /dev/null
+++ b/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml
@@ -0,0 +1,5 @@
+---
+title: Display SAML failure messages instead of expecting CSRF token
+merge_request: 24509
+author:
+type: fixed
diff --git a/changelogs/unreleased/jlenny-AddPagesTemplates.yml b/changelogs/unreleased/jlenny-AddPagesTemplates.yml
new file mode 100644
index 00000000000..0985e4e18ed
--- /dev/null
+++ b/changelogs/unreleased/jlenny-AddPagesTemplates.yml
@@ -0,0 +1,5 @@
+---
+title: Add templates for most popular Pages templates
+merge_request: 24906
+author:
+type: added
diff --git a/changelogs/unreleased/jlenny-NewAndroidTemplate.yml b/changelogs/unreleased/jlenny-NewAndroidTemplate.yml
new file mode 100644
index 00000000000..ae8c58da859
--- /dev/null
+++ b/changelogs/unreleased/jlenny-NewAndroidTemplate.yml
@@ -0,0 +1,5 @@
+---
+title: Add template for Android with Fastlane
+merge_request: 24722
+author:
+type: changed
diff --git a/changelogs/unreleased/jprovazn-remove-redcarpet.yml b/changelogs/unreleased/jprovazn-remove-redcarpet.yml
new file mode 100644
index 00000000000..4e12de2d19b
--- /dev/null
+++ b/changelogs/unreleased/jprovazn-remove-redcarpet.yml
@@ -0,0 +1,5 @@
+---
+title: Removed deprecated Redcarpet markdown engine.
+merge_request:
+author:
+type: removed
diff --git a/changelogs/unreleased/knative-list.yml b/changelogs/unreleased/knative-list.yml
new file mode 100644
index 00000000000..754d8e172cf
--- /dev/null
+++ b/changelogs/unreleased/knative-list.yml
@@ -0,0 +1,5 @@
+---
+title: Modified Knative list view to provide more details
+merge_request: 24072
+author: Chris Baumbauer
+type: changed
diff --git a/changelogs/unreleased/local-markdown-version-bkp3.yml b/changelogs/unreleased/local-markdown-version-bkp3.yml
new file mode 100644
index 00000000000..ce5bff6ae6b
--- /dev/null
+++ b/changelogs/unreleased/local-markdown-version-bkp3.yml
@@ -0,0 +1,5 @@
+---
+title: Allow admins to invalidate markdown texts by setting local markdown version.
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml b/changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml
new file mode 100644
index 00000000000..9e979b48ad1
--- /dev/null
+++ b/changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml
@@ -0,0 +1,5 @@
+---
+title: Move permission check of manual actions of deployments
+merge_request: 24660
+author:
+type: other
diff --git a/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml
new file mode 100644
index 00000000000..732e4baf4e9
--- /dev/null
+++ b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml
@@ -0,0 +1,5 @@
+---
+title: Don't create new merge request pipeline without commits
+merge_request: 24503
+author: Hiroyuki Sato
+type: added
diff --git a/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml b/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml
deleted file mode 100644
index 3ba62b92413..00000000000
--- a/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Adjusts suggestions unable to be applied
-merge_request: 24603
-author:
-type: fixed
diff --git a/changelogs/unreleased/pl-serialize-ac-parameters.yml b/changelogs/unreleased/pl-serialize-ac-parameters.yml
new file mode 100644
index 00000000000..aad222b5506
--- /dev/null
+++ b/changelogs/unreleased/pl-serialize-ac-parameters.yml
@@ -0,0 +1,5 @@
+---
+title: Make `ActionController::Parameters` serializable for sidekiq jobs
+merge_request: 24864
+author:
+type: fixed
diff --git a/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml b/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml
new file mode 100644
index 00000000000..abce9dcc0c6
--- /dev/null
+++ b/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml
@@ -0,0 +1,5 @@
+---
+title: Update last_activity_on for Users on some main GET endpoints
+merge_request: 24642
+author:
+type: changed
diff --git a/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml b/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml
new file mode 100644
index 00000000000..a216d294b30
--- /dev/null
+++ b/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml
@@ -0,0 +1,5 @@
+---
+title: Extracted ReplyPlaceholder to its own component
+merge_request: 24507
+author: Martin Hobert
+type: other
diff --git a/changelogs/unreleased/search-title.yml b/changelogs/unreleased/search-title.yml
new file mode 100644
index 00000000000..ff0933ed0b2
--- /dev/null
+++ b/changelogs/unreleased/search-title.yml
@@ -0,0 +1,5 @@
+---
+title: Add 'in' filter that modifies scope of 'search' filter to issues and merge requests API
+merge_request: 24350
+author: Hiroyuki Sato
+type: added
diff --git a/changelogs/unreleased/security-22076-sanitize-url-in-names.yml b/changelogs/unreleased/security-22076-sanitize-url-in-names.yml
new file mode 100644
index 00000000000..4e0ad4dd4c4
--- /dev/null
+++ b/changelogs/unreleased/security-22076-sanitize-url-in-names.yml
@@ -0,0 +1,6 @@
+---
+title: Sanitize user full name to clean up any URL to prevent mail clients from auto-linking
+ URLs
+merge_request: 2793
+author:
+type: security
diff --git a/changelogs/unreleased/security-55320-stored-xss-in-user-status.yml b/changelogs/unreleased/security-55320-stored-xss-in-user-status.yml
new file mode 100644
index 00000000000..8ea9ae0ccdf
--- /dev/null
+++ b/changelogs/unreleased/security-55320-stored-xss-in-user-status.yml
@@ -0,0 +1,5 @@
+---
+title: Use sanitized user status message for user popover
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/security-stored-xss-via-katex.yml b/changelogs/unreleased/security-stored-xss-via-katex.yml
new file mode 100644
index 00000000000..a71ae1123f2
--- /dev/null
+++ b/changelogs/unreleased/security-stored-xss-via-katex.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed XSS content in KaTex links
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/sh-encode-content-disposition.yml b/changelogs/unreleased/sh-encode-content-disposition.yml
new file mode 100644
index 00000000000..b40ee6a85a8
--- /dev/null
+++ b/changelogs/unreleased/sh-encode-content-disposition.yml
@@ -0,0 +1,5 @@
+---
+title: Encode Content-Disposition filenames
+merge_request: 24919
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml b/changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml
new file mode 100644
index 00000000000..addf327b69d
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml
@@ -0,0 +1,5 @@
+---
+title: Alias GitHub and BitBucket OAuth2 callback URLs
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/sh-fix-issue-9357.yml b/changelogs/unreleased/sh-fix-issue-9357.yml
deleted file mode 100644
index 756cd6047b8..00000000000
--- a/changelogs/unreleased/sh-fix-issue-9357.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix 500 errors with legacy appearance logos
-merge_request: 24615
-author:
-type: fixed
diff --git a/changelogs/unreleased/sh-fix-pages-zip-constant.yml b/changelogs/unreleased/sh-fix-pages-zip-constant.yml
new file mode 100644
index 00000000000..fcd8aa45825
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-pages-zip-constant.yml
@@ -0,0 +1,5 @@
+---
+title: Fix uninitialized constant with GitLab Pages
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml b/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml
deleted file mode 100644
index 8c0b000220f..00000000000
--- a/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix import handling errors in Bitbucket Server importer
-merge_request: 24499
-author:
-type: fixed
diff --git a/changelogs/unreleased/support-chunking-in-client.yml b/changelogs/unreleased/support-chunking-in-client.yml
new file mode 100644
index 00000000000..e50648ea4b2
--- /dev/null
+++ b/changelogs/unreleased/support-chunking-in-client.yml
@@ -0,0 +1,5 @@
+---
+title: Fix code search when text is larger than max gRPC message size
+merge_request: 24111
+author:
+type: changed
diff --git a/changelogs/unreleased/test-permissions.yml b/changelogs/unreleased/test-permissions.yml
new file mode 100644
index 00000000000..cfb69fdcb1e
--- /dev/null
+++ b/changelogs/unreleased/test-permissions.yml
@@ -0,0 +1,5 @@
+---
+title: Disallows unauthorized users from accessing the pipelines section.
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/tooltips-to-top.yml b/changelogs/unreleased/tooltips-to-top.yml
new file mode 100644
index 00000000000..51bf127089e
--- /dev/null
+++ b/changelogs/unreleased/tooltips-to-top.yml
@@ -0,0 +1,5 @@
+---
+title: Change spawning of tooltips to be top by default
+merge_request: 21223
+author:
+type: changed
diff --git a/changelogs/unreleased/update-gitaly.yml b/changelogs/unreleased/update-gitaly.yml
new file mode 100644
index 00000000000..4ba42a689a7
--- /dev/null
+++ b/changelogs/unreleased/update-gitaly.yml
@@ -0,0 +1,5 @@
+---
+title: Update Gitaly to v1.17.0
+merge_request: 24873
+author:
+type: other
diff --git a/changelogs/unreleased/update-pages-config-only-when-changed.yml b/changelogs/unreleased/update-pages-config-only-when-changed.yml
new file mode 100644
index 00000000000..8d9e02df678
--- /dev/null
+++ b/changelogs/unreleased/update-pages-config-only-when-changed.yml
@@ -0,0 +1,5 @@
+---
+title: Do not reload daemon if configuration file of pages does not change
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/update-pages-extensionless-urls.yml b/changelogs/unreleased/update-pages-extensionless-urls.yml
new file mode 100644
index 00000000000..13b3e1df500
--- /dev/null
+++ b/changelogs/unreleased/update-pages-extensionless-urls.yml
@@ -0,0 +1,5 @@
+---
+title: Add support for extensionless pages URLs
+merge_request: 24876
+author:
+type: added
diff --git a/changelogs/unreleased/update-ui-admin-appearance.yml b/changelogs/unreleased/update-ui-admin-appearance.yml
new file mode 100644
index 00000000000..7bc35029d77
--- /dev/null
+++ b/changelogs/unreleased/update-ui-admin-appearance.yml
@@ -0,0 +1,5 @@
+---
+title: Update UI for admin appearance settings
+merge_request: 24685
+author:
+type: other
diff --git a/changelogs/unreleased/update-workhorse-8-2-0.yml b/changelogs/unreleased/update-workhorse-8-2-0.yml
new file mode 100644
index 00000000000..7d593917a25
--- /dev/null
+++ b/changelogs/unreleased/update-workhorse-8-2-0.yml
@@ -0,0 +1,5 @@
+---
+title: Update Workhorse to v8.2.0
+merge_request: 24909
+author:
+type: fixed
diff --git a/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml b/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml
new file mode 100644
index 00000000000..1ec276b4abc
--- /dev/null
+++ b/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Use deployment relation to get an environment name
+merge_request: 24890
+author:
+type: performance
diff --git a/changelogs/unreleased/use_upgrade_install_for_helm_apps.yml b/changelogs/unreleased/use_upgrade_install_for_helm_apps.yml
new file mode 100644
index 00000000000..b41c3cfa1ab
--- /dev/null
+++ b/changelogs/unreleased/use_upgrade_install_for_helm_apps.yml
@@ -0,0 +1,5 @@
+---
+title: Added ability to upgrade cluster applications
+merge_request: 24789
+author:
+type: added
diff --git a/changelogs/unreleased/workhorse-8-3-0.yml b/changelogs/unreleased/workhorse-8-3-0.yml
new file mode 100644
index 00000000000..6ae01d64ae5
--- /dev/null
+++ b/changelogs/unreleased/workhorse-8-3-0.yml
@@ -0,0 +1,5 @@
+---
+title: Update Workhorse to v8.3.0
+merge_request: 24959
+author:
+type: other
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 6fc33e8971e..be23166cb7b 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -166,6 +166,23 @@ production: &base
# aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4.
# endpoint: 'https://s3.amazonaws.com' # default: nil - Useful for S3 compliant services such as DigitalOcean Spaces
+ ## Merge request external diff storage
+ external_diffs:
+ # If disabled (the default), the diffs are in-database. Otherwise, they can
+ # be stored on disk, or in object storage
+ enabled: false
+ # The location where external diffs are stored (default: shared/lfs-external-diffs).
+ # storage_path: shared/external-diffs
+ # object_store:
+ # enabled: false
+ # remote_directory: external-diffs
+ # background_upload: false
+ # proxy_download: false
+ # connection:
+ # provider: AWS
+ # aws_access_key_id: AWS_ACCESS_KEY_ID
+ # aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ # region: us-east-1
## Git LFS
lfs:
@@ -733,6 +750,18 @@ test:
<<: *base
gravatar:
enabled: true
+ external_diffs:
+ enabled: false
+ # The location where external diffs are stored (default: shared/external-diffs).
+ # storage_path: shared/external-diffs
+ object_store:
+ enabled: false
+ remote_directory: external-diffs # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
lfs:
enabled: false
# The location where LFS objects are stored (default: shared/lfs-objects).
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 1aed41e02ab..dfcf1e648b4 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -216,6 +216,14 @@ Settings.pages['admin'] ||= Settingslogic.new({})
Settings.pages.admin['certificate'] ||= ''
#
+# External merge request diffs
+#
+Settings['external_diffs'] ||= Settingslogic.new({})
+Settings.external_diffs['enabled'] = false if Settings.external_diffs['enabled'].nil?
+Settings.external_diffs['storage_path'] = Settings.absolute(Settings.external_diffs['storage_path'] || File.join(Settings.shared['path'], 'external-diffs'))
+Settings.external_diffs['object_store'] = ObjectStoreSettings.parse(Settings.external_diffs['object_store'])
+
+#
# Git LFS
#
Settings['lfs'] ||= Settingslogic.new({})
diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb
index e97c0fcbd6b..fd5a62c39c6 100644
--- a/config/initializers/doorkeeper_openid_connect.rb
+++ b/config/initializers/doorkeeper_openid_connect.rb
@@ -31,8 +31,27 @@ Doorkeeper::OpenidConnect.configure do
o.claim(:name) { |user| user.name }
o.claim(:nickname) { |user| user.username }
- o.claim(:email) { |user| user.public_email }
- o.claim(:email_verified) { |user| true if user.public_email? }
+
+ # Check whether the application has access to the email scope, and grant
+ # access to the user's primary email address if so, otherwise their
+ # public email address (if present)
+ # This allows existing solutions built for GitLab's old behavior to keep
+ # working without modification.
+ o.claim(:email) do |user, scopes|
+ scopes.exists?(:email) ? user.email : user.public_email
+ end
+ o.claim(:email_verified) do |user, scopes|
+ if scopes.exists?(:email)
+ user.primary_email_verified?
+ elsif user.public_email?
+ user.verified_email?(user.public_email)
+ else
+ # If there is no public email set, tell doorkicker-openid-connect to
+ # exclude the email_verified claim by returning nil.
+ nil
+ end
+ end
+
o.claim(:website) { |user| user.full_website_url if user.website_url? }
o.claim(:profile) { |user| Gitlab::Routing.url_helpers.user_url user }
o.claim(:picture) { |user| user.avatar_url(only_path: false) }
diff --git a/config/initializers/sprockets_base_file_digest_key.rb b/config/initializers/sprockets_base_file_digest_key.rb
new file mode 100644
index 00000000000..81ff3812091
--- /dev/null
+++ b/config/initializers/sprockets_base_file_digest_key.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+Sprockets::Base.prepend(Gitlab::Patch::SprocketsBaseFileDigestKey)
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 38c3711c6c7..554e9913faa 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -43,6 +43,7 @@ de:
default: "%d.%m.%Y"
long: "%e. %B %Y"
short: "%e. %b"
+ admin: "%e %b, %Y"
month_names:
-
- Januar
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
index 9f451046462..a2dff92908e 100644
--- a/config/locales/doorkeeper.en.yml
+++ b/config/locales/doorkeeper.en.yml
@@ -64,6 +64,8 @@ en:
read_registry: Grants permission to read container registry images
openid: Authenticate using OpenID Connect
sudo: Perform API actions as any user in the system
+ profile: Allows read-only access to the user's personal information using OpenID Connect
+ email: Allows read-only access to the user's primary email address using OpenID Connect
scope_desc:
api:
Grants complete read/write access to the API, including all groups and projects.
@@ -77,6 +79,10 @@ en:
Grants permission to authenticate with GitLab using OpenID Connect. Also gives read-only access to the user's profile and group memberships.
sudo:
Grants permission to perform API actions as any user in the system, when authenticated as an admin user.
+ profile:
+ Grants read-only access to the user's profile data using OpenID Connect.
+ email:
+ Grants read-only access to the user's primary email address using OpenID Connect.
flash:
applications:
create:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 0a43a1d9a6b..e8dbc033a7c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -55,6 +55,7 @@ en:
default: "%Y-%m-%d"
long: "%B %d, %Y"
short: "%b %d"
+ admin: "%e %b, %Y"
month_names:
-
- January
diff --git a/config/locales/es.yml b/config/locales/es.yml
index fdc52b4ae11..78c07583933 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -42,6 +42,7 @@ es:
default: "%d/%m/%Y"
long: "%d de %B de %Y"
short: "%d de %b"
+ admin: "%e %b, %Y"
month_names:
-
- enero
diff --git a/config/routes/import.rb b/config/routes/import.rb
index 3998d977c81..da5c31d0062 100644
--- a/config/routes/import.rb
+++ b/config/routes/import.rb
@@ -1,3 +1,12 @@
+# Alias import callbacks under the /users/auth endpoint so that
+# the OAuth2 callback URL can be restricted under http://example.com/users/auth
+# instead of http://example.com.
+Devise.omniauth_providers.map(&:downcase).each do |provider|
+ next if provider == 'ldapmain'
+
+ get "/users/auth/-/import/#{provider}/callback", to: "import/#{provider}#callback", as: "users_import_#{provider}_callback"
+end
+
namespace :import do
resource :github, only: [:create, :new], controller: :github do
post :personal_access_token
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 21793e7756a..b4ebc7df4fe 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -224,6 +224,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
collection do
get :metrics, action: :metrics_redirect
get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ }
+ get :search
end
resources :deployments, only: [:index] do
@@ -443,7 +444,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
- resources :error_tracking, only: [:index], controller: :error_tracking
+ resources :error_tracking, only: [:index], controller: :error_tracking do
+ collection do
+ post :list_projects
+ end
+ end
# Since both wiki and repository routing contains wildcard characters
# its preferable to keep it below all other project routes
diff --git a/config/webpack.config.js b/config/webpack.config.js
index b9044e13f50..fdf179b007a 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -94,6 +94,9 @@ module.exports = {
vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'),
vue$: 'vue/dist/vue.esm.js',
spec: path.join(ROOT_PATH, 'spec/javascripts'),
+
+ // the following resolves files which are different between CE and EE
+ ee_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'),
},
},
diff --git a/db/migrate/20140502125220_migrate_repo_size.rb b/db/migrate/20140502125220_migrate_repo_size.rb
index a69b02cddc4..bff1f01c654 100644
--- a/db/migrate/20140502125220_migrate_repo_size.rb
+++ b/db/migrate/20140502125220_migrate_repo_size.rb
@@ -11,7 +11,7 @@ class MigrateRepoSize < ActiveRecord::Migration[4.2]
path = File.join(namespace_path, project['project_path'] + '.git')
begin
- repo = Gitlab::Git::Repository.new('default', path, '')
+ repo = Gitlab::Git::Repository.new('default', path, '', '')
if repo.empty?
print '-'
else
diff --git a/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb b/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb
new file mode 100644
index 00000000000..a0e76c2186e
--- /dev/null
+++ b/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddFirstDayOfWeekToUserPreferences < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :user_preferences, :first_day_of_week, :integer
+ end
+end
diff --git a/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb b/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb
new file mode 100644
index 00000000000..53cfaa289f6
--- /dev/null
+++ b/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AddFirstDayOfWeekToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ add_column_with_default(:application_settings, :first_day_of_week, :integer, default: 0)
+ end
+
+ def down
+ remove_column(:application_settings, :first_day_of_week)
+ end
+end
diff --git a/db/migrate/20190109153125_add_merge_request_external_diffs.rb b/db/migrate/20190109153125_add_merge_request_external_diffs.rb
new file mode 100644
index 00000000000..c67903c7f67
--- /dev/null
+++ b/db/migrate/20190109153125_add_merge_request_external_diffs.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddMergeRequestExternalDiffs < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ # Allow the merge request diff to store details about an external file
+ add_column :merge_request_diffs, :external_diff, :string
+ add_column :merge_request_diffs, :external_diff_store, :integer
+ add_column :merge_request_diffs, :stored_externally, :boolean
+
+ # The diff for each file is mapped to a range in the external file
+ add_column :merge_request_diff_files, :external_diff_offset, :integer
+ add_column :merge_request_diff_files, :external_diff_size, :integer
+
+ # If the diff is in object storage, it will be null in the database
+ change_column_null :merge_request_diff_files, :diff, true
+ end
+end
diff --git a/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb b/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb
new file mode 100644
index 00000000000..190b6f958fd
--- /dev/null
+++ b/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AddColumnsProjectErrorTrackingSettings < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :project_error_tracking_settings, :project_name, :string
+ add_column :project_error_tracking_settings, :organization_name, :string
+
+ change_column_default :project_error_tracking_settings, :enabled, from: true, to: false
+
+ change_column_null :project_error_tracking_settings, :api_url, true
+ end
+end
diff --git a/db/migrate/20190130091630_add_local_cached_markdown_version.rb b/db/migrate/20190130091630_add_local_cached_markdown_version.rb
new file mode 100644
index 00000000000..00570e6458c
--- /dev/null
+++ b/db/migrate/20190130091630_add_local_cached_markdown_version.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AddLocalCachedMarkdownVersion < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :local_markdown_version, :integer, default: 0, null: false
+ end
+end
diff --git a/db/post_migrate/20181219130552_update_project_import_visibility_level.rb b/db/post_migrate/20181219130552_update_project_import_visibility_level.rb
new file mode 100644
index 00000000000..6209de88b31
--- /dev/null
+++ b/db/post_migrate/20181219130552_update_project_import_visibility_level.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class UpdateProjectImportVisibilityLevel < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ BATCH_SIZE = 100
+
+ PRIVATE = 0
+ INTERNAL = 10
+
+ disable_ddl_transaction!
+
+ class Namespace < ActiveRecord::Base
+ self.table_name = 'namespaces'
+ end
+
+ class Project < ActiveRecord::Base
+ include EachBatch
+
+ belongs_to :namespace
+
+ IMPORT_TYPE = 'gitlab_project'
+
+ scope :with_group_visibility, ->(visibility) do
+ joins(:namespace)
+ .where(namespaces: { type: 'Group', visibility_level: visibility })
+ .where(import_type: IMPORT_TYPE)
+ .where('projects.visibility_level > namespaces.visibility_level')
+ end
+
+ self.table_name = 'projects'
+ end
+
+ def up
+ # Update project's visibility to be the same as the group
+ # if it is more restrictive than `PUBLIC`.
+ update_projects_visibility(PRIVATE)
+ update_projects_visibility(INTERNAL)
+ end
+
+ def down
+ # no-op: unrecoverable data migration
+ end
+
+ private
+
+ def update_projects_visibility(visibility)
+ say_with_time("Updating project visibility to #{visibility} on #{Project::IMPORT_TYPE} imports.") do
+ Project.with_group_visibility(visibility).select(:id).each_batch(of: BATCH_SIZE) do |batch, _index|
+ batch_sql = Gitlab::Database.mysql? ? batch.pluck(:id).join(', ') : batch.select(:id).to_sql
+
+ say("Updating #{batch.size} items.", true)
+
+ execute("UPDATE projects SET visibility_level = '#{visibility}' WHERE id IN (#{batch_sql})")
+ end
+ end
+ end
+end
diff --git a/db/post_migrate/20190131122559_fix_null_type_labels.rb b/db/post_migrate/20190131122559_fix_null_type_labels.rb
new file mode 100644
index 00000000000..83bb613990c
--- /dev/null
+++ b/db/post_migrate/20190131122559_fix_null_type_labels.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class FixNullTypeLabels < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ update_column_in_batches(:labels, :type, 'ProjectLabel') do |table, query|
+ query.where(
+ table[:project_id].not_eq(nil)
+ .and(table[:template].eq(false))
+ .and(table[:type].eq(nil))
+ )
+ end
+ end
+
+ def down
+ # no action
+ end
+end
diff --git a/db/post_migrate/20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb b/db/post_migrate/20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb
new file mode 100644
index 00000000000..392e64eeade
--- /dev/null
+++ b/db/post_migrate/20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class MigrateAutoDevOpsDomainToClusterDomain < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ execute(update_clusters_domain_query)
+ end
+
+ def down
+ # no-op
+ end
+
+ private
+
+ def update_clusters_domain_query
+ if Gitlab::Database.mysql?
+ mysql_query
+ else
+ postgresql_query
+ end
+ end
+
+ def mysql_query
+ <<~HEREDOC
+ UPDATE clusters, project_auto_devops, cluster_projects
+ SET
+ clusters.domain = project_auto_devops.domain
+ WHERE
+ cluster_projects.cluster_id = clusters.id
+ AND project_auto_devops.project_id = cluster_projects.project_id
+ AND project_auto_devops.domain != ''
+ HEREDOC
+ end
+
+ def postgresql_query
+ <<~HEREDOC
+ UPDATE clusters
+ SET domain = project_auto_devops.domain
+ FROM cluster_projects, project_auto_devops
+ WHERE
+ cluster_projects.cluster_id = clusters.id
+ AND project_auto_devops.project_id = cluster_projects.project_id
+ AND project_auto_devops.domain != ''
+ HEREDOC
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7c1733becb9..023eee5f33e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20190124200344) do
+ActiveRecord::Schema.define(version: 20190204115450) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -168,6 +168,8 @@ ActiveRecord::Schema.define(version: 20190124200344) do
t.string "commit_email_hostname"
t.boolean "protected_ci_variables", default: false, null: false
t.string "runners_registration_token_encrypted"
+ t.integer "local_markdown_version", default: 0, null: false
+ t.integer "first_day_of_week", default: 0, null: false
t.index ["usage_stats_set_by_user_id"], name: "index_application_settings_on_usage_stats_set_by_user_id", using: :btree
end
@@ -1203,8 +1205,10 @@ ActiveRecord::Schema.define(version: 20190124200344) do
t.string "b_mode", null: false
t.text "new_path", null: false
t.text "old_path", null: false
- t.text "diff", null: false
+ t.text "diff"
t.boolean "binary"
+ t.integer "external_diff_offset"
+ t.integer "external_diff_size"
t.index ["merge_request_diff_id", "relative_order"], name: "index_merge_request_diff_files_on_mr_diff_id_and_order", unique: true, using: :btree
end
@@ -1218,6 +1222,9 @@ ActiveRecord::Schema.define(version: 20190124200344) do
t.string "head_commit_sha"
t.string "start_commit_sha"
t.integer "commits_count"
+ t.string "external_diff"
+ t.integer "external_diff_store"
+ t.boolean "stored_externally"
t.index ["merge_request_id", "id"], name: "index_merge_request_diffs_on_merge_request_id_and_id", using: :btree
end
@@ -1574,10 +1581,12 @@ ActiveRecord::Schema.define(version: 20190124200344) do
end
create_table "project_error_tracking_settings", primary_key: "project_id", id: :integer, force: :cascade do |t|
- t.boolean "enabled", default: true, null: false
- t.string "api_url", null: false
+ t.boolean "enabled", default: false, null: false
+ t.string "api_url"
t.string "encrypted_token"
t.string "encrypted_token_iv"
+ t.string "project_name"
+ t.string "organization_name"
end
create_table "project_features", force: :cascade do |t|
@@ -2146,6 +2155,7 @@ ActiveRecord::Schema.define(version: 20190124200344) do
t.integer "merge_request_notes_filter", limit: 2, default: 0, null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
+ t.integer "first_day_of_week"
t.string "issues_sort"
t.string "merge_requests_sort"
t.index ["user_id"], name: "index_user_preferences_on_user_id", unique: true, using: :btree
diff --git a/doc/README.md b/doc/README.md
index 1a0359f9e2a..f87ff925e94 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -336,7 +336,7 @@ Viewing [Container Scanning reports](https://docs.gitlab.com/ee/user/project/mer
There are two ways to use GitLab:
- [GitLab self-managed](#gitlab-self-managed): Install, administer, and maintain your own GitLab instance.
-- [GitLab.com](#gitlab-com): GitLab's SaaS offering. You don't need to install anything to use GitLab.com,
+- [GitLab.com](#gitlabcom): GitLab's SaaS offering. You don't need to install anything to use GitLab.com,
you only need to [sign up](https://gitlab.com/users/sign_in) and start using GitLab straight away.
The following sections outline tiers and features within GitLab self-managed and GitLab.com.
diff --git a/doc/administration/git_protocol.md b/doc/administration/git_protocol.md
index 341a00009e5..11b2adeeeb8 100644
--- a/doc/administration/git_protocol.md
+++ b/doc/administration/git_protocol.md
@@ -5,6 +5,13 @@ description: "Set and configure Git protocol v2"
# Configuring Git Protocol v2
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/46555) in GitLab 11.4.
+> [Temporarily disabled](https://gitlab.com/gitlab-org/gitlab-ce/issues/55769) in GitLab 11.5.8, 11.6.6, 11.7.1, and 11.8+
+
+NOTE: **Note:**
+Git protocol v2 support has been [temporarily disabled](https://gitlab.com/gitlab-org/gitlab-ce/issues/55769),
+as a feature used to hide certain internal references does not function when it
+is enabled, and this has a security impact. Once this problem has been resolved,
+protocol v2 support will be re-enabled.
Git protocol v2 improves the v1 wire protocol in several ways and is
enabled by default in GitLab for HTTP requests. In order to enable SSH,
diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md
index abef7a6cd33..0795d3dad40 100644
--- a/doc/administration/gitaly/index.md
+++ b/doc/administration/gitaly/index.md
@@ -52,10 +52,10 @@ is used.
### Network architecture
- gitlab-rails shards repositories into "repository storages"
-- gitlab-rails/config/gitlab.yml contains a map from storage names to
+- `gitlab-rails/config/gitlab.yml` contains a map from storage names to
(Gitaly address, Gitaly token) pairs
- the `storage name` -\> `(Gitaly address, Gitaly token)` map in
- gitlab.yml is the single source of truth for the Gitaly network
+ `gitlab.yml` is the single source of truth for the Gitaly network
topology
- a (Gitaly address, Gitaly token) corresponds to a Gitaly server
- a Gitaly server hosts one or more storages
@@ -65,7 +65,7 @@ is used.
gitlab-shell, and Gitaly itself
- special case: a Gitaly server must be able to make RPC calls **to
itself** via its own (Gitaly address, Gitaly token) pair as
- specified in gitlab-rails/config/gitlab.yml
+ specified in `gitlab-rails/config/gitlab.yml`
- Gitaly servers must not be exposed to the public internet
Gitaly network traffic is unencrypted so you should use a firewall to
@@ -125,7 +125,7 @@ Omnibus installations:
```ruby
# /etc/gitlab/gitlab.rb
-# Avoid running unnecessary services on the gitaly server
+# Avoid running unnecessary services on the Gitaly server
postgresql['enable'] = false
redis['enable'] = false
nginx['enable'] = false
@@ -153,7 +153,7 @@ gitaly['storage'] = [
{ 'name' => 'storage1', 'path' => '/mnt/gitlab/storage1/repositories' },
]
-# To use tls for gitaly you need to add
+# To use TLS for Gitaly you need to add
gitaly['tls_listen_addr'] = "0.0.0.0:9999"
gitaly['certificate_path'] = "path/to/cert.pem"
gitaly['key_path'] = "path/to/key.pem"
@@ -239,11 +239,11 @@ repository from your GitLab server over HTTP.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22602) in GitLab 11.7.
Gitaly supports TLS credentials for GRPC authentication. To be able to communicate
-with a gitaly instance that listens for secure connections you will need to use `tls://` url
+with a Gitaly instance that listens for secure connections you will need to use `tls://` url
scheme in the `gitaly_address` of the corresponding storage entry in the gitlab configuration.
The admin needs to bring their own certificate as we do not provide that automatically.
-The certificate to be used needs to be installed on all gitaly nodes and on all client nodes that communicate with it following procedures described in [GitLab custom certificate configuration](https://docs.gitlab.com/omnibus/settings/ssl.html#install-custom-public-certificates)
+The certificate to be used needs to be installed on all Gitaly nodes and on all client nodes that communicate with it following procedures described in [GitLab custom certificate configuration](https://docs.gitlab.com/omnibus/settings/ssl.html#install-custom-public-certificates)
### Example TLS configuration
@@ -261,7 +261,7 @@ git_data_dirs({
gitlab_rails['gitaly_token'] = 'abc123secret'
```
-#### On gitaly server nodes:
+#### On Gitaly server nodes:
```ruby
gitaly['tls_listen_addr'] = "0.0.0.0:9999"
@@ -289,7 +289,7 @@ gitlab:
token: 'abc123secret'
```
-#### On gitaly server nodes:
+#### On Gitaly server nodes:
```toml
# /home/git/gitaly/config.toml
diff --git a/doc/administration/index.md b/doc/administration/index.md
index 0b673d61139..12fec2753bf 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -48,6 +48,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [Third party offers](../user/admin_area/settings/third_party_offers.md)
- [Compliance](compliance.md): A collection of features from across the application that you may configure to help ensure that your GitLab instance and DevOps workflow meet compliance standards.
- [Diff limits](../user/admin_area/diff_limits.md): Configure the diff rendering size limits of branch comparison pages.
+- [Merge request diffs](merge_request_diffs.md): Configure the diffs shown on merge requests
- [Broadcast Messages](../user/admin_area/broadcast_messages.md): Send messages to GitLab users through the UI.
#### Customizing GitLab's appearance
@@ -64,6 +65,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [Backup and restore](../raketasks/backup_restore.md): Backup and restore your GitLab instance.
- [Operations](operations/index.md): Keeping GitLab up and running (clean up Redis sessions, moving repositories, Sidekiq MemoryKiller, Unicorn).
- [Restart GitLab](restart_gitlab.md): Learn how to restart GitLab and its components.
+- [Invalidate markdown cache](invalidate_markdown_cache.md): Invalidate any cached markdown.
#### Updating GitLab
diff --git a/doc/administration/invalidate_markdown_cache.md b/doc/administration/invalidate_markdown_cache.md
new file mode 100644
index 00000000000..ad64cb077c1
--- /dev/null
+++ b/doc/administration/invalidate_markdown_cache.md
@@ -0,0 +1,16 @@
+# Invalidate Markdown Cache
+
+For performance reasons, GitLab caches the HTML version of markdown text
+(e.g. issue and merge request descriptions, comments). It's possible
+that these cached versions become outdated, for example
+when the `external_url` configuration option is changed - causing links
+in the cached text to refer to the old URL.
+
+To avoid this problem, the administrator can invalidate the existing cache by
+increasing the `local_markdown_version` setting in application settings. This can
+be done by [changing the application settings through
+the API](../api/settings.md#change-application-settings):
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/application/settings?local_markdown_version=<increased_number>
+```
diff --git a/doc/administration/merge_request_diffs.md b/doc/administration/merge_request_diffs.md
new file mode 100644
index 00000000000..94620c3d3a0
--- /dev/null
+++ b/doc/administration/merge_request_diffs.md
@@ -0,0 +1,154 @@
+# Merge request diffs administration
+
+> **Notes:**
+> - External merge request diffs introduced in GitLab 11.8
+
+Merge request diffs are size-limited copies of diffs associated with merge
+requests. When viewing a merge request, diffs are sourced from these copies
+wherever possible as a performance optimization.
+
+By default, merge request diffs are stored in the database, in a table named
+`merge_request_diff_files`. Larger installations may find this table grows too
+large, in which case, switching to external storage is recommended.
+
+### Using external storage
+
+Merge request diffs can be stored on disk, or in object storage. In general, it
+is better to store the diffs in the database than on disk.
+
+To enable external storage of merge request diffs:
+
+---
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following line:
+
+ ```ruby
+ gitlab_rails['external_diffs_enabled'] = true
+ ```
+
+1. _The external diffs will be stored in in
+ `/var/opt/gitlab/gitlab-rails/shared/external-diffs`._ To change the path,
+ for example to `/mnt/storage/external-diffs`, edit `/etc/gitlab/gitlab.rb`
+ and add the following line:
+
+ ```ruby
+ gitlab_rails['external_diffs_storage_path'] = "/mnt/storage/external-diffs"
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ ```
+
+1. _The external diffs will be stored in
+ `/home/git/gitlab/shared/external-diffs`._ To change the path, for example
+ to `/mnt/storage/external-diffs`, edit `/home/git/gitlab/config/gitlab.yml`
+ and add or amend the following lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ storage_path: /mnt/storage/external-diffs
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+
+### Using object storage
+
+Instead of storing the external diffs on disk, we recommended you use an object
+store like AWS S3 instead. This configuration relies on valid AWS credentials to
+be configured already.
+
+### Object Storage Settings
+
+For source installations, these settings are nested under `external_diffs:` and
+then `object_store:`. On omnibus installs, they are prefixed by
+`external_diffs_object_store_`.
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `enabled` | Enable/disable object storage | `false` |
+| `remote_directory` | The bucket name where external diffs will be stored| |
+| `direct_upload` | Set to true to enable direct upload of external diffs without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. | `false` |
+| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` |
+| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` |
+| `connection` | Various connection options described below | |
+
+#### S3 compatible connection settings
+
+The connection settings match those provided by [Fog](https://github.com/fog), and are as follows:
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `provider` | Always `AWS` for compatible hosts | AWS |
+| `aws_access_key_id` | AWS credentials, or compatible | |
+| `aws_secret_access_key` | AWS credentials, or compatible | |
+| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 |
+| `region` | AWS region | us-east-1 |
+| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com |
+| `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) |
+| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false |
+| `use_iam_profile` | Set to true to use IAM profile instead of access keys | false
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with
+ the values you want:
+
+ ```ruby
+ gitlab_rails['external_diffs_enabled'] = true
+ gitlab_rails['external_diffs_object_store_enabled'] = true
+ gitlab_rails['external_diffs_object_store_remote_directory'] = "external-diffs"
+ gitlab_rails['external_diffs_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'aws_access_key_id' => 'AWS_ACCESS_KEY_ID',
+ 'aws_secret_access_key' => 'AWS_SECRET_ACCESS_KEY'
+ }
+ ```
+
+ NOTE: if you are using AWS IAM profiles, be sure to omit the
+ AWS access key and secret access key/value pairs. For example:
+
+ ```ruby
+ gitlab_rails['external_diffs_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'use_iam_profile' => true
+ }
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ object_store:
+ enabled: true
+ remote_directory: "external-diffs" # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: eu-central-1
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md
index c9a2778b3a4..6ea0ac0d495 100644
--- a/doc/administration/monitoring/prometheus/gitlab_metrics.md
+++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md
@@ -48,6 +48,8 @@ The following metrics are available:
| upload_file_does_not_exist | Counter | 10.7 in EE, 11.5 in CE | Number of times an upload record could not find its file |
| failed_login_captcha_total | Gauge | 11.0 | Counter of failed CAPTCHA attempts during login |
| successful_login_captcha_total | Gauge | 11.0 | Counter of successful CAPTCHA attempts during login |
+| unicorn_active_connections | Gauge | 11.0 | The number of active Unicorn connections (workers) |
+| unicorn_queued_connections | Gauge | 11.0 | The number of queued Unicorn connections |
### Ruby metrics
diff --git a/doc/administration/operations/filesystem_benchmarking.md b/doc/administration/operations/filesystem_benchmarking.md
index 0397452e650..c0c242733a2 100644
--- a/doc/administration/operations/filesystem_benchmarking.md
+++ b/doc/administration/operations/filesystem_benchmarking.md
@@ -7,13 +7,72 @@ systems.
Normally when talking about filesystem performance the biggest concern is
with Network Filesystems (NFS). However, even some local disks can have slow
-IO. The information on this page can be used for either scenario.
+I/O. The information on this page can be used for either scenario.
-## Write Performance
+## Executing benchmarks
-The following one-line command is a quick benchmark for filesystem write
+### Benchmarking with `fio`
+
+We recommend using
+[fio](https://fio.readthedocs.io/en/latest/fio_doc.html) to test I/O
+performance. This test should be run both on the NFS server and on the
+application nodes that talk to the NFS server.
+
+To install:
+
+- On Ubuntu: `apt install fio`.
+- On `yum`-managed environments: `yum install fio`.
+
+Then run the following:
+
+```sh
+fio --randrepeat=1 --ioengine=libaio --direct=1 --gtod_reduce=1 --name=test --filename=/path/to/git-data/testfile --bs=4k --iodepth=64 --size=4G --readwrite=randrw --rwmixread=75
+```
+
+This will create a 4GB file in `/path/to/git-data/testfile`. It performs
+4KB reads and writes using a 75%/25% split within the file, with 64
+operations running at a time. Be sure to delete the file after the test
+completes.
+
+The output will vary depending on what version of `fio` installed. The following
+is an example output from `fio` v2.2.10 on a networked solid-state drive (SSD):
+
+```
+test: (g=0): rw=randrw, bs=4K-4K/4K-4K/4K-4K, ioengine=libaio, iodepth=64
+ fio-2.2.10
+ Starting 1 process
+ test: Laying out IO file(s) (1 file(s) / 1024MB)
+ Jobs: 1 (f=1): [m(1)] [100.0% done] [131.4MB/44868KB/0KB /s] [33.7K/11.3K/0 iops] [eta 00m:00s]
+ test: (groupid=0, jobs=1): err= 0: pid=10287: Sat Feb 2 17:40:10 2019
+ read : io=784996KB, bw=133662KB/s, iops=33415, runt= 5873msec
+ write: io=263580KB, bw=44880KB/s, iops=11219, runt= 5873msec
+ cpu : usr=6.56%, sys=23.11%, ctx=266267, majf=0, minf=8
+ IO depths : 1=0.1%, 2=0.1%, 4=0.1%, 8=0.1%, 16=0.1%, 32=0.1%, >=64=100.0%
+ submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
+ complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.1%, >=64=0.0%
+ issued : total=r=196249/w=65895/d=0, short=r=0/w=0/d=0, drop=r=0/w=0/d=0
+ latency : target=0, window=0, percentile=100.00%, depth=64
+
+ Run status group 0 (all jobs):
+ READ: io=784996KB, aggrb=133661KB/s, minb=133661KB/s, maxb=133661KB/s, mint=5873msec, maxt=5873msec
+ WRITE: io=263580KB, aggrb=44879KB/s, minb=44879KB/s, maxb=44879KB/s, mint=5873msec, maxt=5873msec
+```
+
+Notice the `iops` values in this output. In this example, the SSD
+performed 33,415 read operations per second and 11,219 write operations
+per second. A spinning disk might yield 2,000 and 700 read and write
+operations per second.
+
+### Simple benchmarking
+
+NOTE: **Note:** This test is naive but may be useful if `fio` is not
+available on the system. It's possible to receive good results on this
+test but still have poor performance due to read speed and various other
+factors.
+
+The following one-line commands provide a quick benchmark for filesystem write and read
performance. This will write 1,000 small files to the directory in which it is
-executed.
+executed, and then read the same 1,000 files.
1. Change into the root of the appropriate
[repository storage path](../repository_storage_paths.md).
@@ -27,13 +86,18 @@ executed.
```sh
time for i in {0..1000}; do echo 'test' > "test${i}.txt"; done
```
+1. To benchmark read performance, run the command:
+
+ ```sh
+ time for i in {0..1000}; do cat "test${i}.txt" > /dev/null; done
+ ```
1. Remove the test files:
```sh
cd ../; rm -rf test
```
-The output of the `time for ...` command will look similar to the following. The
+The output of the `time for ...` commands will look similar to the following. The
important metric is the `real` time.
```sh
@@ -42,12 +106,13 @@ $ time for i in {0..1000}; do echo 'test' > "test${i}.txt"; done
real 0m0.116s
user 0m0.025s
sys 0m0.091s
+
+$ time for i in {0..1000}; do cat "test${i}.txt" > /dev/null; done
+
+real 0m3.118s
+user 0m1.267s
+sys 0m1.663s
```
From experience with multiple customers, this task should take under 10
-seconds to indicate good filesystem performance.
-
-NOTE: **Note:**
-This test is naive and only evaluates write performance. It's possible to
-receive good results on this test but still have poor performance due to read
-speed and various other factors. \ No newline at end of file
+seconds to indicate good filesystem performance.
diff --git a/doc/api/README.md b/doc/api/README.md
index 692f63a400c..a060e0481bf 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -29,6 +29,7 @@ The following API resources are available:
- [Group access requests](access_requests.md)
- [Group badges](group_badges.md)
- [Group issue boards](group_boards.md)
+ - [Group labels](group_labels.md)
- [Group-level variables](group_level_variables.md)
- [Group members](members.md)
- [Group milestones](group_milestones.md)
diff --git a/doc/api/broadcast_messages.md b/doc/api/broadcast_messages.md
index fe370682308..357d9916ade 100644
--- a/doc/api/broadcast_messages.md
+++ b/doc/api/broadcast_messages.md
@@ -1,18 +1,25 @@
# Broadcast Messages API
-> **Note:** This feature was introduced in GitLab 8.12.
+> Introduced in GitLab 8.12.
-The broadcast message API is only accessible to administrators. All requests by
-guests will respond with `401 Unauthorized`, and all requests by normal users
-will respond with `403 Forbidden`.
+Broadcast messages API operates on [broadcast messages](../user/admin_area/broadcast_messages.md).
+
+The broadcast message API is only accessible to administrators. All requests by:
+
+- Guests will result in `401 Unauthorized`.
+- Regular users will result in `403 Forbidden`.
## Get all broadcast messages
-```
+List all broadcast messages.
+
+```text
GET /broadcast_messages
```
-```bash
+Example request:
+
+```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/broadcast_messages
```
@@ -34,15 +41,21 @@ Example response:
## Get a specific broadcast message
-```
+Get a specific broadcast message.
+
+```text
GET /broadcast_messages/:id
```
-| Attribute | Type | Required | Description |
-| ----------- | -------- | -------- | ------------------------- |
-| `id` | integer | yes | Broadcast message ID |
+Parameters:
+
+| Attribute | Type | Required | Description |
+|:----------|:--------|:---------|:-------------------------------------|
+| `id` | integer | yes | ID of broadcast message to retrieve. |
+
+Example request:
-```bash
+```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/broadcast_messages/1
```
@@ -62,19 +75,25 @@ Example response:
## Create a broadcast message
-```
+Create a new broadcast message.
+
+```text
POST /broadcast_messages
```
-| Attribute | Type | Required | Description |
-| ----------- | -------- | -------- | ---------------------------------------------------- |
-| `message` | string | yes | Message to display |
-| `starts_at` | datetime | no | Starting time (defaults to current time) |
-| `ends_at` | datetime | no | Ending time (defaults to one hour from current time) |
-| `color` | string | no | Background color hex code |
-| `font` | string | no | Foreground color hex code |
+Parameters:
-```bash
+| Attribute | Type | Required | Description |
+|:------------|:---------|:---------|:------------------------------------------------------|
+| `message` | string | yes | Message to display. |
+| `starts_at` | datetime | no | Starting time (defaults to current time). |
+| `ends_at` | datetime | no | Ending time (defaults to one hour from current time). |
+| `color` | string | no | Background color hex code. |
+| `font` | string | no | Foreground color hex code. |
+
+Example request:
+
+```sh
curl --data "message=Deploy in progress&color=#cecece" --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/broadcast_messages
```
@@ -94,20 +113,26 @@ Example response:
## Update a broadcast message
-```
+Update an existing broadcast message.
+
+```text
PUT /broadcast_messages/:id
```
-| Attribute | Type | Required | Description |
-| ----------- | -------- | -------- | ------------------------- |
-| `id` | integer | yes | Broadcast message ID |
-| `message` | string | no | Message to display |
-| `starts_at` | datetime | no | Starting time |
-| `ends_at` | datetime | no | Ending time |
-| `color` | string | no | Background color hex code |
-| `font` | string | no | Foreground color hex code |
+Parameters:
+
+| Attribute | Type | Required | Description |
+|:------------|:---------|:---------|:-----------------------------------|
+| `id` | integer | yes | ID of broadcast message to update. |
+| `message` | string | no | Message to display. |
+| `starts_at` | datetime | no | Starting time. |
+| `ends_at` | datetime | no | Ending time. |
+| `color` | string | no | Background color hex code. |
+| `font` | string | no | Foreground color hex code. |
-```bash
+Example request:
+
+```sh
curl --request PUT --data "message=Update message&color=#000" --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/broadcast_messages/1
```
@@ -127,14 +152,20 @@ Example response:
## Delete a broadcast message
-```
+Delete a broadcast message.
+
+```sh
DELETE /broadcast_messages/:id
```
-| Attribute | Type | Required | Description |
-| ----------- | -------- | -------- | ------------------------- |
-| `id` | integer | yes | Broadcast message ID |
+Parameters:
+
+| Attribute | Type | Required | Description |
+|:----------|:--------|:---------|:-----------------------------------|
+| `id` | integer | yes | ID of broadcast message to delete. |
+
+Example request:
-```bash
+```sh
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/broadcast_messages/1
```
diff --git a/doc/api/group_labels.md b/doc/api/group_labels.md
new file mode 100644
index 00000000000..c36d34b4af1
--- /dev/null
+++ b/doc/api/group_labels.md
@@ -0,0 +1,201 @@
+# Group Label API
+
+>**Note:** This feature was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/21368) in GitLab 11.8.
+
+This API supports managing of [group labels](../user/project/labels.md#project-labels-and-group-labels). It allows to list, create, update, and delete group labels. Furthermore, users can subscribe and unsubscribe to and from group labels.
+
+## List group labels
+
+Get all labels for a given group.
+
+```
+GET /groups/:id/labels
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+
+```bash
+curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 7,
+ "name": "bug",
+ "color": "#FF0000",
+ "description": null,
+ "open_issues_count": 0,
+ "closed_issues_count": 0,
+ "open_merge_requests_count": 0,
+ "subscribed": false
+ },
+ {
+ "id": 4,
+ "name": "feature",
+ "color": "#228B22",
+ "description": null,
+ "open_issues_count": 0,
+ "closed_issues_count": 0,
+ "open_merge_requests_count": 0,
+ "subscribed": false
+ }
+]
+```
+
+## Create a new group label
+
+Create a new group label for a given group.
+
+```
+POST /groups/:id/labels
+```
+
+| Attribute | Type | Required | Description |
+| ------------- | ------- | -------- | ---------------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the label |
+| `color` | string | yes | The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the [CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords) |
+| `description` | string | no | The description of the label |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"name": "Feature Proposal", "color": "#FFA500", "description": "Describes new ideas" }' https://gitlab.example.com/api/v4/groups/5/labels
+```
+
+Example response:
+
+```json
+{
+ "id": 9,
+ "name": "Feature Proposal",
+ "color": "#FFA500",
+ "description": "Describes new ideas",
+ "open_issues_count": 0,
+ "closed_issues_count": 0,
+ "open_merge_requests_count": 0,
+ "subscribed": false
+}
+```
+
+## Update a group label
+
+Updates an existing group label. At least one parameter is required, to update the group label.
+
+```
+PUT /groups/:id/labels
+```
+
+| Attribute | Type | Required | Description |
+| ------------- | ------- | -------- | ---------------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the label |
+| `new_name` | string | no | The new name of the label |
+| `color` | string | no | The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the [CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords) |
+| `description` | string | no | The description of the label |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"name": "Feature Proposal", "new_name": "Feature Idea" }' https://gitlab.example.com/api/v4/groups/5/labels
+```
+
+Example response:
+
+```json
+{
+ "id": 9,
+ "name": "Feature Idea",
+ "color": "#FFA500",
+ "description": "Describes new ideas",
+ "open_issues_count": 0,
+ "closed_issues_count": 0,
+ "open_merge_requests_count": 0,
+ "subscribed": false
+}
+```
+
+## Delete a group label
+
+Deletes a group label with a given name.
+
+```
+DELETE /groups/:id/labels
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ------- | -------- | --------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the label |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels?name=bug
+```
+
+## Subscribe to a group label
+
+Subscribes the authenticated user to a group label to receive notifications. If
+the user is already subscribed to the label, the status code `304` is returned.
+
+```
+POST /groups/:id/labels/:label_id/subscribe
+```
+
+| Attribute | Type | Required | Description |
+| ---------- | ----------------- | -------- | ------------------------------------ |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `label_id` | integer or string | yes | The ID or title of a group's label |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels/9/subscribe
+```
+
+Example response:
+
+```json
+{
+ "id": 9,
+ "name": "Feature Idea",
+ "color": "#FFA500",
+ "description": "Describes new ideas",
+ "open_issues_count": 0,
+ "closed_issues_count": 0,
+ "open_merge_requests_count": 0,
+ "subscribed": true
+}
+```
+
+## Unsubscribe from a group label
+
+Unsubscribes the authenticated user from a group label to not receive
+notifications from it. If the user is not subscribed to the label, the status
+code `304` is returned.
+
+```
+POST /groups/:id/labels/:label_id/unsubscribe
+```
+
+| Attribute | Type | Required | Description |
+| ---------- | ----------------- | -------- | ------------------------------------ |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `label_id` | integer or string | yes | The ID or title of a group's label |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels/9/unsubscribe
+```
+
+Example response:
+
+```json
+{
+ "id": 9,
+ "name": "Feature Idea",
+ "color": "#FFA500",
+ "description": "Describes new ideas",
+ "open_issues_count": 0,
+ "closed_issues_count": 0,
+ "open_merge_requests_count": 0,
+ "subscribed": false
+}
+```
diff --git a/doc/api/issues.md b/doc/api/issues.md
index 6d8683601f6..ed3165d95df 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -31,6 +31,7 @@ GET /issues?iids[]=42&iids[]=43
GET /issues?author_id=5
GET /issues?assignee_id=5
GET /issues?my_reaction_emoji=star
+GET /issues?search=foo&in=title
```
| Attribute | Type | Required | Description |
@@ -46,6 +47,7 @@ GET /issues?my_reaction_emoji=star
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Search issues against their `title` and `description` |
+| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` |
| `created_after` | datetime | no | Return issues created on or after the given time |
| `created_before` | datetime | no | Return issues created on or before the given time |
| `updated_after` | datetime | no | Return issues updated on or after the given time |
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index c9b271eada3..d58cd45538d 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -24,6 +24,7 @@ GET /merge_requests?labels=bug,reproduced
GET /merge_requests?author_id=5
GET /merge_requests?my_reaction_emoji=star
GET /merge_requests?scope=assigned_to_me
+GET /merge_requests?search=foo&in=title
```
Parameters:
@@ -47,6 +48,7 @@ Parameters:
| `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` |
+| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` |
| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests |
```json
@@ -182,6 +184,7 @@ Parameters:
| `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` |
+| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests |
```json
[
@@ -991,6 +994,8 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `merge_request_iid` (required) - Internal ID of MR
- `merge_commit_message` (optional) - Custom merge commit message
+- `squash_commit_message` (optional) - Custom squash commit message
+- `squash` (optional) - if `true` the commits will be squashed into a single commit on merge
- `should_remove_source_branch` (optional) - if `true` removes the source branch
- `merge_when_pipeline_succeeds` (optional) - if `true` the MR is merged when the pipeline succeeds
- `sha` (optional) - if present, then this SHA must match the HEAD of the source branch, otherwise the merge will fail
diff --git a/doc/api/project_clusters.md b/doc/api/project_clusters.md
index 034b9172ffa..8efb98fe1fc 100644
--- a/doc/api/project_clusters.md
+++ b/doc/api/project_clusters.md
@@ -76,7 +76,7 @@ Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project owned by the authenticated user |
-| `cluster_id` | integer | yes | The ID of the cluster |
+| `cluster_id` | integer | yes | The ID of the cluster |
Example request:
@@ -157,12 +157,12 @@ Parameters:
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project owned by the authenticated user |
| `name` | String | yes | The name of the cluster |
-| `enabled` | Boolean | no | Determines if cluster is active or not, defaults to true |
-| `platform_kubernetes_attributes[api_url]` | String | yes | The URL to access the Kubernetes API |
+| `enabled` | Boolean | no | Determines if cluster is active or not, defaults to true |
+| `platform_kubernetes_attributes[api_url]` | String | yes | The URL to access the Kubernetes API |
| `platform_kubernetes_attributes[token]` | String | yes | The token to authenticate against Kubernetes |
-| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate |
-| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project |
-| `platform_kubernetes_attributes[authorization_type]` | String | no | The cluster authorization type: `rbac`, `abac` or `unknown_authorization`. Defaults to `rbac`. |
+| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate |
+| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project |
+| `platform_kubernetes_attributes[authorization_type]` | String | no | The cluster authorization type: `rbac`, `abac` or `unknown_authorization`. Defaults to `rbac`. |
Example request:
@@ -246,11 +246,11 @@ Parameters:
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project owned by the authenticated user |
| `cluster_id` | integer | yes | The ID of the cluster |
-| `name` | String | no | The name of the cluster |
-| `platform_kubernetes_attributes[api_url]` | String | no | The URL to access the Kubernetes API |
-| `platform_kubernetes_attributes[token]` | String | no | The token to authenticate against Kubernetes |
-| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate |
-| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project |
+| `name` | String | no | The name of the cluster |
+| `platform_kubernetes_attributes[api_url]` | String | no | The URL to access the Kubernetes API |
+| `platform_kubernetes_attributes[token]` | String | no | The token to authenticate against Kubernetes |
+| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate |
+| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project |
NOTE: **Note:**
`name`, `api_url`, `ca_cert` and `token` can only be updated if the cluster was added
diff --git a/doc/api/projects.md b/doc/api/projects.md
index 1296b435792..3c0c956ddc2 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -54,6 +54,7 @@ GET /projects
| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
+| `with_programming_language` | string | no | Limit by projects which use the given programming language |
| `wiki_checksum_failed` | boolean | no | Limit projects where the wiki checksum calculation has failed _([Introduced][ee-6137] in [GitLab Premium][eep] 11.2)_ |
| `repository_checksum_failed` | boolean | no | Limit projects where the repository checksum calculation has failed _([Introduced][ee-6137] in [GitLab Premium][eep] 11.2)_ |
| `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) |
@@ -279,6 +280,7 @@ GET /users/:user_id/projects
| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
+| `with_programming_language` | string | no | Limit by projects which use the given programming language |
| `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) |
```json
diff --git a/doc/api/settings.md b/doc/api/settings.md
index c329e3cdf24..2e0a2a09133 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -57,11 +57,13 @@ Example response:
"dsa_key_restriction": 0,
"ecdsa_key_restriction": 0,
"ed25519_key_restriction": 0,
+ "first_day_of_week": 0,
"enforce_terms": true,
"terms": "Hello world!",
"performance_bar_allowed_group_id": 42,
"instance_statistics_visibility_private": false,
- "user_show_add_ssh_key_message": true
+ "user_show_add_ssh_key_message": true,
+ "local_markdown_version": 0
}
```
@@ -113,11 +115,13 @@ Example response:
"dsa_key_restriction": 0,
"ecdsa_key_restriction": 0,
"ed25519_key_restriction": 0,
+ "first_day_of_week": 0,
"enforce_terms": true,
"terms": "Hello world!",
"performance_bar_allowed_group_id": 42,
"instance_statistics_visibility_private": false,
- "user_show_add_ssh_key_message": true
+ "user_show_add_ssh_key_message": true,
+ "local_markdown_version": 0
}
```
@@ -157,6 +161,7 @@ are listed in the descriptions of the relevant settings.
| `email_author_in_body` | boolean | no | Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead. |
| `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. |
| `enforce_terms` | boolean | no | (**If enabled, requires:** `terms`) Enforce application ToS to all users. |
+| `first_day_of_week` | integer | no | Start day of the week for calendar views and date pickers. Valid values are `0` (default) for Sunday and `1` for Monday. |
| `gitaly_timeout_default` | integer | no | Default Gitaly timeout, in seconds. This timeout is not enforced for git fetch/push operations or Sidekiq jobs. Set to `0` to disable timeouts. |
| `gitaly_timeout_fast` | integer | no | Gitaly fast operation timeout, in seconds. Some Gitaly operations are expected to be fast. If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' can help maintain the stability of the GitLab instance. Set to `0` to disable timeouts. |
| `gitaly_timeout_medium` | integer | no | Medium Gitaly timeout, in seconds. This should be a value between the Fast and the Default timeout. Set to `0` to disable timeouts. |
@@ -235,3 +240,4 @@ are listed in the descriptions of the relevant settings.
| `user_oauth_applications` | boolean | no | Allow users to register any application to use GitLab as an OAuth provider. |
| `user_show_add_ssh_key_message` | boolean | no | When set to `false` disable the "You won't be able to pull or push project code via SSH" warning shown to users with no uploaded SSH key. |
| `version_check_enabled` | boolean | no | Let GitLab inform you when an update is available. |
+| `local_markdown_version` | integer | no | Increase this value when any cached markdown should be invalidated. |
diff --git a/doc/api/snippets.md b/doc/api/snippets.md
index 2cbd041d132..f90447e124e 100644
--- a/doc/api/snippets.md
+++ b/doc/api/snippets.md
@@ -1,43 +1,100 @@
# Snippets API
-> [Introduced][ce-6373] in GitLab 8.15.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6373) in GitLab 8.15.
+
+Snippets API operates on [snippets](../user/snippets.md).
## Snippet visibility level
Snippets in GitLab can be either private, internal, or public.
You can set it with the `visibility` field in the snippet.
-Constants for snippet visibility levels are:
+Valid values for snippet visibility levels are:
-| Visibility | Description |
-| ---------- | ----------- |
-| `private` | The snippet is visible only to the snippet creator |
-| `internal` | The snippet is visible for any logged in user |
-| `public` | The snippet can be accessed without any authentication |
+| Visibility | Description |
+|:-----------|:----------------------------------------------------|
+| `private` | Snippet is visible only to the snippet creator. |
+| `internal` | Snippet is visible for any logged in user. |
+| `public` | Snippet can be accessed without any authentication. |
-## List snippets
+## List all snippets for a user
-Get a list of current user's snippets.
+Get a list of the current user's snippets.
-```
+```text
GET /snippets
```
-## Single snippet
+Example request:
-Get a single snippet.
+```sh
+curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/snippets
+```
+
+Example response:
+```json
+[
+ {
+ "id": 42,
+ "title": "Voluptatem iure ut qui aut et consequatur quaerat.",
+ "file_name": "mclaughlin.rb",
+ "description": null,
+ "visibility": "internal",
+ "author": {
+ "id": 22,
+ "name": "User 0",
+ "username": "user0",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon",
+ "web_url": "http://localhost:3000/user0"
+ },
+ "updated_at": "2018-09-18T01:12:26.383Z",
+ "created_at": "2018-09-18T01:12:26.383Z",
+ "project_id": null,
+ "web_url": "http://localhost:3000/snippets/42",
+ "raw_url": "http://localhost:3000/snippets/42/raw"
+ },
+ {
+ "id": 41,
+ "title": "Ut praesentium non et atque.",
+ "file_name": "ondrickaemard.rb",
+ "description": null,
+ "visibility": "internal",
+ "author": {
+ "id": 22,
+ "name": "User 0",
+ "username": "user0",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon",
+ "web_url": "http://localhost:3000/user0"
+ },
+ "updated_at": "2018-09-18T01:12:26.360Z",
+ "created_at": "2018-09-18T01:12:26.360Z",
+ "project_id": null,
+ "web_url": "http://localhost:3000/snippets/41",
+ "raw_url": "http://localhost:3000/snippets/41/raw"
+ }
+]
```
+
+## Get a single snippet
+
+Get a single snippet.
+
+```text
GET /snippets/:id
```
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | Integer | yes | The ID of a snippet |
+| Attribute | Type | Required | Description |
+|:----------|:--------|:---------|:---------------------------|
+| `id` | integer | yes | ID of snippet to retrieve. |
+
+Example request:
-```bash
+```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/snippets/1
```
@@ -69,46 +126,52 @@ Example response:
Get a single snippet's raw contents.
-```
+```text
GET /snippets/:id/raw
```
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | Integer | yes | The ID of a snippet |
+| Attribute | Type | Required | Description |
+|:----------|:--------|:---------|:---------------------------|
+| `id` | integer | yes | ID of snippet to retrieve. |
-```bash
+Example request:
+
+```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/snippets/1/raw
```
Example response:
-```
+```text
Hello World snippet
```
## Create new snippet
-Creates a new snippet. The user must have permission to create new snippets.
+Create a new snippet.
-```
+NOTE: **Note:**
+The user must have permission to create new snippets.
+
+```text
POST /snippets
```
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `title` | String | yes | The title of a snippet |
-| `file_name` | String | yes | The name of a snippet file |
-| `content` | String | yes | The content of a snippet |
-| `description` | String | no | The description of a snippet |
-| `visibility` | String | no | The snippet's visibility |
+| Attribute | Type | Required | Description |
+|:--------------|:-------|:---------|:---------------------------------------------------|
+| `title` | string | yes | Title of a snippet. |
+| `file_name` | string | yes | Name of a snippet file. |
+| `content` | string | yes | Content of a snippet. |
+| `description` | string | no | Description of a snippet. |
+| `visibility` | string | no | Snippet's [visibility](#snippet-visibility-level). |
+Example request:
-```bash
+```sh
curl --request POST \
--data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' \
--header 'Content-Type: application/json' \
@@ -142,25 +205,29 @@ Example response:
## Update snippet
-Updates an existing snippet. The user must have permission to change an existing snippet.
+Update an existing snippet.
-```
+NOTE: **Note:**
+The user must have permission to change an existing snippet.
+
+```text
PUT /snippets/:id
```
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | Integer | yes | The ID of a snippet |
-| `title` | String | no | The title of a snippet |
-| `file_name` | String | no | The name of a snippet file |
-| `description` | String | no | The description of a snippet |
-| `content` | String | no | The content of a snippet |
-| `visibility` | String | no | The snippet's visibility |
+| Attribute | Type | Required | Description |
+|:--------------|:--------|:---------|:---------------------------------------------------|
+| `id` | integer | yes | ID of snippet to update. |
+| `title` | string | no | Title of a snippet. |
+| `file_name` | string | no | Name of a snippet file. |
+| `description` | string | no | Description of a snippet. |
+| `content` | string | no | Content of a snippet. |
+| `visibility` | string | no | Snippet's [visibility](#snippet-visibility-level). |
+Example request:
-```bash
+```sh
curl --request PUT \
--data '{"title": "foo", "content": "bar"}' \
--header 'Content-Type: application/json' \
@@ -194,38 +261,49 @@ Example response:
## Delete snippet
-Deletes an existing snippet.
+Delete an existing snippet.
-```
+```text
DELETE /snippets/:id
```
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | Integer | yes | The ID of a snippet |
+| Attribute | Type | Required | Description |
+|:----------|:--------|:---------|:-------------------------|
+| `id` | integer | yes | ID of snippet to delete. |
+Example request:
-```
+```sh
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/snippets/1"
```
-upon successful delete a `204 No content` HTTP code shall be expected, with no data,
-but if the snippet is non-existent, a `404 Not Found` will be returned.
+The following are possible return codes:
-## Explore all public snippets
+| Code | Description |
+|:------|:--------------------------------------------|
+| `204` | Delete was successful. No data is returned. |
+| `404` | The snippet wasn't found. |
-```
+## List all public snippets
+
+List all public snippets.
+
+```text
GET /snippets/public
```
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `per_page` | Integer | no | number of snippets to return per page |
-| `page` | Integer | no | the page to retrieve |
+Parameters:
+
+| Attribute | Type | Required | Description |
+|:-----------|:--------|:---------|:---------------------------------------|
+| `per_page` | integer | no | Number of snippets to return per page. |
+| `page` | integer | no | Page to retrieve. |
-```bash
+Example request:
+
+```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/snippets/public?per_page=2&page=1
```
@@ -273,21 +351,22 @@ Example response:
## Get user agent details
-> **Notes:**
-> [Introduced][ce-29508] in GitLab 9.4.
-
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12655) in GitLab 9.4.
-Available only for admins.
+NOTE: **Note:**
+Available only for administrators.
-```
+```text
GET /snippets/:id/user_agent_detail
```
-| Attribute | Type | Required | Description |
-|-------------|---------|----------|--------------------------------------|
-| `id` | Integer | yes | The ID of a snippet |
+| Attribute | Type | Required | Description |
+|:----------|:--------|:---------|:---------------|
+| `id` | integer | yes | ID of snippet. |
-```bash
+Example request:
+
+```sh
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/snippets/1/user_agent_detail
```
@@ -300,6 +379,3 @@ Example response:
"akismet_submitted": false
}
```
-
-[ce-6373]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6373
-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12655
diff --git a/doc/api/users.md b/doc/api/users.md
index 6000b9b900f..fd8778abb17 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -1212,6 +1212,7 @@ The activities that update the timestamp are:
- Git HTTP/SSH activities (such as clone, push)
- User logging in into GitLab
+ - User visiting pages related to Dashboards, Projects, Issues and Merge Requests ([introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/54947) in GitLab 11.8)
By default, it shows the activity for all users in the last 6 months, but this can be
amended by using the `from` parameter.
diff --git a/doc/ci/examples/browser_performance.md b/doc/ci/examples/browser_performance.md
index 7c3b3a65675..b47038011de 100644
--- a/doc/ci/examples/browser_performance.md
+++ b/doc/ci/examples/browser_performance.md
@@ -41,7 +41,7 @@ The above example will create a `performance` job in your CI/CD pipeline and wil
Sitespeed.io against the webpage you defined in `URL` to gather key metrics.
The [GitLab plugin](https://gitlab.com/gitlab-org/gl-performance) for
Sitespeed.io is downloaded in order to save the report as a
-[Performance report artifact](https://docs.gitlab.com/ee//ci/yaml/README.html#artifactsreportsperformance)
+[Performance report artifact](../yaml/README.md#artifactsreportsperformance-premium)
that you can later download and analyze.
Due to implementation limitations we always take the latest Performance artifact available.
diff --git a/doc/ci/examples/code_quality.md b/doc/ci/examples/code_quality.md
index ae000b9d30d..3e7d6e7e3f7 100644
--- a/doc/ci/examples/code_quality.md
+++ b/doc/ci/examples/code_quality.md
@@ -36,7 +36,7 @@ code_quality:
The above example will create a `code_quality` job in your CI/CD pipeline which
will scan your source code for code quality issues. The report will be saved as a
-[Code Quality report artifact](../../ci/yaml/README.md#artifactsreportscodequality)
+[Code Quality report artifact](../yaml/README.md#artifactsreportscodequality-starter)
that you can later download and analyze.
Due to implementation limitations we always take the latest Code Quality artifact available.
diff --git a/doc/ci/examples/container_scanning.md b/doc/ci/examples/container_scanning.md
index 31c3df81fef..e8e9c73d1b2 100644
--- a/doc/ci/examples/container_scanning.md
+++ b/doc/ci/examples/container_scanning.md
@@ -51,7 +51,7 @@ The above example will create a `container_scanning` job in your CI/CD pipeline,
the image from the [Container Registry](../../user/project/container_registry.md)
(whose name is defined from the two `CI_APPLICATION_` variables) and scan it
for possible vulnerabilities. The report will be saved as a
-[Container Scanning report artifact](https://docs.gitlab.com/ee//ci/yaml/README.html#artifactsreportscontainer_scanning)
+[Container Scanning report artifact](../yaml/README.md#artifactsreportscontainer_scanning-ultimate)
that you can later download and analyze.
Due to implementation limitations we always take the latest Container Scanning artifact available.
diff --git a/doc/ci/examples/dast.md b/doc/ci/examples/dast.md
index 0ca89eb6700..ab0ca13d2cf 100644
--- a/doc/ci/examples/dast.md
+++ b/doc/ci/examples/dast.md
@@ -40,7 +40,7 @@ dast:
The above example will create a `dast` job in your CI/CD pipeline which will run
the tests on the URL defined in the `website` variable (change it to use your
own) and scan it for possible vulnerabilities. The report will be saved as a
-[DAST report artifact](https://docs.gitlab.com/ee//ci/yaml/README.html#artifactsreportsdast)
+[DAST report artifact](../yaml/README.md#artifactsreportsdast-ultimate)
that you can later download and analyze.
Due to implementation limitations we always take the latest DAST artifact available.
diff --git a/doc/ci/interactive_web_terminal/index.md b/doc/ci/interactive_web_terminal/index.md
index 0cf9daed22f..2a4160f62b0 100644
--- a/doc/ci/interactive_web_terminal/index.md
+++ b/doc/ci/interactive_web_terminal/index.md
@@ -1,4 +1,4 @@
-# Interactive Web Terminals **[CORE ONLY]**
+# Interactive Web Terminals
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/50144) in GitLab 11.3.
@@ -9,10 +9,11 @@ is deployed, some [security precautions](../../administration/integration/termin
taken to protect the users.
NOTE: **Note:**
-GitLab.com does not support interactive web terminal at the moment – neither
-using shared GitLab.com runners nor your own runners. Please follow
-[this issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/52611) for
-progress.
+[Shared runners on GitLab.com](../quick_start/README.md#shared-runners) do not
+provide an interactive web terminal. Follow [this
+issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/52611) for progress on
+adding support. For groups and projects hosted on GitLab.com, interactive web
+terminals are available when using your own group or project runner.
## Configuration
diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md
index d2a00b9218d..c41f3b7e82d 100644
--- a/doc/ci/pipelines.md
+++ b/doc/ci/pipelines.md
@@ -3,7 +3,7 @@
> Introduced in GitLab 8.8.
NOTE: **Note:**
-If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository-starter),
you may need to enable pipeline triggering in your project's
**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md
index 1ec8a8c89c9..9684cb6ed98 100644
--- a/doc/ci/quick_start/README.md
+++ b/doc/ci/quick_start/README.md
@@ -127,7 +127,7 @@ Now if you go to the **Pipelines** page you will see that the pipeline is
pending.
NOTE: **Note:**
-If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository-starter),
you may need to enable pipeline triggering in your project's
**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md
index 8b3a7b63e62..f4d7b9ad194 100644
--- a/doc/ci/review_apps/index.md
+++ b/doc/ci/review_apps/index.md
@@ -60,7 +60,7 @@ To get a better understanding of Review Apps, review documentation on how enviro
1. Learn about [environments](../environments.md) and their role in the development workflow.
1. Learn about [CI variables](../variables/README.md) and how they can be used in your CI jobs.
1. Explore the [`environment` syntax](../yaml/README.md#environment) as defined in `.gitlab-ci.yml`. This will become a primary reference.
-1. Additionally, find out about [manual actions](../environments.md#manual-actions) and how you can use them to deploy to critical environments like production with the push of a button.
+1. Additionally, find out about [manual actions](../environments.md#manually-deploying-to-environments) and how you can use them to deploy to critical environments like production with the push of a button.
1. Follow the [example tutorials](#examples). These will guide you through setting up infrastructure and using Review Apps.
### Configuring dynamic environments
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index 12cae3ce2e9..7498617ed2c 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -65,7 +65,7 @@ future GitLab releases.**
| **CI_COMMIT_TITLE** | 10.8 | all | The title of the commit - the full first line of the message |
| **CI_CONFIG_PATH** | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` |
| **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled |
-| **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.|
+| **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.|
| **CI_DEPLOY_USER** | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.|
| **CI_DISPOSABLE_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. |
| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. |
@@ -98,7 +98,7 @@ future GitLab releases.**
| **CI_PIPELINE_SOURCE** | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, and `pipeline`. For pipelines created before GitLab 9.5, this will show as `unknown` |
| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] |
| **CI_PIPELINE_URL** | 11.1 | 0.5 | Pipeline details URL |
-| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run |
+| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see [Advanced configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) for GitLab Runner. |
| **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally |
| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) |
| **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built |
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 4c39b14b1d0..984878b6c9b 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -11,7 +11,7 @@ If you want a quick introduction to GitLab CI, follow our
[quick start guide](../quick_start/README.md).
NOTE: **Note:**
-If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository-starter),
you may need to enable pipeline triggering in your project's
**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
@@ -776,7 +776,7 @@ In the above example we set up the `review_app` job to deploy to the `review`
environment, and we also defined a new `stop_review_app` job under `on_stop`.
Once the `review_app` job is successfully finished, it will trigger the
`stop_review_app` job based on what is defined under `when`. In this case we
-set it up to `manual` so it will need a [manual action](#manual-actions) via
+set it up to `manual` so it will need a [manual action](#whenmanual) via
GitLab's web interface in order to run.
The `stop_review_app` job is **required** to have the following keywords defined:
diff --git a/doc/development/README.md b/doc/development/README.md
index 05715274a81..d5829e31343 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -123,3 +123,7 @@ description: 'Learn how to contribute to GitLab.'
## Compliance
- [Licensing](licensing.md) for ensuring license compliance
+
+## Go guides
+
+- [Go Guidelines](go_guide/index.md)
diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md
index 24feb1378a1..c5344139ab4 100644
--- a/doc/development/contributing/issue_workflow.md
+++ b/doc/development/contributing/issue_workflow.md
@@ -20,7 +20,7 @@ All labels, their meaning and priority are defined on the
If you come across an issue that has none of these, and you're allowed to set
labels, you can _always_ add the team and type, and often also the subject.
-[milestones-page]: https://gitlab.com/gitlab-org/gitlab-ce/milestones
+[milestones-page]: https://gitlab.com/groups/gitlab-org/-/milestones
## Type labels
diff --git a/doc/development/contributing/merge_request_workflow.md b/doc/development/contributing/merge_request_workflow.md
index 9bef0635e3f..19b6181c9a2 100644
--- a/doc/development/contributing/merge_request_workflow.md
+++ b/doc/development/contributing/merge_request_workflow.md
@@ -86,6 +86,9 @@ request is as follows:
guidelines](../merge_request_performance_guidelines.md).
1. For tests that use Capybara or PhantomJS, see this [article on how
to write reliable asynchronous tests](https://robots.thoughtbot.com/write-reliable-asynchronous-integration-tests-with-capybara).
+1. If your merge request introduces changes that require additional steps when
+ installing GitLab from source, add them to `doc/install/installation.md` in
+ the same merge request.
Please keep the change in a single MR **as small as possible**. If you want to
contribute a large feature think very hard what the minimum viable change is.
diff --git a/doc/development/contributing/style_guides.md b/doc/development/contributing/style_guides.md
index 6f1ba5d62a5..0eedef5e14f 100644
--- a/doc/development/contributing/style_guides.md
+++ b/doc/development/contributing/style_guides.md
@@ -21,6 +21,7 @@
of _prohibited this user from being saved due to the following errors:_ the
text should be _sorry, we could not create your account because:_
1. Code should be written in [US English][us-english]
+1. [Go](../go_guide/index.md)
This is also the style used by linting tools such as
[RuboCop](https://github.com/bbatsov/rubocop) and [Hound CI](https://houndci.com).
diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md
index 828f9bfeec6..aac98c6ee7f 100644
--- a/doc/development/documentation/index.md
+++ b/doc/development/documentation/index.md
@@ -27,7 +27,7 @@ The source of the documentation is maintained in the following repository locati
| Project | Path |
| --- | --- |
| [GitLab Community Edition](https://gitlab.com/gitlab-org/gitlab-ce/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc) |
-| [GitLab Enterprise Edition](https://gitlab.com/gitlab-org/gitlab-ce/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ee/tree/master/doc) |
+| [GitLab Enterprise Edition](https://gitlab.com/gitlab-org/gitlab-ee/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ee/tree/master/doc) |
| [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-runner/) | [`/docs`](https://gitlab.com/gitlab-org/gitlab-runner/tree/master/docs) |
| [Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ee/tree/master/doc) |
@@ -78,7 +78,7 @@ you can immediately tell that you are navigating to user-related documentation
about project features; specifically about merge requests. Our site's paths match
those of our repository, so the clear structure also makes documentation easier to update.
-While the documentation is home to a variety of content types, we do not organize by content type.
+While the documentation is home to a variety of content types, we do not organize by content type.
For example, do not create groupings of similar media types (e.g. indexes of all articles, videos, etc.).
Similarly, we do not use glossaries or FAQs. Such grouping of content by type makes
it difficult to browse for the information you need and difficult to maintain up-to-date content.
@@ -498,7 +498,7 @@ If you want to know the in-depth details, here's what's really happening:
The following GitLab features are used among others:
-- [Manual actions](../../ci/yaml/README.md#manual-actions)
+- [Manual actions](../../ci/yaml/README.md#whenmanual)
- [Multi project pipelines](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html)
- [Review Apps](../../ci/review_apps/index.md)
- [Artifacts](../../ci/yaml/README.md#artifacts)
diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md
index 790b1bf951b..e0985922443 100644
--- a/doc/development/ee_features.md
+++ b/doc/development/ee_features.md
@@ -839,6 +839,20 @@ For example there can be an
`app/assets/javascripts/protected_branches/protected_branches_bundle.js` and an
EE counterpart
`ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js`.
+The corresponding import statement would then look like this:
+
+```javascript
+// app/assets/javascripts/protected_branches/protected_branches_bundle.js
+import bundle from '~/protected_branches/protected_branches_bundle.js';
+
+// ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
+// (only works in EE)
+import bundle from 'ee/protected_branches/protected_branches_bundle.js';
+
+// in CE: app/assets/javascripts/protected_branches/protected_branches_bundle.js
+// in EE: ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
+import bundle from 'ee_else_ce/protected_branches/protected_branches_bundle.js';
+```
See the frontend guide [performance section](./fe_guide/performance.md) for
information on managing page-specific javascript within EE.
diff --git a/doc/development/file_storage.md b/doc/development/file_storage.md
index b90dc90e424..597812c8c49 100644
--- a/doc/development/file_storage.md
+++ b/doc/development/file_storage.md
@@ -18,6 +18,7 @@ There are many places where file uploading is used, according to contexts:
- Issues/MR/Notes Legacy Markdown attachments
- CI Artifacts (archive, metadata, trace)
- LFS Objects
+ - Merge request diffs
## Disk storage
@@ -37,6 +38,7 @@ they are still not 100% standardized. You can see them below:
| Issues/MR/Notes Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note |
| CI Artifacts (CE) | yes | shared/artifacts/:disk_hash[0..1]/:disk_hash[2..3]/:disk_hash/:year_:month_:date/:job_id/:job_artifact_id (:disk_hash is SHA256 digest of project_id) | `JobArtifactUploader` | Ci::JobArtifact |
| LFS Objects (CE) | yes | shared/lfs-objects/:hex/:hex/:object_hash | `LfsObjectUploader` | LfsObject |
+| External merge request diffs | yes | shared/external-diffs/merge_request_diffs/mr-:parent_id/diff-:id | `ExternalDiffUploader` | MergeRequestDiff |
CI Artifacts and LFS Objects behave differently in CE and EE. In CE they inherit the `GitlabUploader`
while in EE they inherit the `ObjectStorage` and store files in and S3 API compatible object store.
diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md
new file mode 100644
index 00000000000..cdc806a2d31
--- /dev/null
+++ b/doc/development/go_guide/index.md
@@ -0,0 +1,216 @@
+# Go standards and style guidelines
+
+This document describes various guidelines and best practices for GitLab
+projects using the [Go language](https://golang.org).
+
+## Overview
+
+GitLab is built on top of [Ruby on Rails](https://rubyonrails.org/), but we're
+also using Go for projects where it makes sense. Go is a very powerful
+language, with many advantages, and is best suited for projects with a lot of
+IO (disk/network access), HTTP requests, parallel processing, etc. Since we
+have both Ruby on Rails and Go at GitLab, we should evaluate carefully which of
+the two is best for the job.
+
+This page aims to define and organize our Go guidelines, based on our various
+experiences. Several projects were started with different standards and they
+can still have specifics. They will be described in their respective
+`README.md` or `PROCESS.md` files.
+
+## Code Review
+
+We follow the common principles of
+[Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments).
+
+Reviewers and maintainers should pay attention to:
+
+- `defer` functions: ensure the presence when needed, and after `err` check.
+- Inject dependencies as parameters.
+- Void structs when marshalling to JSON (generates `null` instead of `[]`).
+
+### Security
+
+Security is our top priority at GitLab. During code reviews, we must take care
+of possible security breaches in our code:
+
+- XSS when using text/template
+- CSRF Protection using Gorilla
+- Use a Go version without known vulnerabilities
+- Don't leak secret tokens
+- SQL injections
+
+Remember to run
+[SAST](https://docs.gitlab.com/ee/user/project/merge_requests/sast.html)
+**[ULTIMATE]** on your project (or at least the [gosec
+analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/gosec)),
+and to follow our [Security
+requirements](../code_review.md#security-requirements).
+
+Web servers can take advantages of middlewares like [Secure](https://github.com/unrolled/secure).
+
+### Finding a reviewer
+
+Many of our projects are too small to have full-time maintainers. That's why we
+have a shared pool of Go reviewers at GitLab. To find a reviewer, use the
+[Engineering Projects](https://about.gitlab.com/handbook/engineering/projects/)
+page in the handbook. "GitLab Community Edition (CE)" and "GitLab Community
+Edition (EE)" both have a "Go" section with its list of reviewers.
+
+To add yourself to this list, add the following to your profile in the
+[team.yml](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/team.yml)
+file and ask your manager to review and merge.
+
+```yaml
+projects:
+ gitlab-ee: reviewer go
+ gitlab-ce: reviewer go
+```
+
+## Code style and format
+
+- Avoid global variables, even in packages. By doing so you will introduce side
+ effects if the package is included multiple times.
+- Use `go fmt` before committing ([Gofmt](https://golang.org/cmd/gofmt/) is a
+ tool that automatically formats Go source code).
+
+### Automatic linting
+
+All Go projects should include these GitLab CI/CD jobs:
+
+```yaml
+go lint:
+ image: golang:1.11
+ script:
+ - go get -u golang.org/x/lint/golint
+ - golint -set_exit_status
+```
+
+Once [recursive includes](https://gitlab.com/gitlab-org/gitlab-ce/issues/56836)
+become available, you will be able to share job templates like this
+[analyzer](https://gitlab.com/gitlab-org/security-products/ci-templates/raw/master/includes-dev/analyzer.yml).
+
+## Dependencies
+
+Dependencies should be kept to the minimum. The introduction of a new
+dependency should be argued in the merge request, as per our [Approval
+Guidelines](../code_review.html#approval-guidelines). Both [License
+Management](https://docs.gitlab.com/ee/user/project/merge_requests/license_management.html)
+**[ULTIMATE]** and [Dependency
+Scanning](https://docs.gitlab.com/ee/user/project/merge_requests/dependency_scanning.html)
+**[ULTIMATE]** should be activated on all projects to ensure new dependencies
+security status and license compatibility.
+
+### Modules
+
+Since Go 1.11, a standard dependency system is available behind the name [Go
+Modules](https://github.com/golang/go/wiki/Modules). It provides a way to
+define and lock dependencies for reproducible builds. It should be used
+whenever possible.
+
+There was a [bug on modules
+checksums](https://github.com/golang/go/issues/29278) in Go < v1.11.4, so make
+sure to use at least this version to avoid `checksum mismatch` errors.
+
+### ORM
+
+We don't use object-relational mapping libraries (ORMs) at GitLab (except
+[ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html) in
+Ruby on Rails). Projects can be structured with services to avoid them.
+[PQ](https://github.com/lib/pq) should be enough to interact with PostgreSQL
+databases.
+
+### Migrations
+
+In the rare event of managing a hosted database, it's necessary to use a
+migration system like ActiveRecord is providing. A simple library like
+[Journey](https://github.com/db-journey/journey), designed to be used in
+`postgres` containers, can be deployed as long-running pods. New versions will
+deploy a new pod, migrating the data automatically.
+
+## Testing
+
+We should not use any specific library or framework for testing, as the
+[standard library](https://golang.org/pkg/) provides already everything to get
+started. For example, some external dependencies might be worth considering in
+case we decide to use a specific library or framework:
+
+- [Testify](https://github.com/stretchr/testify)
+- [httpexpect](https://github.com/gavv/httpexpect)
+
+Use [subtests](https://blog.golang.org/subtests) whenever possible to improve
+code readability and test output.
+
+### Benchmarks
+
+Programs handling a lot of IO or complex operations should always include
+[benchmarks](https://golang.org/pkg/testing/#hdr-Benchmarks), to ensure
+performance consistency over time.
+
+## CLIs
+
+Every Go program is launched from the command line.
+[cli](https://github.com/urfave/cli) is a convenient package to create command
+line apps. It should be used whether the project is a daemon or a simple cli
+tool. Flags can be mapped to [environment
+variables](https://github.com/urfave/cli#values-from-the-environment) directly,
+which documents and centralizes at the same time all the possible command line
+interactions with the program. Don't use `os.GetEnv`, it hides variables deep
+in the code.
+
+## Daemons
+
+### Logging
+
+The usage of a logging library is strongly recommended for daemons. Even though
+there is a `log` package in the standard library, we generally use
+[logrus](https://github.com/sirupsen/logrus). Its plugin ("hooks") system
+makes it a powerful logging library, with the ability to add notifiers and
+formatters at the logger level directly.
+
+### Tracing and Correlation
+
+[LabKit](https://gitlab.com/gitlab-org/labkit) is a place to keep common
+libraries for Go services. Currently it's vendored into two projects:
+Workhorse and Gitaly, and it exports two main (but related) pieces of
+functionality:
+
+- [`gitlab.com/gitlab-org/labkit/correlation`](https://gitlab.com/gitlab-org/labkit/tree/master/correlation):
+ for propagating and extracting correlation ids between services.
+- [`gitlab.com/gitlab-org/labkit/tracing`](https://gitlab.com/gitlab-org/labkit/tree/master/tracing):
+ for instrumenting Go libraries for distributed tracing.
+
+This gives us a thin abstraction over underlying implementations that is
+consistent across Workhorse, Gitaly, and, in future, other Go servers. For
+example, in the case of `gitlab.com/gitlab-org/labkit/tracing` we can switch
+from using Opentracing directly to using Zipkin or Gokit's own tracing wrapper
+without changes to the application code, while still keeping the same
+consistent configuration mechanism (i.e. the `GITLAB_TRACING` environment
+variable).
+
+### Context
+
+Since daemons are long-running applications, they should have mechanisms to
+manage cancellations, and avoid unnecessary resources consumption (which could
+lead to DDOS vulnerabilities). [Go
+Context](https://github.com/golang/go/wiki/CodeReviewComments#contexts) should
+be used in functions that can block and passed as the first parameter.
+
+## Dockerfiles
+
+Every project should have a `Dockerfile` at the root of their repository, to
+build and run the project. Since Go program are static binaries, they should
+not require any external dependency, and shells in the final image are useless.
+We encourage [Multistage
+builds](https://docs.docker.com/develop/develop-images/multistage-build/):
+
+- They let the user build the project with the right Go version and
+ dependencies.
+- They generate a small, self-contained image, derived from `Scratch`.
+
+Generated docker images should have the program at their `Entrypoint` to create
+portable commands. That way, anyone can run the image, and without parameters
+it will display its help message (if `cli` has been used).
+
+---
+
+[Return to Development documentation](../README.md).
diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md
index 00db58a45a2..223585ebb55 100644
--- a/doc/development/i18n/externalization.md
+++ b/doc/development/i18n/externalization.md
@@ -231,11 +231,11 @@ This makes use of [`Intl.DateTimeFormat`].
- In Ruby/HAML, we have two ways of adding format to dates and times:
1. **Through the `l` helper**, i.e. `l(active_session.created_at, format: :short)`. We have some predefined formats for
-[dates](https://gitlab.com/gitlab-org/gitlab-ce/blob/v11.7.0/config/locales/en.yml#L54) and [times](https://gitlab.com/gitlab-org/gitlab-ce/blob/v11.7.0/config/locales/en.yml#L261).
+ [dates](https://gitlab.com/gitlab-org/gitlab-ce/blob/v11.7.0/config/locales/en.yml#L54) and [times](https://gitlab.com/gitlab-org/gitlab-ce/blob/v11.7.0/config/locales/en.yml#L261).
If you need to add a new format, because other parts of the code could benefit from it,
you'll need to add it to [en.yml](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/locales/en.yml) file.
- 2. **Through `strftime`**, i.e. `milestone.start_date.strftime('%b %-d')`. We use `strftime` in case none of the formats
- defined on [en.yml](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/locales/en.yml) matches the date/time
+ 1. **Through `strftime`**, i.e. `milestone.start_date.strftime('%b %-d')`. We use `strftime` in case none of the formats
+ defined on [en.yml](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/locales/en.yml) matches the date/time
specifications we need, and if there is no need to add it as a new format because is very particular (i.e. it's only used in a single view).
## Best practices
diff --git a/doc/development/i18n/index.md b/doc/development/i18n/index.md
index c44690a4c5d..5e4923341af 100644
--- a/doc/development/i18n/index.md
+++ b/doc/development/i18n/index.md
@@ -51,4 +51,4 @@ able to proofread and instructions on becoming a proofreader yourself.
Translations are typically included in the next major or minor release.
-See [Merging translations from Crowdin](merging_translations.md)
+See [Merging translations from Crowdin](merging_translations.md).
diff --git a/doc/development/i18n/merging_translations.md b/doc/development/i18n/merging_translations.md
index d172aa6da21..2fa7558d30b 100644
--- a/doc/development/i18n/merging_translations.md
+++ b/doc/development/i18n/merging_translations.md
@@ -4,7 +4,7 @@ Crowdin automatically syncs the `gitlab.pot` file presenting newly
added translations to the community of translators.
At the same time, it creates a merge request to merge all newly added
-& approved translations. Find the [merge reqeust created by
+& approved translations. Find the [merge request created by
`gitlab-crowdin-bot`](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests?scope=all&utf8=%E2%9C%93&state=opened&author_username=gitlab-crowdin-bot)
to see new and merged merge requests. They are created in EE and need
to be ported to CE manually.
diff --git a/doc/development/sql.md b/doc/development/sql.md
index 06005a0a6f8..47519d39e74 100644
--- a/doc/development/sql.md
+++ b/doc/development/sql.md
@@ -256,32 +256,12 @@ violation, for example.
Using transactions does not solve this problem.
-The following pattern should be used to avoid the problem:
+To solve this we've added the `ApplicationRecord.safe_find_or_create_by`.
-```ruby
-Project.transaction do
- begin
- User.find_or_create_by(username: "foo")
- rescue ActiveRecord::RecordNotUnique
- retry
- end
-end
-```
-
-If the above block is run inside a transaction and hits the race
-condition, the transaction is aborted and we cannot simply retry (any
-further queries inside the aborted transaction are going to fail). We
-can employ [nested transactions](http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions)
-here to only rollback the "inner transaction". Note that `requires_new: true` is required here.
+This method can be used just as you would the normal
+`find_or_create_by` but it wraps the call in a *new* transaction and
+retries if it were to fail because of an
+`ActiveRecord::RecordNotUnique` error.
-```ruby
-Project.transaction do
- begin
- User.transaction(requires_new: true) do
- User.find_or_create_by(username: "foo")
- end
- rescue ActiveRecord::RecordNotUnique
- retry
- end
-end
-```
+To be able to use this method, make sure the model you want to use
+this on inherits from `ApplicationRecord`.
diff --git a/doc/install/installation.md b/doc/install/installation.md
index 1f65e3415d1..a8064ae046e 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -139,8 +139,8 @@ Then select 'Internet Site' and press enter to confirm the hostname.
The Ruby interpreter is required to run GitLab.
-**Note:** The current supported Ruby (MRI) version is 2.3.x. GitLab 9.0 dropped
-support for Ruby 2.1.x.
+**Note:** The current supported Ruby (MRI) version is 2.5.x. GitLab 11.6
+ dropped support for Ruby 2.4.x.
The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab
in production, frequently leads to hard to diagnose problems. For example,
@@ -345,11 +345,15 @@ cd /home/git
```sh
# Clone GitLab repository
-sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 11-7-stable gitlab
+sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b X-Y-stable gitlab
```
+Make sure to replace `X-Y-stable` with the stable branch that matches the
+version you want to install. For example, if you want to install 11.8 you would
+use the branch name `11-8-stable`.
+
CAUTION: **Caution:**
-You can change `11-7-stable` to `master` if you want the *bleeding edge* version, but never install `master` on a production server!
+You can change `X-Y-stable` to `master` if you want the *bleeding edge* version, but never install `master` on a production server!
### Configure It
@@ -691,6 +695,11 @@ sudo nginx -t
You should receive `syntax is okay` and `test is successful` messages. If you receive errors check your `gitlab` or `gitlab-ssl` Nginx config file for typos, etc. as indicated in the error message given.
+NOTE: **Note:**
+Verify that the installed version is greater than 1.12.1 by running `nginx -v`. If it's lower, you may receive the error below:
+`nginx: [emerg] unknown "start$temp=[filtered]$rest" variable
+nginx: configuration file /etc/nginx/nginx.conf test failed`
+
### Restart
```sh
diff --git a/doc/integration/bitbucket.md b/doc/integration/bitbucket.md
index a69db1d1a6e..68ec8c4b5c2 100644
--- a/doc/integration/bitbucket.md
+++ b/doc/integration/bitbucket.md
@@ -43,9 +43,13 @@ you to use.
| :--- | :---------- |
| **Name** | This can be anything. Consider something like `<Organization>'s GitLab` or `<Your Name>'s GitLab` or something else descriptive. |
| **Application description** | Fill this in if you wish. |
- | **Callback URL** | The URL to your GitLab installation, e.g., `https://gitlab.example.com`. |
+ | **Callback URL** | The URL to your GitLab installation, e.g., `https://gitlab.example.com/users/auth`. |
| **URL** | The URL to your GitLab installation, e.g., `https://gitlab.example.com`. |
+ NOTE: Be sure to append `/users/auth` to the end of the callback URL
+ to prevent a [OAuth2 convert
+ redirect](http://tetraph.com/covert_redirect/) vulnerability.
+
NOTE: Starting in GitLab 8.15, you MUST specify a callback URL, or you will
see an "Invalid redirect_uri" message. For more details, see [the
Bitbucket documentation](https://confluence.atlassian.com/bitbucket/oauth-faq-338365710.html).
diff --git a/doc/integration/github.md b/doc/integration/github.md
index b8156b2b593..eca9aa16499 100644
--- a/doc/integration/github.md
+++ b/doc/integration/github.md
@@ -21,9 +21,13 @@ To get the credentials (a pair of Client ID and Client Secret), you must registe
- Application name: This can be anything. Consider something like `<Organization>'s GitLab` or `<Your Name>'s GitLab` or something else descriptive.
- Homepage URL: the URL to your GitLab installation. e.g., `https://gitlab.company.com`
- Application description: Fill this in if you wish.
- - Authorization callback URL: `http(s)://${YOUR_DOMAIN}`. Please make sure the port is included if your GitLab instance is not configured on default port.
+ - Authorization callback URL: `http(s)://${YOUR_DOMAIN}/users/auth`. Please make sure the port is included if your GitLab instance is not configured on default port.
![Register OAuth App](img/github_register_app.png)
+ NOTE: Be sure to append `/users/auth` to the end of the callback URL
+ to prevent a [OAuth2 convert
+ redirect](http://tetraph.com/covert_redirect/) vulnerability.
+
1. Select **Register application**.
1. You should now see a pair of **Client ID** and **Client Secret** near the top right of the page (see screenshot).
diff --git a/doc/ssh/README.md b/doc/ssh/README.md
index 09a97fcea07..1b53be15b44 100644
--- a/doc/ssh/README.md
+++ b/doc/ssh/README.md
@@ -19,7 +19,7 @@ comes pre-installed on GNU/Linux and macOS, but not on Windows.
Depending on your Windows version, there are different methods to work with
SSH keys.
-### Installing the SSH client for Windows 10
+### Windows 10: Windows Subsystem for Linux
Starting with Windows 10, you can
[install the Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/install-win10)
@@ -27,10 +27,10 @@ where you can run Linux distributions directly on Windows, without the overhead
of a virtual machine. Once installed and set up, you'll have the Git and SSH
clients at your disposal.
-### Installing the SSH client for Windows 8.1 and Windows 7
+### Windows 10, 8.1, and 7: Git for Windows
The easiest way to install Git and the SSH client on Windows 8.1 and Windows 7
-is [Git for Windows](https://gitforwindows.org). It provides a BASH
+is [Git for Windows](https://gitforwindows.org). It provides a Bash
emulation (Git Bash) used for running Git from the command line and the
`ssh-keygen` command that is useful to create SSH keys as you'll learn below.
@@ -114,7 +114,8 @@ To create a new SSH key pair:
and want to tell which is which. It is optional.
1. Next, you will be prompted to input a file path to save your SSH key pair to.
- If you don't already have an SSH key pair, use the suggested path by pressing
+ If you don't already have an SSH key pair and aren't generating a [deploy key](#deploy-keys),
+ use the suggested path by pressing
<kbd>Enter</kbd>. Using the suggested path will normally allow your SSH client
to automatically use the SSH key pair with no additional configuration.
@@ -128,7 +129,7 @@ To create a new SSH key pair:
<kbd>Enter</kbd> twice.
If, in any case, you want to add or change the password of your SSH key pair,
- you can use the `-p`flag:
+ you can use the `-p` flag:
```
ssh-keygen -p -o -f <keyname>
@@ -258,7 +259,8 @@ Integration (CI) server. By using deploy keys, you don't have to set up a
dummy user account.
If you are a project maintainer or owner, you can add a deploy key in the
-project settings under the section 'Repository'. Specify a title for the new
+project's **Settings > Repository** page by expanding the
+**Deploy Keys** section. Specify a title for the new
deploy key and paste a public SSH key. After this, the machine that uses
the corresponding private SSH key has read-only or read-write (if enabled)
access to the project.
@@ -300,8 +302,8 @@ of broader usage for something like "Anywhere you need to give read access to
your repository".
Once a GitLab administrator adds the Global Deployment key, project maintainers
-and owners can add it in project's **Settings > Repository** section by expanding the
-**Deploy Key** section and clicking **Enable** next to the appropriate key listed
+and owners can add it in project's **Settings > Repository** page by expanding the
+**Deploy Keys** section and clicking **Enable** next to the appropriate key listed
under **Public deploy keys available to any project**.
NOTE: **Note:**
diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md
index 325de50cab0..463bdd59282 100644
--- a/doc/topics/autodevops/index.md
+++ b/doc/topics/autodevops/index.md
@@ -126,14 +126,22 @@ Auto Deploy, and Auto Monitoring will be silently skipped.
## Auto DevOps base domain
+NOTE: **Note**
+`AUTO_DEVOPS_DOMAIN` environment variable is deprecated and
+[is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) in GitLab 12.0.
+
The Auto DevOps base domain is required if you want to make use of [Auto
Review Apps](#auto-review-apps) and [Auto Deploy](#auto-deploy). It can be defined
-in three places:
+in any of the following places:
-- either under the project's CI/CD settings while [enabling Auto DevOps](#enabling-auto-devops)
+- either under the cluster's settings, whether for [projects](../../user/project/clusters/index.md#base-domain) or [groups](../../user/group/clusters/index.md#base-domain)
- or in instance-wide settings in the **admin area > Settings** under the "Continuous Integration and Delivery" section
-- or at the project as a variable: `AUTO_DEVOPS_DOMAIN` (required if you want to use [multiple clusters](#using-multiple-kubernetes-clusters))
-- or at the group level as a variable: `AUTO_DEVOPS_DOMAIN`
+- or at the project level as a variable: `KUBE_INGRESS_BASE_DOMAIN`
+- or at the group level as a variable: `KUBE_INGRESS_BASE_DOMAIN`.
+
+NOTE: **Note**
+The Auto DevOps base domain variable (`KUBE_INGRESS_BASE_DOMAIN`) follows the same order of precedence
+as other environment [variables](../../ci/variables/README.md#priority-of-variables).
A wildcard DNS A record matching the base domain(s) is required, for example,
given a base domain of `example.com`, you'd need a DNS entry like:
@@ -170,13 +178,13 @@ In the [Auto DevOps template](https://gitlab.com/gitlab-org/gitlab-ce/blob/maste
Those environments are tied to jobs that use [Auto Deploy](#auto-deploy), so
except for the environment scope, they would also need to have a different
domain they would be deployed to. This is why you need to define a separate
-`AUTO_DEVOPS_DOMAIN` variable for all the above
+`KUBE_INGRESS_BASE_DOMAIN` variable for all the above
[based on the environment](../../ci/variables/README.md#limiting-environment-scopes-of-variables).
The following table is an example of how the three different clusters would
be configured.
-| Cluster name | Cluster environment scope | `AUTO_DEVOPS_DOMAIN` variable value | Variable environment scope | Notes |
+| Cluster name | Cluster environment scope | `KUBE_INGRESS_BASE_DOMAIN` variable value | Variable environment scope | Notes |
| ------------ | -------------- | ----------------------------- | ------------- | ------ |
| review | `review/*` | `review.example.com` | `review/*` | The review cluster which will run all [Review Apps](../../ci/review_apps/index.md). `*` is a wildcard, which means it will be used by every environment name starting with `review/`. |
| staging | `staging` | `staging.example.com` | `staging` | (Optional) The staging cluster which will run the deployments of the staging environments. You need to [enable it first](#deploy-policy-for-staging-and-production-environments). |
@@ -190,14 +198,11 @@ To add a different cluster for each environment:
![Auto DevOps multiple clusters](img/autodevops_multiple_clusters.png)
1. After the clusters are created, navigate to each one and install Helm Tiller
- and Ingress.
+ and Ingress. Wait for the Ingress IP address to be assigned.
1. Make sure you have [configured your DNS](#auto-devops-base-domain) with the
specified Auto DevOps domains.
-1. Navigate to your project's **Settings > CI/CD > Environment variables** and add
- the `AUTO_DEVOPS_DOMAIN` variables with their respective environment
- scope.
-
- ![Auto DevOps domain variables](img/autodevops_domain_variables.png)
+1. Navigate to each cluster's page, through **Operations > Kubernetes**,
+ and add the domain based on its Ingress IP address.
Now that all is configured, you can test your setup by creating a merge request
and verifying that your app is deployed as a review app in the Kubernetes
@@ -205,10 +210,9 @@ cluster with the `review/*` environment scope. Similarly, you can check the
other environments.
NOTE: **Note:**
-Auto DevOps is not supported for a group with multiple clusters, as it
-is not possible to set `AUTO_DEVOPS_DOMAIN` per environment on the group
-level. This will be resolved in the future with the [following issue](
-https://gitlab.com/gitlab-org/gitlab-ce/issues/52363).
+From GitLab 11.8, `KUBE_INGRESS_BASE_DOMAIN` replaces `AUTO_DEVOPS_DOMAIN`.
+`AUTO_DEVOPS_DOMAIN` [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959)
+in GitLab 12.0.
## Enabling/Disabling Auto DevOps
@@ -681,7 +685,7 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac
| **Variable** | **Description** |
| ------------ | --------------- |
-| `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-domain); by default set automatically by the [Auto DevOps setting](#enabling-auto-devops). |
+| `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-domain). By default, set automatically by the [Auto DevOps setting](#enabling-auto-devops). This variable is deprecated and [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) in GitLab 12.0. Use `KUBE_INGRESS_BASE_DOMAIN` instead. |
| `AUTO_DEVOPS_CHART` | The Helm Chart used to deploy your apps; defaults to the one [provided by GitLab](https://gitlab.com/charts/auto-deploy-app). |
| `AUTO_DEVOPS_CHART_REPOSITORY` | The Helm Chart repository used to search for charts; defaults to `https://charts.gitlab.io`. |
| `REPLICAS` | The number of replicas to deploy; defaults to 1. |
@@ -711,6 +715,7 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac
| `DAST_DISABLED` | From GitLab 11.0, this variable can be used to disable the `dast` job. If the variable is present, the job will not be created. |
| `PERFORMANCE_DISABLED` | From GitLab 11.0, this variable can be used to disable the `performance` job. If the variable is present, the job will not be created. |
| `K8S_SECRET_*` | From GitLab 11.7, any variable prefixed with [`K8S_SECRET_`](#application-secret-variables) will be made available by Auto DevOps as environment variables to the deployed application. |
+| `KUBE_INGRESS_BASE_DOMAIN` | From GitLab 11.8, this variable can be used to set a domain per cluster. See [cluster domains](../../user/project/clusters/index.md#base-domain) for more information. |
TIP: **Tip:**
Set up the replica variables using a
diff --git a/doc/user/admin_area/broadcast_messages.md b/doc/user/admin_area/broadcast_messages.md
index 51949088521..02445abdb37 100644
--- a/doc/user/admin_area/broadcast_messages.md
+++ b/doc/user/admin_area/broadcast_messages.md
@@ -4,6 +4,8 @@ GitLab can display messages to all users of a GitLab instance in a banner that a
![Broadcast Message](img/broadcast_messages.png)
+Broadcast messages can be managed using the [broadcast messages API](../../api/broadcast_messages.md).
+
NOTE: **Note:**
If more than one banner message is active at one time, they are displayed in a stack in order of creation.
diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md
index 68a0f1a5837..c1c9b8bf43c 100644
--- a/doc/user/gitlab_com/index.md
+++ b/doc/user/gitlab_com/index.md
@@ -46,7 +46,7 @@ Below are the settings for [GitLab Pages].
| Setting | GitLab.com | Default |
| ----------------------- | ---------------- | ------------- |
| Domain name | `gitlab.io` | - |
-| IP address | `52.167.214.135` | - |
+| IP address | `35.185.44.232` | - |
| Custom domains support | yes | no |
| TLS certificates support| yes | no |
diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md
index 9f9b2da23e1..9fc50741407 100644
--- a/doc/user/group/clusters/index.md
+++ b/doc/user/group/clusters/index.md
@@ -59,11 +59,16 @@ Add another cluster similar to the first one and make sure to
[set an environment scope](#environment-scopes) that will
differentiate the new cluster from the rest.
-NOTE: **Note:**
-Auto DevOps is not supported for a group with multiple clusters, as it
-is not possible to set `AUTO_DEVOPS_DOMAIN` per environment on the group
-level. This will be resolved in the future with the [following issue](
-https://gitlab.com/gitlab-org/gitlab-ce/issues/52363).
+## Base domain
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580) in GitLab 11.8.
+
+Domains at the cluster level permit support for multiple domains
+per [multiple Kubernetes clusters](#multiple-kubernetes-clusters-premium). When specifying a domain,
+this will be automatically set as an environment variable (`KUBE_INGRESS_BASE_DOMAIN`) during
+the [Auto DevOps](../../../topics/autodevops/index.md) stages.
+
+The domain should have a wildcard DNS configured to the Ingress IP address.
## Environment scopes **[PREMIUM]**
diff --git a/doc/user/index.md b/doc/user/index.md
index fc68404d0c2..36aba5e01c6 100644
--- a/doc/user/index.md
+++ b/doc/user/index.md
@@ -12,28 +12,22 @@ includes, except [GitLab administrator](../README.md#administrator-documentation
settings, unless you have admin privileges to install, configure,
and upgrade your GitLab instance.
-For GitLab.com, admin privileges are restricted to the GitLab team.
+Admin privileges for [GitLab.com](https://gitlab.com/) are restricted to the GitLab team.
-If you run your own GitLab instance and are looking for the administration settings,
-please refer to the [administration](../README.md#administrator-documentation)
-documentation.
+For more information on configuring GitLab self-managed instances, see [Administrator documentation](../README.md#administrator-documentation).
## Overview
-GitLab is a fully integrated software development platform that enables you
-and your team to work cohesively, faster, transparently, and effectively,
-since the discussion of a new idea until taking that idea to production all
-the way through, from within the same platform.
+GitLab is a fully integrated software development platform that enables your team to be transparent, fast, effective, and cohesive from discussion on a new idea to production, all on the same platform.
-Please check this page for an overview on [GitLab's features](https://about.gitlab.com/features/).
+For more information, see [All GitLab Features](https://about.gitlab.com/features/).
### Concepts
-For an overview on concepts involved when developing code on GitLab,
-read the articles on:
+To get familiar with the concepts needed to develop code on GitLab, read the following articles:
-- [Mastering Code Review With GitLab](https://about.gitlab.com/2017/03/17/demo-mastering-code-review-with-gitlab/).
-- [GitLab Workflow, an Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/#gitlab-workflow-use-case-scenario).
+- [Demo: Mastering Code Review With GitLab](https://about.gitlab.com/2017/03/17/demo-mastering-code-review-with-gitlab/).
+- [GitLab Workflow: An Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/#gitlab-workflow-use-case-scenario).
- [Tutorial: It's all connected in GitLab](https://about.gitlab.com/2016/03/08/gitlab-tutorial-its-all-connected/): an overview on code collaboration with GitLab.
- [Trends in Version Control Land: Microservices](https://about.gitlab.com/2016/08/16/trends-in-version-control-land-microservices/).
- [Trends in Version Control Land: Innersourcing](https://about.gitlab.com/2016/07/07/trends-version-control-innersourcing/).
@@ -42,16 +36,16 @@ read the articles on:
GitLab is a Git-based platform that integrates a great number of essential tools for software development and deployment, and project management:
-- Code hosting in repositories with version control
-- Track proposals for new implementations, bug reports, and feedback with a
+- Hosting code in repositories with version control
+- Tracking proposals for new implementations, bug reports, and feedback with a
fully featured [Issue Tracker](project/issues/index.md#issue-tracker)
-- Organize and prioritize with [Issue Boards](project/issues/index.md#issue-boards)
-- Code review in [Merge Requests](project/merge_requests/index.md) with live-preview changes per
+- Organizing and prioritizing with [Issue Boards](project/issues/index.md#issue-boards)
+- Reviewing code in [Merge Requests](project/merge_requests/index.md) with live-preview changes per
branch with [Review Apps](../ci/review_apps/index.md)
-- Build, test and deploy with built-in [Continuous Integration](../ci/README.md)
-- Deploy your personal and professional static websites with [GitLab Pages](project/pages/index.md)
-- Integrate with Docker with [GitLab Container Registry](project/container_registry.md)
-- Track the development lifecycle with [GitLab Cycle Analytics](project/cycle_analytics.md)
+- Building, testing and deploying with built-in [Continuous Integration](../ci/README.md)
+- Deploying personal and professional static websites with [GitLab Pages](project/pages/index.md)
+- Integrating with Docker by using [GitLab Container Registry](project/container_registry.md)
+- Tracking the development lifecycle by usingn [GitLab Cycle Analytics](project/cycle_analytics.md)
With GitLab Enterprise Edition, you can also:
@@ -68,15 +62,15 @@ and [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.
- [Export issues as CSV](https://docs.gitlab.com/ee/user/project/issues/csv_export.html)
- View your entire CI/CD pipeline involving more than one project with [Multiple-Project Pipeline Graphs](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html)
- [Lock files](https://docs.gitlab.com/ee/user/project/file_lock.html) to prevent conflicts
-- View of the current health and status of each CI environment running on Kubernetes with [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html)
-- Leverage your continuous delivery method with [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html)
+- View the current health and status of each CI environment running on Kubernetes with [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html)
+- Leverage continuous delivery method with [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html)
You can also [integrate](project/integrations/project_services.md) GitLab with numerous third-party applications, such as Mattermost, Microsoft Teams, HipChat, Trello, Slack, Bamboo CI, JIRA, and a lot more.
## Projects
-In GitLab, you can create [projects](project/index.md) for numerous reasons, such as, host
-your code, use it as an issue tracker, collaborate on code, and continuously
+In GitLab, you can create [projects](project/index.md) to host
+your code, track issues, collaborate on code, and continuously
build, test, and deploy your app with built-in GitLab CI/CD. Or, you can do
it all at once, from one single project.
diff --git a/doc/user/instance_statistics/user_cohorts.md b/doc/user/instance_statistics/user_cohorts.md
index f52f24ef5f7..e76363a6d9f 100644
--- a/doc/user/instance_statistics/user_cohorts.md
+++ b/doc/user/instance_statistics/user_cohorts.md
@@ -25,3 +25,4 @@ How do we measure the activity of users? GitLab considers a user active if:
- The user signs in.
- The user has Git activity (whether push or pull).
+- The user visits pages related to Dashboards, Projects, Issues and Merge Requests ([introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/54947) in GitLab 11.8).
diff --git a/doc/user/markdown.md b/doc/user/markdown.md
index f2448f240ca..9a01625f3ff 100644
--- a/doc/user/markdown.md
+++ b/doc/user/markdown.md
@@ -31,8 +31,10 @@ dependency to do so. Please see the [`github-markup` gem readme](https://github.
> As of 11.1, GitLab uses the [CommonMark Ruby Library][commonmarker] for Markdown
processing of all new issues, merge requests, comments, and other Markdown content
in the GitLab system. As of 11.3, wiki pages and Markdown files (`.md`) in the
-repositories are also processed with CommonMark. Older content in issues/comments
-are still processed using the [Redcarpet Ruby library][redcarpet].
+repositories are also processed with CommonMark. As of 11.8, the [Redcarpet
+Ruby library][redcarpet] has been removed and all issues/comments, including
+those from pre-11.1, are now processed using [CommonMark Ruby
+Library][commonmarker].
>
> The documentation website had its [markdown engine migrated from Redcarpet to Kramdown](https://gitlab.com/gitlab-com/gitlab-docs/merge_requests/108)
in October 2018.
@@ -41,11 +43,11 @@ in October 2018.
### Transitioning to CommonMark
-You may have Markdown documents in your repository that were written using some
-of the nuances of RedCarpet's version of Markdown. Since CommonMark uses a
-slightly stricter syntax, these documents may now display a little strangely
-since we've transitioned to CommonMark. Numbered lists with nested lists in
-particular can be displayed incorrectly.
+You may have older issues/merge requests or Markdown documents in your
+repository that were written using some of the nuances of RedCarpet's version
+of Markdown. Since CommonMark uses a slightly stricter syntax, these documents
+may now display a little strangely since we've transitioned to CommonMark.
+Numbered lists with nested lists in particular can be displayed incorrectly.
It is usually quite easy to fix. In the case of a nested list such as this:
@@ -65,11 +67,6 @@ simply add a space to each nested item:
In the documentation below, we try to highlight some of the differences.
-If you have a need to view a document using RedCarpet, you can add the token
-`legacy_render=1` to the end of the url, like this:
-
-https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md?legacy_render=1
-
If you have a large volume of Markdown files, it can be tedious to determine
if they will be displayed correctly or not. You can use the
[diff_redcarpet_cmark](https://gitlab.com/digitalmoksha/diff_redcarpet_cmark)
@@ -677,7 +674,7 @@ Becomes:
+ Or pluses
If a list item contains multiple paragraphs,
-each subsequent paragraph should be indented to the same level as the start of the list item text (_Redcarpet: paragraph should be indented with four spaces._)
+each subsequent paragraph should be indented to the same level as the start of the list item text
Example:
@@ -841,7 +838,7 @@ These details <em>will</em> remain <strong>hidden</strong> until expanded.
</details>
</p>
-**Note:** Markdown inside these tags is supported, as long as you have a blank line after the `</summary>` tag and before the `</details>` tag, as shown in the example. _Redcarpet does not support Markdown inside these tags. You can work around this by using HTML, for example you can use `<pre><code>` tags instead of [code fences](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#code-and-syntax-highlighting)._
+**Note:** Markdown inside these tags is supported, as long as you have a blank line after the `</summary>` tag and before the `</details>` tag, as shown in the example.
```html
<details>
diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md
index eb2d731343e..363d3db8db1 100644
--- a/doc/user/profile/preferences.md
+++ b/doc/user/profile/preferences.md
@@ -87,3 +87,11 @@ You can choose between 3 options:
- Files and Readme (default)
- Readme
- Activity
+
+## Localization
+
+### First day of the week
+
+The first day of the week can be customised for calendar views and date pickers.
+
+You can choose **Sunday** or **Monday** as the first day of the week. If you select **System Default**, the system-wide default setting will be used.
diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md
index bb815695cb1..85a4af24dc5 100644
--- a/doc/user/project/clusters/index.md
+++ b/doc/user/project/clusters/index.md
@@ -172,6 +172,17 @@ functionalities needed to successfully build and deploy a containerized
application. Bear in mind that the same credentials are used for all the
applications running on the cluster.
+## Base domain
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580) in GitLab 11.8.
+
+Domains at the cluster level permit support for multiple domains
+per [multiple Kubernetes clusters](#multiple-kubernetes-clusters-premium). When specifying a domain,
+this will be automatically set as an environment variable (`KUBE_INGRESS_BASE_DOMAIN`) during
+the [Auto DevOps](../../../topics/autodevops/index.md) stages.
+
+The domain should have a wildcard DNS configured to the Ingress IP address.
+
## Access controls
When creating a cluster in GitLab, you will be asked if you would like to create an
@@ -254,6 +265,12 @@ install it manually.
## Installing applications
+NOTE: **Note:**
+Before starting the installation of applications, make sure that time is synchronized
+between your GitLab server and your Kubernetes cluster. Otherwise, installation could fail
+and you may get errors like `Error: remote error: tls: bad certificate`
+in the `stdout` of pods created by GitLab in your Kubernetes cluster.
+
GitLab provides a one-click install for various applications which can
be added directly to your configured cluster. Those applications are
needed for [Review Apps](../../../ci/review_apps/index.md) and
@@ -449,6 +466,7 @@ GitLab CI/CD build environment.
| `KUBE_CA_PEM_FILE` | Path to a file containing PEM data. Only present if a custom CA bundle was specified. |
| `KUBE_CA_PEM` | (**deprecated**) Raw PEM data. Only if a custom CA bundle was specified. |
| `KUBECONFIG` | Path to a file containing `kubeconfig` for this deployment. CA bundle would be embedded if specified. This config also embeds the same token defined in `KUBE_TOKEN` so you likely will only need this variable. This variable name is also automatically picked up by `kubectl` so you won't actually need to reference it explicitly if using `kubectl`. |
+| `KUBE_INGRESS_BASE_DOMAIN` | From GitLab 11.8, this variable can be used to set a domain per cluster. See [cluster domains](#base-domain) for more information. | 
NOTE: **NOTE:**
Prior to GitLab 11.5, `KUBE_TOKEN` was the Kubernetes token of the main
diff --git a/doc/user/project/clusters/serverless/img/app-domain.png b/doc/user/project/clusters/serverless/img/app-domain.png
new file mode 100644
index 00000000000..d113dfadd2e
--- /dev/null
+++ b/doc/user/project/clusters/serverless/img/app-domain.png
Binary files differ
diff --git a/doc/user/project/clusters/serverless/img/serverless-details.png b/doc/user/project/clusters/serverless/img/serverless-details.png
deleted file mode 100644
index 61e0735199a..00000000000
--- a/doc/user/project/clusters/serverless/img/serverless-details.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md
index bebccf97987..aa1e165e3a2 100644
--- a/doc/user/project/clusters/serverless/index.md
+++ b/doc/user/project/clusters/serverless/index.md
@@ -1,7 +1,9 @@
# Serverless
> Introduced in GitLab 11.5.
-> Serverless is currently in [alpha](https://about.gitlab.com/handbook/product/#alpha).
+
+CAUTION: **Caution:**
+Serverless is currently in [alpha](https://about.gitlab.com/handbook/product/#alpha).
Run serverless workloads on Kubernetes using [Knative](https://cloud.google.com/knative/).
@@ -82,7 +84,15 @@ Currently the following [runtimes](https://gitlab.com/triggermesh/runtimes) are
- node.js
- kaniko
-In order to deploy functions to your Knative instance, the following files must be present:
+You can find all the files referenced in this doc in the [functions example project](https://gitlab.com/knative-examples/functions).
+
+Follow these steps to deploy a function using the Node.js runtime to your Knative instance:
+
+1. Create a directory that will house the function. In this example we will create a directory called `echo` at the root of the project.
+
+1. Create the file that will contain the function code. In this example, our file is called `echo.js` and is located inside the `echo` directory. If your project is:
+ - Public, continue to the next step.
+ - Private, you will need to [create a GitLab deploy token](../../deploy_tokens/index.md#creating-a-deploy-token) with `gitlab-deploy-token` as the name and the `read_registry` scope.
1. `.gitlab-ci.yml`: This template allows to define the stage, environment, and
image to be used for your functions. It must be included at the root of your repository:
@@ -94,10 +104,12 @@ In order to deploy functions to your Knative instance, the following files must
functions:
stage: deploy
environment: test
- image: gcr.io/triggermesh/tm:v0.0.7
+ image: gcr.io/triggermesh/tm:v0.0.9
script:
- - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password "$CI_JOB_TOKEN"
- - tm -n "$KUBE_NAMESPACE" --registry-host "$CI_REGISTRY_IMAGE" deploy --wait
+ - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password "$CI_JOB_TOKEN" --push
+ - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_DEPLOY_USER" --password "$CI_DEPLOY_PASSWORD" --pull
+ - tm -n "$KUBE_NAMESPACE" deploy --wait
+
```
The `gitlab-ci.yml` template creates a `Deploy` stage with a `functions` job that invokes the `tm` CLI with the required parameters.
@@ -127,7 +139,9 @@ In order to deploy functions to your Knative instance, the following files must
```
-The `serverless.yml` file is referencing both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`) which is a reference to `echo.js` in the [repository](https://gitlab.com/knative-examples/functions). Additionally, it contains three sections with distinct parameters:
+The `serverless.yml` file references both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`),
+which is a reference to `echo.js` in the [repository](https://gitlab.com/knative-examples/functions). Additionally, it
+contains three sections with distinct parameters:
### `service`
@@ -149,7 +163,6 @@ The `serverless.yml` file is referencing both an `echo` directory (under `builda
In the `serverless.yml` example above, the function name is `echo` and the subsequent lines contain the function attributes.
-
| Parameter | Description |
|-----------|-------------|
| `handler` | The function's file name. In the example above, both the function name and the handler are the same. |
@@ -158,9 +171,8 @@ In the `serverless.yml` example above, the function name is `echo` and the subse
| `buildargs` | Pointer to the function file in the repo. In the sample the function is located in the `echo` directory. |
| `environment` | Sets an environment variable for the specific function only. |
-After the `gitlab-ci.yml` template has been added and the `serverless.yml` file has been
-created, each function must be defined as a single file in your repository. Committing a
-function to your project will result in a
+After the `gitlab-ci.yml` template has been added and the `serverless.yml` file has been
+created, pushing a commit to your project will result in a
CI pipeline being executed which will deploy each function as a Knative service.
Once the deploy stage has finished, additional details for the function will
appear under **Operations > Serverless**.
@@ -182,14 +194,6 @@ The sample function can now be triggered from any HTTP client using a simple `PO
![function exection](img/function-execution.png)
-Currently, the Serverless page presents all functions available in all clusters registered for the project with Knative installed.
-
-Clicking on the function name will provide additional details such as the
-function's URL as well as runtime statistics such as the number of active pods
-available to service the request based on load.
-
-![serverless function details](img/serverless-details.png)
-
## Deploying Serverless applications
> Introduced in GitLab 11.5.
@@ -227,14 +231,18 @@ deploy:
- tm -n "$KUBE_NAMESPACE" --config "$KUBECONFIG" deploy service "$CI_PROJECT_NAME" --from-image "$CI_REGISTRY_IMAGE" --wait
```
-## Deploy the application with Knative
+### Deploy the application with Knative
With all the pieces in place, the next time a CI pipeline runs, the Knative application will be deployed. Navigate to
**CI/CD > Pipelines** and click the most recent pipeline.
-## Obtain the URL for the Knative deployment
+### Obtain the URL for the Knative deployment
+
+Go to the **Operations > Serverless** page to find the URL for your deployment in the **Domain** column.
+
+![app domain](img/app-domain.png)
-Use the CI/CD deployment job output to obtain the deployment URL. Once all the stages of the pipeline finish, click the **deploy** stage.
+Alternatively, use the CI/CD deployment job output to obtain the deployment URL. Once all the stages of the pipeline finish, click the **deploy** stage.
![deploy stage](img/deploy-stage.png)
diff --git a/doc/user/project/merge_requests/img/squash_mr_message.png b/doc/user/project/merge_requests/img/squash_mr_message.png
new file mode 100644
index 00000000000..8734cab29aa
--- /dev/null
+++ b/doc/user/project/merge_requests/img/squash_mr_message.png
Binary files differ
diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md
index b4f5a72e148..593eb80e044 100644
--- a/doc/user/project/merge_requests/index.md
+++ b/doc/user/project/merge_requests/index.md
@@ -46,7 +46,7 @@ A. Consider you are a software developer working in a team:
1. You verify your changes with [JUnit test reports](../../../ci/junit_test_reports.md) in GitLab CI/CD
1. You request the approval from your manager
1. Your manager pushes a commit with his final review, [approves the merge request](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html), and set it to [merge when pipeline succeeds](#merge-when-pipeline-succeeds) (Merge Request Approvals are available in GitLab Starter)
-1. Your changes get deployed to production with [manual actions](../../../ci/yaml/README.md#manual-actions) for GitLab CI/CD
+1. Your changes get deployed to production with [manual actions](../../../ci/yaml/README.md#whenmanual) for GitLab CI/CD
1. Your implementations were successfully shipped to your customer
B. Consider you're a web developer writing a webpage for your company's:
diff --git a/doc/user/project/merge_requests/squash_and_merge.md b/doc/user/project/merge_requests/squash_and_merge.md
index 1b57331dbe7..4ff8ec3a7e6 100644
--- a/doc/user/project/merge_requests/squash_and_merge.md
+++ b/doc/user/project/merge_requests/squash_and_merge.md
@@ -18,12 +18,19 @@ Into a single commit on merge:
![A squashed commit followed by a merge commit][squashed-commit]
-The squashed commit's commit message is the merge request title. And note that
-the squashed commit is still followed by a merge commit, as the merge
-method for this example repository uses a merge commit. Squashing also works
-with the fast-forward merge strategy, see
-[squashing and fast-forward merge](#squash-and-fast-forward-merge) for more
-details.
+The squashed commit's commit message will be either:
+
+- Taken from the first multi-line commit message in the merge.
+- The merge request's title if no multi-line commit message is found.
+
+It can be customized before merging a merge request.
+
+![A squash commit message editor](img/squash_mr_message.png)
+
+NOTE: **Note:**
+The squashed commit in this example is followed by a merge commit, as the merge method for this example repository uses a merge commit.
+
+Squashing also works with the fast-forward merge strategy, see [squashing and fast-forward merge](#squash-and-fast-forward-merge) for more details.
## Use cases
@@ -34,7 +41,7 @@ you'd rather not include them in your target branch.
With squash and merge, when the merge request is ready to be merged,
all you have to do is enable squashing before you press merge to join
-the commits include in the merge request into a single commit.
+the commits in the merge request into a single commit.
This way, the history of your base branch remains clean with
meaningful commit messages and is simpler to [revert] if necessary.
@@ -56,7 +63,7 @@ This can then be overridden at the time of accepting the merge request:
The squashed commit has the following metadata:
-- Message: the title of the merge request.
+- Message: the message of the squash commit, or a customized message.
- Author: the author of the merge request.
- Committer: the user who initiated the squash.
diff --git a/doc/user/project/operations/index.md b/doc/user/project/operations/index.md
new file mode 100644
index 00000000000..b0f9936be5c
--- /dev/null
+++ b/doc/user/project/operations/index.md
@@ -0,0 +1,11 @@
+# Project operations
+
+GitLab provides a variety of tools to help operate and maintain
+your applications:
+
+- Collect [Prometheus metrics](../integrations/prometheus_library/index.md).
+- Deploy to different [environments](../../../ci/environments.md).
+- Connect your project to a [Kubernetes cluster](../clusters/index.md).
+- Discover and view errors generated by your applications with [Error Tracking](error_tracking.md).
+- Create, toggle, and remove [Feature Flags](https://docs.gitlab.com/ee/user/project/operations/feature_flags.html). **[PREMIUM]**
+- [Trace](https://docs.gitlab.com/ee/user/project/operations/tracing.html) the performance and health of a deployed application. **[ULTIMATE]**
diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md
index cea9628966d..b2da1c85c62 100644
--- a/doc/user/project/pages/getting_started_part_three.md
+++ b/doc/user/project/pages/getting_started_part_three.md
@@ -79,11 +79,14 @@ running on your instance).
![DNS A record pointing to GitLab.com Pages server](img/dns_add_new_a_record_example_updated_2018.png)
-NOTE: **Note:**
-Note that if you use your root domain for your GitLab Pages website **only**, and if
-your domain registrar supports this feature, you can add a DNS apex `CNAME`
-record instead of an `A` record. The main advantage of doing so is that when GitLab Pages
-IP on GitLab.com changes for whatever reason, you don't need to update your `A` record.
+CAUTION: **Caution:**
+Note that if you use your root domain for your GitLab Pages website
+**only**, and if your domain registrar supports this feature, you can
+add a DNS apex `CNAME` record instead of an `A` record. The main
+advantage of doing so is that when GitLab Pages IP on GitLab.com
+changes for whatever reason, you don't need to update your `A` record.
+There may be a few exceptions, but **this method is not recommended**
+as it most likely won't work if you set an `MX` record for your root domain.
#### DNS CNAME record
@@ -114,14 +117,16 @@ co-exist, so you need to place the TXT record in a special subdomain of its own.
#### TL;DR
-If the domain has multiple uses (e.g., you host email on it as well):
+For root domains (`domain.com`), set a DNS `A` record and verify your
+domain's ownership with a TXT record:
| From | DNS Record | To |
| ---- | ---------- | -- |
| domain.com | A | 35.185.44.232 |
| domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff |
-If the domain is dedicated to GitLab Pages use and no other services run on it:
+For subdomains (`subdomain.domain.com`), set a DNS `CNAME` record and
+verify your domain's ownership with a TXT record:
| From | DNS Record | To |
| ---- | ---------- | -- |
@@ -264,7 +269,7 @@ your Pages project are the same.
1. A PEM certificate
1. An intermediate certificate
-1. A public key
+1. A private key
![Pages project - adding certificates](img/add_certificate_to_pages.png)
@@ -280,7 +285,7 @@ Usually it's combined with the PEM certificate, but there are
some cases in which you need to add them manually.
[CloudFlare certs](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/)
are one of these cases.
-- A public key is an encrypted key which validates
+- A private key is an encrypted key which validates
your PEM against your domain.
### Now what?
@@ -293,7 +298,7 @@ of this, it's simple:
and paste the root certificate (usually available from your CA website)
and paste it in the [same field as your PEM certificate](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/),
just jumping a line between them.
-- Copy your public key and paste it in the last field
+- Copy your private key and paste it in the last field
>**Note:**
**Do not** open certificates or encryption keys in
diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md
index b0560c2f44c..c9081a6d72b 100644
--- a/doc/user/project/pages/getting_started_part_two.md
+++ b/doc/user/project/pages/getting_started_part_two.md
@@ -31,12 +31,26 @@ The optional settings, custom domain, DNS records, and SSL/TLS certificates, are
## Project
Your GitLab Pages project is a regular project created the
-same way you do for the other ones. To get started with GitLab Pages, you have two ways:
+same way you do for the other ones. To get started with GitLab Pages, you have three ways:
+- Use one of the popular templates already in the app,
- Fork one of the templates from Page Examples, or
- Create a new project from scratch
-Let's go over both options.
+Let's go over each option.
+
+### Use one of the popular Pages templates bundled with GitLab
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/47857)
+in GitLab 11.8.
+
+The simplest way to create a GitLab Pages site is to use one of the most
+popular templates, which come already bundled and ready to go. To use one
+of these templates:
+
+1. From the top navigation, click the **+** button and select **New project**
+1. Select **Create from Template**
+1. Choose one of the templates starting with **Pages**
### Fork a project to get started from
@@ -57,7 +71,7 @@ created for the steps below.
To turn a **project website** forked from the Pages group into a **user/group** website, you'll need to:
- Rename it to `namespace.gitlab.io`: navigate to project's **Settings** > expand **Advanced settings** > and scroll down to **Rename repository**
-- Adjust your SSG's [base URL](#urls-and-baseurls) to from `"project-name"` to `""`. This setting will be at a different place for each SSG, as each of them have their own structure and file tree. Most likely, it will be in the SSG's config file.
+- Adjust your SSG's [base URL](#urls-and-baseurls) from `"project-name"` to `""`. This setting will be at a different place for each SSG, as each of them have their own structure and file tree. Most likely, it will be in the SSG's config file.
> **Notes:**
>
diff --git a/doc/user/project/pages/index.md b/doc/user/project/pages/index.md
index ce4fccdaff3..11f6165fcb4 100644
--- a/doc/user/project/pages/index.md
+++ b/doc/user/project/pages/index.md
@@ -91,8 +91,8 @@ site under the HTTPS protocol.
## Getting started
-To get started with GitLab Pages, you can either [create a project from scratch](getting_started_part_two.md#create-a-project-from-scratch)
-or quickly start from copying an existing example project, as follows:
+To get started with GitLab Pages, you can either [create a project from scratch](getting_started_part_two.md#create-a-project-from-scratch),
+use a [bundled template](getting_started_part_two.md#use-one-of-the-popular-pages-templates-bundled-with-gitlab), or copy any of our existing example projects:
1. Choose an [example project](https://gitlab.com/pages) to [fork](../../../gitlab-basics/fork-project.md#how-to-fork-a-project):
by forking a project, you create a copy of the codebase you're forking from to start from a template instead of starting from scratch.
diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md
index a7846b1ee18..cebff38ba88 100644
--- a/doc/user/project/pages/introduction.md
+++ b/doc/user/project/pages/introduction.md
@@ -178,7 +178,7 @@ Supposed your repository contained the following files:
```
├── index.html
├── css
-│   └── main.css
+│ └── main.css
└── js
└── main.js
```
@@ -333,7 +333,7 @@ public/
│ └ index.html.gz
│
├── css/
-│   └─┬ main.css
+│ └─┬ main.css
│ └ main.css.gz
│
└── js/
@@ -356,6 +356,57 @@ By pre-compressing the files and including both versions in the artifact, Pages
can serve requests for both compressed and uncompressed content without
needing to compress files on-demand.
+### Resolving ambiguous URLs
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-pages/issues/95) in GitLab 11.8
+
+GitLab Pages makes assumptions about which files to serve when receiving a
+request for a URL that does not include an extension.
+
+Consider a Pages site deployed with the following files:
+
+```
+public/
+├─┬ index.html
+│ ├ data.html
+│ └ info.html
+│
+├── data/
+│ └── index.html
+├── info/
+│ └── details.html
+└── other/
+ └── index.html
+```
+
+Pages supports reaching each of these files through several different URLs. In
+particular, it will always look for an `index.html` file if the URL only
+specifies the directory. If the URL references a file that doesn't exist, but
+adding `.html` to the URL leads to a file that *does* exist, it will be served
+instead. Here are some examples of what will happen given the above Pages site:
+
+| URL path | HTTP response | File served |
+| -------------------- | ------------- | ----------- |
+| `/` | `200 OK` | `public/index.html` |
+| `/index.html` | `200 OK` | `public/index.html` |
+| `/index` | `200 OK` | `public/index.html` |
+| `/data` | `200 OK` | `public/data/index.html` |
+| `/data/` | `200 OK` | `public/data/index.html` |
+| `/data.html` | `200 OK` | `public/data.html` |
+| `/info` | `200 OK` | `public/info.html` |
+| `/info/` | `200 OK` | `public/info.html` |
+| `/info.html` | `200 OK` | `public/info.html` |
+| `/info/details` | `200 OK` | `public/info/details.html` |
+| `/info/details.html` | `200 OK` | `public/info/details.html` |
+| `/other` | `302 Found` | `public/other/index.html` |
+| `/other/` | `200 OK` | `public/other/index.html` |
+| `/other/index` | `200 OK` | `public/other/index.html` |
+| `/other/index.html` | `200 OK` | `public/other/index.html` |
+
+NOTE: **Note:**
+When `public/data/index.html` exists, it takes priority over the `public/data.html`
+file for both the `/data` and `/data/` URL paths.
+
### Add a custom domain to your Pages website
For a complete guide on Pages domains, read through the article
diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md
index a8b47558c99..0d0575b1ab4 100644
--- a/doc/user/project/pipelines/job_artifacts.md
+++ b/doc/user/project/pipelines/job_artifacts.md
@@ -38,8 +38,9 @@ turn are defined with the `paths` keyword. All paths to files and directories
are relative to the repository that was cloned during the build. These uploaded
artifacts will be kept in GitLab for 1 week as defined by the `expire_in`
definition. You have the option to keep the artifacts from expiring via the
-[web interface](#browsing-job-artifacts). If you don't define an expiry date,
-the artifacts will be kept forever.
+[web interface](#browsing-job-artifacts). If the expiry time is not defined,
+it defaults to the [instance wide
+setting](../../admin_area/settings/continuous_integration.md#default-artifacts-expiration-core-only).
For more examples on artifacts, follow the [artifacts reference in
`.gitlab-ci.yml`](../../../ci/yaml/README.md#artifacts).
diff --git a/doc/user/snippets.md b/doc/user/snippets.md
index 5c9f6ffb163..569bdc9e2d5 100644
--- a/doc/user/snippets.md
+++ b/doc/user/snippets.md
@@ -4,7 +4,12 @@ With GitLab Snippets you can store and share bits of code and text with other us
![GitLab Snippet](img/gitlab_snippet.png)
-There are 2 types of snippets, personal snippets and project snippets.
+Snippets can be maintained using [snippets API](../api/snippets.md).
+
+There are two types of snippets:
+
+- Personal snippets.
+- Project snippets.
## Personal snippets
diff --git a/doc/workflow/repository_mirroring.md b/doc/workflow/repository_mirroring.md
index ac26aeab137..8a2f4e1b40e 100644
--- a/doc/workflow/repository_mirroring.md
+++ b/doc/workflow/repository_mirroring.md
@@ -55,7 +55,7 @@ When push mirroring is enabled, only push commits directly to the mirrored repos
mirror diverging. All changes will end up in the mirrored repository whenever:
- Commits are pushed to GitLab.
-- A [forced update](#forcing-an-update) is initiated.
+- A [forced update](#forcing-an-update-core) is initiated.
Changes pushed to files in the repository are automatically pushed to the remote mirror at least:
@@ -88,6 +88,14 @@ The mirrored repository will be listed. For example, `https://*****:*****@github
The repository will push soon. To force a push, click the appropriate button.
+## Setting up a push mirror to another GitLab instance with 2FA activated
+
+1. On the destination GitLab instance, create a [personal access token](../user/profile/personal_access_tokens.md) with `API` scope.
+1. On the source GitLab instance:
+ 1. Fill in the **Git repository URL** field using this format: `https://oauth2@<destination host>/<your_gitlab_group_or_name>/<your_gitlab_project>.git`.
+ 1. Fill in **Password** field with the GitLab personal access token created on the destination GitLab instance.
+ 1. Click the **Mirror repository** button.
+
## Pulling from a remote repository **[STARTER]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/51) in GitLab Enterprise Edition 8.2.
@@ -122,7 +130,7 @@ directly to the repository on GitLab. Instead, any commits should be pushed to t
Changes pushed to the upstream repository will be pulled into the GitLab repository, either:
- Automatically within a certain period of time.
-- When a [forced update](#forcing-an-update) is initiated.
+- When a [forced update](#forcing-an-update-core) is initiated.
CAUTION: **Caution:**
If you do manually update a branch in the GitLab repository, the branch will become diverged from
@@ -259,7 +267,7 @@ failed. This will become visible in either the:
- Pull mirror settings page.
When a project is hard failed, it will no longer get picked up for mirroring. A user can resume the
-project mirroring again by [Forcing an update](#forcing-an-update).
+project mirroring again by [Forcing an update](#forcing-an-update-core).
### Trigger update using API **[STARTER]**
@@ -292,8 +300,8 @@ them and how they will be resolved.
Rewriting any mirrored commit on either remote will cause conflicts and mirroring to fail. This can
be prevented by:
-- [Pulling only protected branches](#pull-only-protected-branches).
-- [Pushing only protected branches](#push-only-protected-branches).
+- [Pulling only protected branches](#only-mirror-protected-branches-starter).
+- [Pushing only protected branches](#push-only-protected-branches-core).
You should [protect the branches](../user/project/protected_branches.md) you wish to mirror on both
remotes to prevent conflicts caused by rewriting history.
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 2b42e377c74..4dd1b459554 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -9,6 +9,7 @@ module API
NO_SLASH_URL_PART_REGEX = %r{[^/]+}
NAMESPACE_OR_PROJECT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
COMMIT_ENDPOINT_REQUIREMENTS = NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(sha: NO_SLASH_URL_PART_REGEX).freeze
+ USER_REQUIREMENTS = { user_id: NO_SLASH_URL_PART_REGEX }.freeze
insert_before Grape::Middleware::Error,
GrapeLogging::Middleware::RequestLogger,
@@ -108,6 +109,7 @@ module API
mount ::API::Features
mount ::API::Files
mount ::API::GroupBoards
+ mount ::API::GroupLabels
mount ::API::GroupMilestones
mount ::API::Groups
mount ::API::GroupVariables
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 4edec631e8d..beb8ce349b4 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1019,12 +1019,17 @@ module API
label.open_merge_requests_count(options[:current_user])
end
- expose :priority do |label, options|
- label.priority(options[:project])
+ expose :subscribed do |label, options|
+ label.subscribed?(options[:current_user], options[:parent])
end
+ end
- expose :subscribed do |label, options|
- label.subscribed?(options[:current_user], options[:project])
+ class GroupLabel < Label
+ end
+
+ class ProjectLabel < Label
+ expose :priority do |label, options|
+ label.priority(options[:parent])
end
end
@@ -1116,7 +1121,9 @@ module API
class Release < TagRelease
expose :name
- expose :description_html
+ expose :description_html do |entity|
+ MarkupHelper.markdown_field(entity, :description)
+ end
expose :created_at
expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? }
expose :commit, using: Entities::Commit
@@ -1223,8 +1230,11 @@ module API
end
class Trigger < Grape::Entity
+ include ::API::Helpers::Presentable
+
expose :id
- expose :token, :description
+ expose :token
+ expose :description
expose :created_at, :updated_at, :last_used
expose :owner, using: Entities::UserBasic
end
diff --git a/lib/api/group_labels.rb b/lib/api/group_labels.rb
new file mode 100644
index 00000000000..0dbc5f45a68
--- /dev/null
+++ b/lib/api/group_labels.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module API
+ class GroupLabels < Grape::API
+ include PaginationParams
+ helpers ::API::Helpers::LabelHelpers
+
+ before { authenticate! }
+
+ params do
+ requires :id, type: String, desc: 'The ID of a group'
+ end
+ resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ desc 'Get all labels of the group' do
+ detail 'This feature was added in GitLab 11.8'
+ success Entities::GroupLabel
+ end
+ params do
+ use :pagination
+ end
+ get ':id/labels' do
+ get_labels(user_group, Entities::GroupLabel)
+ end
+
+ desc 'Create a new label' do
+ detail 'This feature was added in GitLab 11.8'
+ success Entities::GroupLabel
+ end
+ params do
+ use :label_create_params
+ end
+ post ':id/labels' do
+ create_label(user_group, Entities::GroupLabel)
+ end
+
+ desc 'Update an existing label. At least one optional parameter is required.' do
+ detail 'This feature was added in GitLab 11.8'
+ success Entities::GroupLabel
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the label to be updated'
+ optional :new_name, type: String, desc: 'The new name of the label'
+ optional :color, type: String, desc: "The new color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the allowed CSS color names"
+ optional :description, type: String, desc: 'The new description of label'
+ at_least_one_of :new_name, :color, :description
+ end
+ put ':id/labels' do
+ update_label(user_group, Entities::GroupLabel)
+ end
+
+ desc 'Delete an existing label' do
+ detail 'This feature was added in GitLab 11.8'
+ success Entities::GroupLabel
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the label to be deleted'
+ end
+ delete ':id/labels' do
+ delete_label(user_group)
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index fa6c9777824..2eb7b04711a 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -84,8 +84,8 @@ module API
page || not_found!('Wiki Page')
end
- def available_labels_for(label_parent)
- search_params = { include_ancestor_groups: true }
+ def available_labels_for(label_parent, include_ancestor_groups: true)
+ search_params = { include_ancestor_groups: include_ancestor_groups }
if label_parent.is_a?(Project)
search_params[:project_id] = label_parent.id
@@ -170,13 +170,6 @@ module API
end
end
- def find_project_label(id)
- labels = available_labels_for(user_project)
- label = labels.find_by_id(id) || labels.find_by_title(id)
-
- label || not_found!('Label')
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def find_project_issue(iid)
IssuesFinder.new(current_user, project_id: user_project.id).find_by!(iid: iid)
@@ -422,7 +415,7 @@ module API
def present_disk_file!(path, filename, content_type = 'application/octet-stream')
filename ||= File.basename(path)
- header['Content-Disposition'] = "attachment; filename=#{filename}"
+ header['Content-Disposition'] = ::Gitlab::ContentDisposition.format(disposition: 'attachment', filename: filename)
header['Content-Transfer-Encoding'] = 'binary'
content_type content_type
@@ -496,7 +489,7 @@ module API
def send_git_blob(repository, blob)
env['api.format'] = :txt
content_type 'text/plain'
- header['Content-Disposition'] = content_disposition('inline', blob.name)
+ header['Content-Disposition'] = ::Gitlab::ContentDisposition.format(disposition: 'inline', filename: blob.name)
# Let Workhorse examine the content and determine the better content disposition
header[Gitlab::Workhorse::DETECT_HEADER] = "true"
@@ -533,11 +526,5 @@ module API
params[:archived]
end
-
- def content_disposition(disposition, filename)
- disposition += %(; filename=#{filename.inspect}) if filename.present?
-
- disposition
- end
end
end
diff --git a/lib/api/helpers/label_helpers.rb b/lib/api/helpers/label_helpers.rb
new file mode 100644
index 00000000000..c11e7d614ab
--- /dev/null
+++ b/lib/api/helpers/label_helpers.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module API
+ module Helpers
+ module LabelHelpers
+ extend Grape::API::Helpers
+
+ params :label_create_params do
+ requires :name, type: String, desc: 'The name of the label to be created'
+ requires :color, type: String, desc: "The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the allowed CSS color names"
+ optional :description, type: String, desc: 'The description of label to be created'
+ end
+
+ def find_label(parent, id, include_ancestor_groups: true)
+ labels = available_labels_for(parent, include_ancestor_groups: include_ancestor_groups)
+ label = labels.find_by_id(id) || labels.find_by_title(id)
+
+ label || not_found!('Label')
+ end
+
+ def get_labels(parent, entity)
+ present paginate(available_labels_for(parent)), with: entity, current_user: current_user, parent: parent
+ end
+
+ def create_label(parent, entity)
+ authorize! :admin_label, parent
+
+ label = available_labels_for(parent).find_by_title(params[:name])
+ conflict!('Label already exists') if label
+
+ priority = params.delete(:priority)
+ label_params = declared_params(include_missing: false)
+
+ label =
+ if parent.is_a?(Project)
+ ::Labels::CreateService.new(label_params).execute(project: parent)
+ else
+ ::Labels::CreateService.new(label_params).execute(group: parent)
+ end
+
+ if label.persisted?
+ if parent.is_a?(Project)
+ label.prioritize!(parent, priority) if priority
+ end
+
+ present label, with: entity, current_user: current_user, parent: parent
+ else
+ render_validation_error!(label)
+ end
+ end
+
+ def update_label(parent, entity)
+ authorize! :admin_label, parent
+
+ label = find_label(parent, params[:name], include_ancestor_groups: false)
+ update_priority = params.key?(:priority)
+ priority = params.delete(:priority)
+
+ label = ::Labels::UpdateService.new(declared_params(include_missing: false)).execute(label)
+ render_validation_error!(label) unless label.valid?
+
+ if parent.is_a?(Project) && update_priority
+ if priority.nil?
+ label.unprioritize!(parent)
+ else
+ label.prioritize!(parent, priority)
+ end
+ end
+
+ present label, with: entity, current_user: current_user, parent: parent
+ end
+
+ def delete_label(parent)
+ authorize! :admin_label, parent
+
+ label = find_label(parent, params[:name], include_ancestor_groups: false)
+
+ destroy_conditionally!(label)
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/presentable.rb b/lib/api/helpers/presentable.rb
new file mode 100644
index 00000000000..973c2132efe
--- /dev/null
+++ b/lib/api/helpers/presentable.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module API
+ module Helpers
+ ##
+ # This module makes it possible to use `app/presenters` with
+ # Grape Entities. It instantiates model presenter and passes
+ # options defined in the API endpoint to the presenter itself.
+ #
+ # present object, with: Entities::Something,
+ # current_user: current_user,
+ # another_option: 'my options'
+ #
+ # Example above will make `current_user` and `another_option`
+ # values available in the subclass of `Gitlab::View::Presenter`
+ # thorough a separate method in the presenter.
+ #
+ # The model class needs to have `::Presentable` module mixed in
+ # if you want to use `API::Helpers::Presentable`.
+ #
+ module Presentable
+ extend ActiveSupport::Concern
+
+ def initialize(object, options = {})
+ super(object.present(options), options)
+ end
+ end
+ end
+end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index afa3ac80121..b3636c98550 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -43,7 +43,8 @@ module API
desc: 'Return issues sorted in `asc` or `desc` order.'
optional :milestone, type: String, desc: 'Return issues for a specific milestone'
optional :iids, type: Array[Integer], desc: 'The IID array of issues'
- optional :search, type: String, desc: 'Search issues for text present in the title or description'
+ optional :search, type: String, desc: 'Search issues for text present in the title, description, or any combination of these'
+ optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma'
optional :created_after, type: DateTime, desc: 'Return issues created after the specified time'
optional :created_before, type: DateTime, desc: 'Return issues created before the specified time'
optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time'
diff --git a/lib/api/labels.rb b/lib/api/labels.rb
index d5eb2b94669..d729d3ee625 100644
--- a/lib/api/labels.rb
+++ b/lib/api/labels.rb
@@ -3,6 +3,7 @@
module API
class Labels < Grape::API
include PaginationParams
+ helpers ::API::Helpers::LabelHelpers
before { authenticate! }
@@ -11,62 +12,28 @@ module API
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get all labels of the project' do
- success Entities::Label
+ success Entities::ProjectLabel
end
params do
use :pagination
end
get ':id/labels' do
- present paginate(available_labels_for(user_project)), with: Entities::Label, current_user: current_user, project: user_project
+ get_labels(user_project, Entities::ProjectLabel)
end
desc 'Create a new label' do
- success Entities::Label
+ success Entities::ProjectLabel
end
params do
- requires :name, type: String, desc: 'The name of the label to be created'
- requires :color, type: String, desc: "The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the allowed CSS color names"
- optional :description, type: String, desc: 'The description of label to be created'
+ use :label_create_params
optional :priority, type: Integer, desc: 'The priority of the label', allow_blank: true
end
- # rubocop: disable CodeReuse/ActiveRecord
post ':id/labels' do
- authorize! :admin_label, user_project
-
- label = available_labels_for(user_project).find_by(title: params[:name])
- conflict!('Label already exists') if label
-
- priority = params.delete(:priority)
- label = ::Labels::CreateService.new(declared_params(include_missing: false)).execute(project: user_project)
-
- if label.valid?
- label.prioritize!(user_project, priority) if priority
- present label, with: Entities::Label, current_user: current_user, project: user_project
- else
- render_validation_error!(label)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- desc 'Delete an existing label' do
- success Entities::Label
- end
- params do
- requires :name, type: String, desc: 'The name of the label to be deleted'
- end
- # rubocop: disable CodeReuse/ActiveRecord
- delete ':id/labels' do
- authorize! :admin_label, user_project
-
- label = user_project.labels.find_by(title: params[:name])
- not_found!('Label') unless label
-
- destroy_conditionally!(label)
+ create_label(user_project, Entities::ProjectLabel)
end
- # rubocop: enable CodeReuse/ActiveRecord
desc 'Update an existing label. At least one optional parameter is required.' do
- success Entities::Label
+ success Entities::ProjectLabel
end
params do
requires :name, type: String, desc: 'The name of the label to be updated'
@@ -76,33 +43,19 @@ module API
optional :priority, type: Integer, desc: 'The priority of the label', allow_blank: true
at_least_one_of :new_name, :color, :description, :priority
end
- # rubocop: disable CodeReuse/ActiveRecord
put ':id/labels' do
- authorize! :admin_label, user_project
-
- label = user_project.labels.find_by(title: params[:name])
- not_found!('Label not found') unless label
-
- update_priority = params.key?(:priority)
- priority = params.delete(:priority)
- label_params = declared_params(include_missing: false)
- # Rename new name to the actual label attribute name
- label_params[:name] = label_params.delete(:new_name) if label_params.key?(:new_name)
-
- label = ::Labels::UpdateService.new(label_params).execute(label)
- render_validation_error!(label) unless label.valid?
-
- if update_priority
- if priority.nil?
- label.unprioritize!(user_project)
- else
- label.prioritize!(user_project, priority)
- end
- end
+ update_label(user_project, Entities::ProjectLabel)
+ end
- present label, with: Entities::Label, current_user: current_user, project: user_project
+ desc 'Delete an existing label' do
+ success Entities::ProjectLabel
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the label to be deleted'
+ end
+ delete ':id/labels' do
+ delete_label(user_project)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
end
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 132b19164d0..df46b4446ff 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -109,7 +109,8 @@ module API
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
optional :source_branch, type: String, desc: 'Return merge requests with the given source branch'
optional :target_branch, type: String, desc: 'Return merge requests with the given target branch'
- optional :search, type: String, desc: 'Search merge requests for text present in the title or description'
+ optional :search, type: String, desc: 'Search merge requests for text present in the title, description, or any combination of these'
+ optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma'
optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title'
use :pagination
end
@@ -342,6 +343,7 @@ module API
end
params do
optional :merge_commit_message, type: String, desc: 'Custom merge commit message'
+ optional :squash_commit_message, type: String, desc: 'Custom squash commit message'
optional :should_remove_source_branch, type: Boolean,
desc: 'When true, the source branch will be deleted if possible'
optional :merge_when_pipeline_succeeds, type: Boolean,
@@ -369,6 +371,7 @@ module API
merge_params = {
commit_message: params[:merge_commit_message],
+ squash_commit_message: params[:squash_commit_message],
should_remove_source_branch: params[:should_remove_source_branch]
}
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
index 1f59b27f685..ac8fe98e55e 100644
--- a/lib/api/pipelines.rb
+++ b/lib/api/pipelines.rb
@@ -76,7 +76,7 @@ module API
requires :pipeline_id, type: Integer, desc: 'The pipeline ID'
end
get ':id/pipelines/:pipeline_id' do
- authorize! :read_pipeline, user_project
+ authorize! :read_pipeline, pipeline
present pipeline, with: Entities::Pipeline
end
@@ -104,7 +104,7 @@ module API
requires :pipeline_id, type: Integer, desc: 'The pipeline ID'
end
post ':id/pipelines/:pipeline_id/retry' do
- authorize! :update_pipeline, user_project
+ authorize! :update_pipeline, pipeline
pipeline.retry_failed(current_user)
@@ -119,7 +119,7 @@ module API
requires :pipeline_id, type: Integer, desc: 'The pipeline ID'
end
post ':id/pipelines/:pipeline_id/cancel' do
- authorize! :update_pipeline, user_project
+ authorize! :update_pipeline, pipeline
pipeline.cancel_running
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 9f3a1699146..6a93ef9f3ad 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -25,6 +25,9 @@ module API
projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled]
projects = projects.with_statistics if params[:statistics]
+ lang = params[:with_programming_language]
+ projects = projects.with_programming_language(lang) if lang
+
projects
end
@@ -91,6 +94,7 @@ module API
optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of'
optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature'
optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature'
+ optional :with_programming_language, type: String, desc: 'Limit to repositories which use the given programming language'
optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user'
use :optional_filter_params_ee
@@ -128,7 +132,7 @@ module API
end
end
- resource :users, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ resource :users, requirements: API::USER_REQUIREMENTS do
desc 'Get a user projects' do
success Entities::BasicProjectDetails
end
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index 95371961398..b16faffe335 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -121,6 +121,7 @@ module API
optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.'
optional :usage_ping_enabled, type: Boolean, desc: 'Every week GitLab will report license usage back to GitLab, Inc.'
optional :instance_statistics_visibility_private, type: Boolean, desc: 'When set to `true` Instance statistics will only be available to admins'
+ optional :local_markdown_version, type: Integer, desc: "Local markdown version, increase this value when any cached markdown should be invalidated"
ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
optional :"#{type}_key_restriction",
diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb
index 74ad3c35a61..dfb54446ddf 100644
--- a/lib/api/subscriptions.rb
+++ b/lib/api/subscriptions.rb
@@ -2,51 +2,88 @@
module API
class Subscriptions < Grape::API
+ helpers ::API::Helpers::LabelHelpers
+
before { authenticate! }
- subscribable_types = {
- 'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) },
- 'issues' => proc { |id| find_project_issue(id) },
- 'labels' => proc { |id| find_project_label(id) }
- }
+ subscribables = [
+ {
+ type: 'merge_requests',
+ entity: Entities::MergeRequest,
+ source: Project,
+ finder: ->(id) { find_merge_request_with_access(id, :update_merge_request) }
+ },
+ {
+ type: 'issues',
+ entity: Entities::Issue,
+ source: Project,
+ finder: ->(id) { find_project_issue(id) }
+ },
+ {
+ type: 'labels',
+ entity: Entities::ProjectLabel,
+ source: Project,
+ finder: ->(id) { find_label(user_project, id) }
+ },
+ {
+ type: 'labels',
+ entity: Entities::GroupLabel,
+ source: Group,
+ finder: ->(id) { find_label(user_group, id) }
+ }
+ ]
- params do
- requires :id, type: String, desc: 'The ID of a project'
- requires :subscribable_id, type: String, desc: 'The ID of a resource'
- end
- resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
- subscribable_types.each do |type, finder|
- type_singularized = type.singularize
- entity_class = Entities.const_get(type_singularized.camelcase)
+ subscribables.each do |subscribable|
+ source_type = subscribable[:source].name.underscore
+ params do
+ requires :id, type: String, desc: "The #{source_type} ID"
+ requires :subscribable_id, type: String, desc: 'The ID of a resource'
+ end
+ resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Subscribe to a resource' do
- success entity_class
+ success subscribable[:entity]
end
- post ":id/#{type}/:subscribable_id/subscribe" do
- resource = instance_exec(params[:subscribable_id], &finder)
+ post ":id/#{subscribable[:type]}/:subscribable_id/subscribe" do
+ parent = parent_resource(source_type)
+ resource = instance_exec(params[:subscribable_id], &subscribable[:finder])
- if resource.subscribed?(current_user, user_project)
+ if resource.subscribed?(current_user, parent)
not_modified!
else
- resource.subscribe(current_user, user_project)
- present resource, with: entity_class, current_user: current_user, project: user_project
+ resource.subscribe(current_user, parent)
+ present resource, with: subscribable[:entity], current_user: current_user, project: parent, parent: parent
end
end
desc 'Unsubscribe from a resource' do
- success entity_class
+ success subscribable[:entity]
end
- post ":id/#{type}/:subscribable_id/unsubscribe" do
- resource = instance_exec(params[:subscribable_id], &finder)
+ post ":id/#{subscribable[:type]}/:subscribable_id/unsubscribe" do
+ parent = parent_resource(source_type)
+ resource = instance_exec(params[:subscribable_id], &subscribable[:finder])
- if !resource.subscribed?(current_user, user_project)
+ if !resource.subscribed?(current_user, parent)
not_modified!
else
- resource.unsubscribe(current_user, user_project)
- present resource, with: entity_class, current_user: current_user, project: user_project
+ resource.unsubscribe(current_user, parent)
+ present resource, with: subscribable[:entity], current_user: current_user, project: parent, parent: parent
end
end
end
end
+
+ private
+
+ helpers do
+ def parent_resource(source_type)
+ case source_type
+ when 'project'
+ user_project
+ else
+ nil
+ end
+ end
+ end
end
end
diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb
index 604f989d8b3..8fc7c7361e1 100644
--- a/lib/api/triggers.rb
+++ b/lib/api/triggers.rb
@@ -51,7 +51,7 @@ module API
triggers = user_project.triggers.includes(:trigger_requests)
- present paginate(triggers), with: Entities::Trigger
+ present paginate(triggers), with: Entities::Trigger, current_user: current_user
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -68,7 +68,7 @@ module API
trigger = user_project.triggers.find(params.delete(:trigger_id))
break not_found!('Trigger') unless trigger
- present trigger, with: Entities::Trigger
+ present trigger, with: Entities::Trigger, current_user: current_user
end
desc 'Create a trigger' do
@@ -85,7 +85,7 @@ module API
declared_params(include_missing: false).merge(owner: current_user))
if trigger.valid?
- present trigger, with: Entities::Trigger
+ present trigger, with: Entities::Trigger, current_user: current_user
else
render_validation_error!(trigger)
end
@@ -106,7 +106,7 @@ module API
break not_found!('Trigger') unless trigger
if trigger.update(declared_params(include_missing: false))
- present trigger, with: Entities::Trigger
+ present trigger, with: Entities::Trigger, current_user: current_user
else
render_validation_error!(trigger)
end
@@ -127,7 +127,7 @@ module API
if trigger.update(owner: current_user)
status :ok
- present trigger, with: Entities::Trigger
+ present trigger, with: Entities::Trigger, current_user: current_user
else
render_validation_error!(trigger)
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index b41fce76df0..8ce09a8881b 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -133,10 +133,10 @@ module API
desc "Get the status of a user"
params do
- requires :id_or_username, type: String, desc: 'The ID or username of the user'
+ requires :user_id, type: String, desc: 'The ID or username of the user'
end
- get ":id_or_username/status" do
- user = find_user(params[:id_or_username])
+ get ":user_id/status", requirements: API::USER_REQUIREMENTS do
+ user = find_user(params[:user_id])
not_found!('User') unless user && can?(current_user, :read_user, user)
present user.status || {}, with: Entities::UserStatus
diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb
index 184c7418e75..22ed1d8e7b4 100644
--- a/lib/backup/repository.rb
+++ b/lib/backup/repository.rb
@@ -93,7 +93,7 @@ module Backup
progress.puts "Error: #{e}".color(:red)
end
else
- restore_repo_success = gitlab_shell.create_repository(project.repository_storage, project.disk_path)
+ restore_repo_success = gitlab_shell.create_project_repository(project)
end
if restore_repo_success
diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb
index deda4b1872e..086adf59d2b 100644
--- a/lib/banzai/filter/autolink_filter.rb
+++ b/lib/banzai/filter/autolink_filter.rb
@@ -8,6 +8,10 @@ module Banzai
#
# Based on HTML::Pipeline::AutolinkFilter
#
+ # Note that our CommonMark parser, `commonmarker` (using the autolink extension)
+ # handles standard autolinking, like http/https. We detect additional
+ # schemes (smb, rdar, etc).
+ #
# Context options:
# :autolink - Boolean, skips all processing done by this filter when false
# :link_attr - Hash of attributes for the generated links
@@ -107,10 +111,17 @@ module Banzai
end
end
- # match has come from node.to_html above, so we know it's encoded
- # correctly.
+ # Since this came from a Text node, make sure the new href is encoded.
+ # `commonmarker` percent encodes the domains of links it handles, so
+ # do the same (instead of using `normalized_encode`).
+ begin
+ href_safe = Addressable::URI.encode(match).html_safe
+ rescue Addressable::URI::InvalidURIError
+ return uri.to_s
+ end
+
html_safe_match = match.html_safe
- options = link_options.merge(href: html_safe_match)
+ options = link_options.merge(href: href_safe)
content_tag(:a, html_safe_match, options) + dropped
end
diff --git a/lib/banzai/filter/external_link_filter.rb b/lib/banzai/filter/external_link_filter.rb
index 4f60b6f84c6..61ee3eac216 100644
--- a/lib/banzai/filter/external_link_filter.rb
+++ b/lib/banzai/filter/external_link_filter.rb
@@ -4,17 +4,29 @@ module Banzai
module Filter
# HTML Filter to modify the attributes of external links
class ExternalLinkFilter < HTML::Pipeline::Filter
- SCHEMES = ['http', 'https', nil].freeze
+ SCHEMES = ['http', 'https', nil].freeze
+ RTLO = "\u202E".freeze
+ ENCODED_RTLO = '%E2%80%AE'.freeze
def call
links.each do |node|
- uri = uri(node['href'].to_s)
-
- node.set_attribute('href', uri.to_s) if uri
+ # URI.parse does stricter checking on the url than Addressable,
+ # such as on `mailto:` links. Since we've been using it, do an
+ # initial parse for validity and then use Addressable
+ # for IDN support, etc
+ uri = uri_strict(node['href'].to_s)
+ if uri
+ node.set_attribute('href', uri.to_s)
+ addressable_uri = addressable_uri(node['href'])
+ else
+ addressable_uri = nil
+ end
- if SCHEMES.include?(uri&.scheme) && !internal_url?(uri)
- node.set_attribute('rel', 'nofollow noreferrer noopener')
- node.set_attribute('target', '_blank')
+ unless internal_url?(addressable_uri)
+ punycode_autolink_node!(addressable_uri, node)
+ sanitize_link_text!(node)
+ add_malicious_tooltip!(addressable_uri, node)
+ add_nofollow!(addressable_uri, node)
end
end
@@ -23,12 +35,18 @@ module Banzai
private
- def uri(href)
+ def uri_strict(href)
URI.parse(href)
rescue URI::Error
nil
end
+ def addressable_uri(href)
+ Addressable::URI.parse(href)
+ rescue Addressable::URI::InvalidURIError
+ nil
+ end
+
def links
query = 'descendant-or-self::a[@href and not(@href = "")]'
doc.xpath(query)
@@ -45,6 +63,57 @@ module Banzai
def internal_url
@internal_url ||= URI.parse(Gitlab.config.gitlab.url)
end
+
+ # Only replace an autolink with an IDN with it's punycode
+ # version if we need emailable links. Otherwise let it
+ # be shown normally and the tooltips will show the
+ # punycode version.
+ def punycode_autolink_node!(uri, node)
+ return unless uri
+ return unless context[:emailable_links]
+
+ unencoded_uri_str = Addressable::URI.unencode(node['href'])
+
+ if unencoded_uri_str == node.content && idn?(uri)
+ node.content = uri.normalize
+ end
+ end
+
+ # escape any right-to-left (RTLO) characters in link text
+ def sanitize_link_text!(node)
+ node.inner_html = node.inner_html.gsub(RTLO, ENCODED_RTLO)
+ end
+
+ # If the domain is an international domain name (IDN),
+ # let's expose with a tooltip in case it's intended
+ # to be malicious. This is particularly useful for links
+ # where the link text is not the same as the actual link.
+ # We will continue to show the unicode version of the domain
+ # in autolinked link text, which could contain emojis, etc.
+ #
+ # Also show the tooltip if the url contains the RTLO character,
+ # as this is an indicator of a malicious link
+ def add_malicious_tooltip!(uri, node)
+ if idn?(uri) || has_encoded_rtlo?(uri)
+ node.add_class('has-tooltip')
+ node.set_attribute('title', uri.normalize)
+ end
+ end
+
+ def add_nofollow!(uri, node)
+ if SCHEMES.include?(uri&.scheme)
+ node.set_attribute('rel', 'nofollow noreferrer noopener')
+ node.set_attribute('target', '_blank')
+ end
+ end
+
+ def idn?(uri)
+ uri&.normalized_host&.start_with?('xn--')
+ end
+
+ def has_encoded_rtlo?(uri)
+ uri&.to_s&.include?(ENCODED_RTLO)
+ end
end
end
end
diff --git a/lib/banzai/filter/markdown_engines/redcarpet.rb b/lib/banzai/filter/markdown_engines/redcarpet.rb
deleted file mode 100644
index 5b3f75096b1..00000000000
--- a/lib/banzai/filter/markdown_engines/redcarpet.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-# `Redcarpet` markdown engine for GitLab's Banzai markdown filter.
-# This module is used in Banzai::Filter::MarkdownFilter.
-# Used gem is `redcarpet` which is a ruby library for markdown processing.
-# Homepage: https://github.com/vmg/redcarpet
-
-module Banzai
- module Filter
- module MarkdownEngines
- class Redcarpet
- OPTIONS = {
- fenced_code_blocks: true,
- footnotes: true,
- lax_spacing: true,
- no_intra_emphasis: true,
- space_after_headers: true,
- strikethrough: true,
- superscript: true,
- tables: true
- }.freeze
-
- def initialize(context = nil)
- html_renderer = Banzai::Renderer::Redcarpet::HTML.new
- @renderer = ::Redcarpet::Markdown.new(html_renderer, OPTIONS)
- end
-
- def render(text)
- @renderer.render(text)
- end
- end
- end
- end
-end
diff --git a/lib/banzai/filter/spaced_link_filter.rb b/lib/banzai/filter/spaced_link_filter.rb
index 00dbf2d3130..50bf823929c 100644
--- a/lib/banzai/filter/spaced_link_filter.rb
+++ b/lib/banzai/filter/spaced_link_filter.rb
@@ -45,8 +45,6 @@ module Banzai
]).freeze
def call
- return doc if context[:markdown_engine] == :redcarpet
-
doc.xpath(TEXT_QUERY).each do |node|
content = node.to_html
diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb
index bcf77861f10..9ffde52b5f2 100644
--- a/lib/banzai/filter/syntax_highlight_filter.rb
+++ b/lib/banzai/filter/syntax_highlight_filter.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'rouge/plugins/common_mark'
-require 'rouge/plugins/redcarpet'
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai
diff --git a/lib/banzai/pipeline/email_pipeline.rb b/lib/banzai/pipeline/email_pipeline.rb
index 0f4dd9d143d..13e6a990407 100644
--- a/lib/banzai/pipeline/email_pipeline.rb
+++ b/lib/banzai/pipeline/email_pipeline.rb
@@ -12,6 +12,7 @@ module Banzai
def self.transform_context(context)
super(context).merge(
only_path: false,
+ emailable_links: true,
no_sourcepos: true
)
end
diff --git a/lib/banzai/renderer/redcarpet/html.rb b/lib/banzai/renderer/redcarpet/html.rb
deleted file mode 100644
index 84931fdc784..00000000000
--- a/lib/banzai/renderer/redcarpet/html.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module Banzai
- module Renderer
- module Redcarpet
- class HTML < ::Redcarpet::Render::HTML
- def block_code(code, lang)
- lang_attr = lang ? %Q{ lang="#{lang}"} : ''
-
- "\n<pre>" \
- "<code#{lang_attr}>#{ERB::Util.html_escape(code)}</code>" \
- "</pre>"
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 7aa02009aa0..b2ef04d23d7 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -12,6 +12,9 @@ module Gitlab
# Scopes used for OpenID Connect
OPENID_SCOPES = [:openid].freeze
+ # OpenID Connect profile scopes
+ PROFILE_SCOPES = [:profile, :email].freeze
+
# Default scopes for OAuth applications that don't define their own
DEFAULT_SCOPES = [:api].freeze
@@ -284,7 +287,7 @@ module Gitlab
# Other available scopes
def optional_scopes
- available_scopes + OPENID_SCOPES - DEFAULT_SCOPES
+ available_scopes + OPENID_SCOPES + PROFILE_SCOPES - DEFAULT_SCOPES
end
def registry_scopes
diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb
index 42c657afe6a..15b9d5ad6e9 100644
--- a/lib/gitlab/auth/ldap/adapter.rb
+++ b/lib/gitlab/auth/ldap/adapter.rb
@@ -30,14 +30,7 @@ module Gitlab
def users(fields, value, limit = nil)
options = user_options(Array(fields), value, limit)
-
- entries = ldap_search(options).select do |entry|
- entry.respond_to? config.uid
- end
-
- entries.map do |entry|
- Gitlab::Auth::LDAP::Person.new(entry, provider)
- end
+ users_search(options)
end
def user(*args)
@@ -90,6 +83,16 @@ module Gitlab
SEARCH_RETRY_FACTOR[retry_number] * config.timeout
end
+ def users_search(options)
+ entries = ldap_search(options).select do |entry|
+ entry.respond_to? config.uid
+ end
+
+ entries.map do |entry|
+ Gitlab::Auth::LDAP::Person.new(entry, provider)
+ end
+ end
+
def user_options(fields, value, limit)
options = {
attributes: Gitlab::Auth::LDAP::Person.ldap_attributes(config),
diff --git a/lib/gitlab/background_migration.rb b/lib/gitlab/background_migration.rb
index 6cf40e2d4ca..5251e0fadf9 100644
--- a/lib/gitlab/background_migration.rb
+++ b/lib/gitlab/background_migration.rb
@@ -16,11 +16,18 @@ module Gitlab
# re-raises the exception.
#
# steal_class - The name of the class for which to steal jobs.
- def self.steal(steal_class)
- enqueued = Sidekiq::Queue.new(self.queue)
- scheduled = Sidekiq::ScheduledSet.new
+ def self.steal(steal_class, retry_dead_jobs: false)
+ queues = [
+ Sidekiq::ScheduledSet.new,
+ Sidekiq::Queue.new(self.queue)
+ ]
+
+ if retry_dead_jobs
+ queues << Sidekiq::RetrySet.new
+ queues << Sidekiq::DeadSet.new
+ end
- [scheduled, enqueued].each do |queue|
+ queues.each do |queue|
queue.each do |job|
migration_class, migration_args = job.args
diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb
index eaead41a720..75a3f17f549 100644
--- a/lib/gitlab/bitbucket_import/importer.rb
+++ b/lib/gitlab/bitbucket_import/importer.rb
@@ -65,9 +65,9 @@ module Gitlab
def import_wiki
return if project.wiki.repository_exists?
- disk_path = project.wiki.disk_path
- import_url = project.import_url.sub(/\.git\z/, ".git/wiki")
- gitlab_shell.import_repository(project.repository_storage, disk_path, import_url)
+ wiki = WikiFormatter.new(project)
+
+ gitlab_shell.import_wiki_repository(project, wiki)
rescue StandardError => e
errors << { type: :wiki, errors: e.message }
end
diff --git a/lib/gitlab/bitbucket_import/wiki_formatter.rb b/lib/gitlab/bitbucket_import/wiki_formatter.rb
new file mode 100644
index 00000000000..b8ff43b777b
--- /dev/null
+++ b/lib/gitlab/bitbucket_import/wiki_formatter.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BitbucketImport
+ class WikiFormatter
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def disk_path
+ project.wiki.disk_path
+ end
+
+ def full_path
+ project.wiki.full_path
+ end
+
+ def import_url
+ project.import_url.sub(/\.git\z/, ".git/wiki")
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/chain/limit/activity.rb b/lib/gitlab/ci/pipeline/chain/limit/activity.rb
new file mode 100644
index 00000000000..fe7c8738cc0
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/chain/limit/activity.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Pipeline
+ module Chain
+ module Limit
+ class Activity < Chain::Base
+ def perform!
+ # to be overriden in EE
+ end
+
+ def break?
+ false # to be overriden in EE
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/chain/limit/size.rb b/lib/gitlab/ci/pipeline/chain/limit/size.rb
new file mode 100644
index 00000000000..b4d51437cd6
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/chain/limit/size.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Pipeline
+ module Chain
+ module Limit
+ class Size < Chain::Base
+ def perform!
+ # to be overriden in EE
+ end
+
+ def break?
+ false # to be overriden in EE
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb
new file mode 100644
index 00000000000..0f687a4ce9b
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Pipeline
+ module Chain
+ class RemoveUnwantedChatJobs < Chain::Base
+ def perform!
+ # to be overriden in EE
+ end
+
+ def break?
+ false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/templates/Android-Fastlane.gitlab-ci.yml b/lib/gitlab/ci/templates/Android-Fastlane.gitlab-ci.yml
new file mode 100644
index 00000000000..9c534b2b8e7
--- /dev/null
+++ b/lib/gitlab/ci/templates/Android-Fastlane.gitlab-ci.yml
@@ -0,0 +1,121 @@
+# Read more about how to use this script on this blog post https://about.gitlab.com/2019/01/28/android-publishing-with-gitlab-and-fastlane/
+# You will also need to configure your build.gradle, Dockerfile, and fastlane configuration to make this work.
+# If you are looking for a simpler template that does not publish, see the Android template.
+
+stages:
+ - environment
+ - build
+ - test
+ - internal
+ - alpha
+ - beta
+ - production
+
+
+.updateContainerJob:
+ image: docker:stable
+ stage: environment
+ services:
+ - docker:dind
+ script:
+ - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
+ - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG || true
+ - docker build --cache-from $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG .
+ - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+
+updateContainer:
+ extends: .updateContainerJob
+ only:
+ changes:
+ - Dockerfile
+
+ensureContainer:
+ extends: .updateContainerJob
+ allow_failure: true
+ before_script:
+ - "mkdir -p ~/.docker && echo '{\"experimental\": \"enabled\"}' > ~/.docker/config.json"
+ - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
+ # Skip update container `script` if the container already exists
+ # via https://gitlab.com/gitlab-org/gitlab-ce/issues/26866#note_97609397 -> https://stackoverflow.com/a/52077071/796832
+ - docker manifest inspect $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG > /dev/null && exit || true
+
+
+.build_job:
+ image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+ stage: build
+ before_script:
+ # We store this binary file in a variable as hex with this command: `xxd -p android-app.jks`
+ # Then we convert the hex back to a binary file
+ - echo "$signing_jks_file_hex" | xxd -r -p - > android-signing-keystore.jks
+ - "export VERSION_CODE=$CI_PIPELINE_IID && echo $VERSION_CODE"
+ - "export VERSION_SHA=`echo ${CI_COMMIT_SHA:0:8}` && echo $VERSION_SHA"
+ after_script:
+ - rm -f android-signing-keystore.jks || true
+ artifacts:
+ paths:
+ - app/build/outputs
+
+buildDebug:
+ extends: .build_job
+ script:
+ - bundle exec fastlane buildDebug
+
+buildRelease:
+ extends: .build_job
+ script:
+ - bundle exec fastlane buildRelease
+ environment:
+ name: production
+
+testDebug:
+ image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+ stage: test
+ dependencies:
+ - buildDebug
+ script:
+ - bundle exec fastlane test
+
+publishInternal:
+ image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+ stage: internal
+ dependencies:
+ - buildRelease
+ when: manual
+ before_script:
+ - echo $google_play_service_account_api_key_json > ~/google_play_api_key.json
+ after_script:
+ - rm ~/google_play_api_key.json
+ script:
+ - bundle exec fastlane internal
+
+.promote_job:
+ image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+ when: manual
+ dependencies: []
+ before_script:
+ - echo $google_play_service_account_api_key_json > ~/google_play_api_key.json
+ after_script:
+ - rm ~/google_play_api_key.json
+
+promoteAlpha:
+ extends: .promote_job
+ stage: alpha
+ script:
+ - bundle exec fastlane promote_internal_to_alpha
+
+promoteBeta:
+ extends: .promote_job
+ stage: beta
+ script:
+ - bundle exec fastlane promote_alpha_to_beta
+
+promoteProduction:
+ extends: .promote_job
+ stage: production
+ # We only allow production promotion on `master` because
+ # it has its own production scoped secret variables
+ only:
+ - master
+ script:
+ - bundle exec fastlane promote_beta_to_production
+ \ No newline at end of file
diff --git a/lib/gitlab/ci/templates/Android.gitlab-ci.yml b/lib/gitlab/ci/templates/Android.gitlab-ci.yml
index 6e138639b71..c169e3f7686 100644
--- a/lib/gitlab/ci/templates/Android.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Android.gitlab-ci.yml
@@ -1,4 +1,6 @@
# Read more about this script on this blog post https://about.gitlab.com/2018/10/24/setting-up-gitlab-ci-for-android-projects/, by Jason Lenny
+# If you are interested in using Android with FastLane for publishing take a look at the Android-Fastlane template.
+
image: openjdk:8-jdk
variables:
diff --git a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml
index 75a5bf142d2..e369d26f22f 100644
--- a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml
@@ -21,8 +21,8 @@
#
# In order to deploy, you must have a Kubernetes cluster configured either
# via a project integration, or via group/project variables.
-# AUTO_DEVOPS_DOMAIN must also be set as a variable at the group or project
-# level, or manually added below.
+# KUBE_INGRESS_BASE_DOMAIN must also be set on the cluster settings,
+# as a variable at the group or project level, or manually added below.
#
# Continuous deployment to production is enabled by default.
# If you want to deploy to staging first, set STAGING_ENABLED environment variable.
@@ -41,8 +41,8 @@
image: alpine:latest
variables:
- # AUTO_DEVOPS_DOMAIN is the application deployment domain and should be set as a variable at the group or project level.
- # AUTO_DEVOPS_DOMAIN: domain.example.com
+ # KUBE_INGRESS_BASE_DOMAIN is the application deployment domain and should be set as a variable at the group or project level.
+ # KUBE_INGRESS_BASE_DOMAIN: domain.example.com
POSTGRES_USER: user
POSTGRES_PASSWORD: testing-password
@@ -251,7 +251,7 @@ review:
- persist_environment_url
environment:
name: review/$CI_COMMIT_REF_NAME
- url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$AUTO_DEVOPS_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$KUBE_INGRESS_BASE_DOMAIN
on_stop: stop_review
artifacts:
paths: [environment_url.txt]
@@ -306,7 +306,7 @@ staging:
- deploy
environment:
name: staging
- url: http://$CI_PROJECT_PATH_SLUG-staging.$AUTO_DEVOPS_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-staging.$KUBE_INGRESS_BASE_DOMAIN
only:
refs:
- master
@@ -330,7 +330,7 @@ canary:
- deploy canary
environment:
name: production
- url: http://$CI_PROJECT_PATH_SLUG.$AUTO_DEVOPS_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG.$KUBE_INGRESS_BASE_DOMAIN
when: manual
only:
refs:
@@ -354,7 +354,7 @@ canary:
- persist_environment_url
environment:
name: production
- url: http://$CI_PROJECT_PATH_SLUG.$AUTO_DEVOPS_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG.$KUBE_INGRESS_BASE_DOMAIN
artifacts:
paths: [environment_url.txt]
@@ -403,7 +403,7 @@ production_manual:
- persist_environment_url
environment:
name: production
- url: http://$CI_PROJECT_PATH_SLUG.$AUTO_DEVOPS_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG.$KUBE_INGRESS_BASE_DOMAIN
artifacts:
paths: [environment_url.txt]
@@ -689,7 +689,7 @@ rollout 100%:
--set application.database_url="$DATABASE_URL" \
--set application.secretName="$APPLICATION_SECRET_NAME" \
--set application.secretChecksum="$APPLICATION_SECRET_CHECKSUM" \
- --set service.commonName="le.$AUTO_DEVOPS_DOMAIN" \
+ --set service.commonName="le.$KUBE_INGRESS_BASE_DOMAIN" \
--set service.url="$CI_ENVIRONMENT_URL" \
--set service.additionalHosts="$additional_hosts" \
--set replicaCount="$replicas" \
@@ -725,7 +725,7 @@ rollout 100%:
--set application.database_url="$DATABASE_URL" \
--set application.secretName="$APPLICATION_SECRET_NAME" \
--set application.secretChecksum="$APPLICATION_SECRET_CHECKSUM" \
- --set service.commonName="le.$AUTO_DEVOPS_DOMAIN" \
+ --set service.commonName="le.$KUBE_INGRESS_BASE_DOMAIN" \
--set service.url="$CI_ENVIRONMENT_URL" \
--set service.additionalHosts="$additional_hosts" \
--set replicaCount="$replicas" \
@@ -823,11 +823,24 @@ rollout 100%:
kubectl describe namespace "$KUBE_NAMESPACE" || kubectl create namespace "$KUBE_NAMESPACE"
}
+
+ # Function to ensure backwards compatibility with AUTO_DEVOPS_DOMAIN
+ function ensure_kube_ingress_base_domain() {
+ if [ -z ${KUBE_INGRESS_BASE_DOMAIN+x} ]; then
+ export KUBE_INGRESS_BASE_DOMAIN=$AUTO_DEVOPS_DOMAIN
+ fi
+ }
+
function check_kube_domain() {
- if [ -z ${AUTO_DEVOPS_DOMAIN+x} ]; then
- echo "In order to deploy or use Review Apps, AUTO_DEVOPS_DOMAIN variable must be set"
- echo "You can do it in Auto DevOps project settings or defining a variable at group or project level"
+ ensure_kube_ingress_base_domain
+
+ if [ -z ${KUBE_INGRESS_BASE_DOMAIN+x} ]; then
+ echo "In order to deploy or use Review Apps,"
+ echo "AUTO_DEVOPS_DOMAIN or KUBE_INGRESS_BASE_DOMAIN variables must be set"
+ echo "From 11.8, you can set KUBE_INGRESS_BASE_DOMAIN in cluster settings"
+ echo "or by defining a variable at group or project level."
echo "You can also manually add it in .gitlab-ci.yml"
+ echo "AUTO_DEVOPS_DOMAIN support will be dropped on 12.0"
false
else
true
diff --git a/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml b/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml
index fc3d4ecdbba..25a32ba0f74 100644
--- a/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml
@@ -57,6 +57,7 @@ test_job:
script:
- '& "$env:NUNIT_PATH" ".\$env:TEST_FOLDER\Tests.dll"' # running NUnit tests
artifacts:
+ when: always # save test results even when the task fails
expire_in: 1 week # save gitlab server space, we copy the files we need to deploy folder later on
paths:
- '.\TestResult.xml' # saving NUnit results to copy to deploy folder
diff --git a/lib/gitlab/content_disposition.rb b/lib/gitlab/content_disposition.rb
new file mode 100644
index 00000000000..32207514ce5
--- /dev/null
+++ b/lib/gitlab/content_disposition.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+# This ports ActionDispatch::Http::ContentDisposition (https://github.com/rails/rails/pull/33829,
+# which will be available in Rails 6.
+module Gitlab
+ class ContentDisposition # :nodoc:
+ # Make sure we remove this patch starting with Rails 6.0.
+ if Rails.version.start_with?('6.0')
+ raise <<~MSG
+ Please remove this file and use `ActionDispatch::Http::ContentDisposition` instead.
+ MSG
+ end
+
+ def self.format(disposition:, filename:)
+ new(disposition: disposition, filename: filename).to_s
+ end
+
+ attr_reader :disposition, :filename
+
+ def initialize(disposition:, filename:)
+ @disposition = disposition
+ @filename = filename
+ end
+
+ # rubocop:disable Style/VariableInterpolation
+ TRADITIONAL_ESCAPED_CHAR = /[^ A-Za-z0-9!#$+.^_`|~-]/
+
+ def ascii_filename
+ 'filename="' + percent_escape(::I18n.transliterate(filename), TRADITIONAL_ESCAPED_CHAR) + '"'
+ end
+
+ RFC_5987_ESCAPED_CHAR = /[^A-Za-z0-9!#$&+.^_`|~-]/
+ # rubocop:enable Style/VariableInterpolation
+
+ def utf8_filename
+ "filename*=UTF-8''" + percent_escape(filename, RFC_5987_ESCAPED_CHAR)
+ end
+
+ def to_s
+ if filename
+ "#{disposition}; #{ascii_filename}; #{utf8_filename}"
+ else
+ "#{disposition}"
+ end
+ end
+
+ private
+
+ def percent_escape(string, pattern)
+ string.gsub(pattern) do |char|
+ char.bytes.map { |byte| "%%%02X" % byte }.join
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb
index 862127110b9..ea08b5f7eae 100644
--- a/lib/gitlab/data_builder/push.rb
+++ b/lib/gitlab/data_builder/push.rb
@@ -93,7 +93,7 @@ module Gitlab
user_id: user.id,
user_name: user.name,
user_username: user.username,
- user_email: user.email,
+ user_email: user.public_email,
user_avatar: user.avatar_url(only_path: false),
project_id: project.id,
project: project.hook_attrs,
diff --git a/lib/gitlab/email/handler/reply_processing.rb b/lib/gitlab/email/handler/reply_processing.rb
index ba9730d2685..d8f4be8ada1 100644
--- a/lib/gitlab/email/handler/reply_processing.rb
+++ b/lib/gitlab/email/handler/reply_processing.rb
@@ -56,7 +56,7 @@ module Gitlab
raise ProjectNotFound unless author.can?(:read_project, project)
end
- raise UserNotAuthorizedError unless author.can?(permission, project || noteable)
+ raise UserNotAuthorizedError unless author.can?(permission, try(:noteable) || project)
end
def verify_record!(record:, invalid_exception:, record_name:)
diff --git a/lib/gitlab/error_tracking/project.rb b/lib/gitlab/error_tracking/project.rb
new file mode 100644
index 00000000000..93e81da5034
--- /dev/null
+++ b/lib/gitlab/error_tracking/project.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module ErrorTracking
+ class Project
+ include ActiveModel::Model
+
+ ACCESSORS = [
+ :id, :name, :status, :slug, :organization_name,
+ :organization_id, :organization_slug
+ ].freeze
+
+ attr_accessor(*ACCESSORS)
+ end
+ end
+end
diff --git a/lib/gitlab/git/object_pool.rb b/lib/gitlab/git/object_pool.rb
index 1c6242b444a..e93ca3e11f8 100644
--- a/lib/gitlab/git/object_pool.rb
+++ b/lib/gitlab/git/object_pool.rb
@@ -10,12 +10,13 @@ module Gitlab
delegate :exists?, :size, to: :repository
delegate :unlink_repository, :delete, to: :object_pool_service
- attr_reader :storage, :relative_path, :source_repository
+ attr_reader :storage, :relative_path, :source_repository, :gl_project_path
- def initialize(storage, relative_path, source_repository)
+ def initialize(storage, relative_path, source_repository, gl_project_path)
@storage = storage
@relative_path = relative_path
@source_repository = source_repository
+ @gl_project_path = gl_project_path
end
def create
@@ -31,12 +32,12 @@ module Gitlab
end
def to_gitaly_repository
- Gitlab::GitalyClient::Util.repository(storage, relative_path, GL_REPOSITORY)
+ Gitlab::GitalyClient::Util.repository(storage, relative_path, GL_REPOSITORY, gl_project_path)
end
# Allows for reusing other RPCs by 'tricking' Gitaly to think its a repository
def repository
- @repository ||= Gitlab::Git::Repository.new(storage, relative_path, GL_REPOSITORY)
+ @repository ||= Gitlab::Git::Repository.new(storage, relative_path, GL_REPOSITORY, gl_project_path)
end
private
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 786c90f9272..54bbd531398 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -67,7 +67,7 @@ module Gitlab
# Relative path of repo
attr_reader :relative_path
- attr_reader :storage, :gl_repository, :relative_path
+ attr_reader :storage, :gl_repository, :relative_path, :gl_project_path
# This remote name has to be stable for all types of repositories that
# can join an object pool. If it's structure ever changes, a migration
@@ -78,10 +78,11 @@ module Gitlab
# This initializer method is only used on the client side (gitlab-ce).
# Gitaly-ruby uses a different initializer.
- def initialize(storage, relative_path, gl_repository)
+ def initialize(storage, relative_path, gl_repository, gl_project_path)
@storage = storage
@relative_path = relative_path
@gl_repository = gl_repository
+ @gl_project_path = gl_project_path
@name = @relative_path.split("/").last
end
@@ -872,7 +873,7 @@ module Gitlab
end
def gitaly_repository
- Gitlab::GitalyClient::Util.repository(@storage, @relative_path, @gl_repository)
+ Gitlab::GitalyClient::Util.repository(@storage, @relative_path, @gl_repository, @gl_project_path)
end
def gitaly_ref_client
diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb
index 8a1abfbf874..a7e20d9429e 100644
--- a/lib/gitlab/gitaly_client/repository_service.rb
+++ b/lib/gitlab/gitaly_client/repository_service.rb
@@ -324,13 +324,40 @@ module Gitlab
GitalyClient.call(@storage, :repository_service, :search_files_by_name, request, timeout: GitalyClient.fast_timeout).flat_map(&:files)
end
- def search_files_by_content(ref, query)
- request = Gitaly::SearchFilesByContentRequest.new(repository: @gitaly_repo, ref: ref, query: query)
- GitalyClient.call(@storage, :repository_service, :search_files_by_content, request).flat_map(&:matches)
+ def search_files_by_content(ref, query, chunked_response: true)
+ request = Gitaly::SearchFilesByContentRequest.new(repository: @gitaly_repo, ref: ref, query: query, chunked_response: chunked_response)
+ response = GitalyClient.call(@storage, :repository_service, :search_files_by_content, request)
+
+ search_results_from_response(response)
end
private
+ def search_results_from_response(gitaly_response)
+ matches = []
+ current_match = +""
+
+ gitaly_response.each do |message|
+ next if message.nil?
+
+ # Old client will ignore :chunked_response flag
+ # and return messages with `matches` key.
+ # This code path will be removed post 12.0 release
+ if message.matches.any?
+ matches += message.matches
+ else
+ current_match << message.match_data
+
+ if message.end_of_match
+ matches << current_match
+ current_match = +""
+ end
+ end
+ end
+
+ matches
+ end
+
def gitaly_fetch_stream_to_file(save_path, rpc_name, request_class, timeout)
request = request_class.new(repository: @gitaly_repo)
response = GitalyClient.call(
diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb
index dce5d6a8ad0..899921f76e4 100644
--- a/lib/gitlab/gitaly_client/util.rb
+++ b/lib/gitlab/gitaly_client/util.rb
@@ -4,7 +4,7 @@ module Gitlab
module GitalyClient
module Util
class << self
- def repository(repository_storage, relative_path, gl_repository)
+ def repository(repository_storage, relative_path, gl_repository, gl_project_path)
git_env = Gitlab::Git::HookEnv.all(gl_repository)
git_object_directory = git_env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence
git_alternate_object_directories = Array.wrap(git_env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE'])
@@ -14,14 +14,16 @@ module Gitlab
relative_path: relative_path,
gl_repository: gl_repository.to_s,
git_object_directory: git_object_directory.to_s,
- git_alternate_object_directories: git_alternate_object_directories
+ git_alternate_object_directories: git_alternate_object_directories,
+ gl_project_path: gl_project_path
)
end
def git_repository(gitaly_repository)
Gitlab::Git::Repository.new(gitaly_repository.storage_name,
gitaly_repository.relative_path,
- gitaly_repository.gl_repository)
+ gitaly_repository.gl_repository,
+ gitaly_repository.gl_project_path)
end
end
end
diff --git a/lib/gitlab/github_import/importer/lfs_object_importer.rb b/lib/gitlab/github_import/importer/lfs_object_importer.rb
index a88c17aaf82..195383fd3e9 100644
--- a/lib/gitlab/github_import/importer/lfs_object_importer.rb
+++ b/lib/gitlab/github_import/importer/lfs_object_importer.rb
@@ -13,10 +13,12 @@ module Gitlab
@project = project
end
+ def lfs_download_object
+ LfsDownloadObject.new(oid: lfs_object.oid, size: lfs_object.size, link: lfs_object.link)
+ end
+
def execute
- Projects::LfsPointers::LfsDownloadService
- .new(project)
- .execute(lfs_object.oid, lfs_object.download_link)
+ Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object).execute
end
end
end
diff --git a/lib/gitlab/github_import/importer/repository_importer.rb b/lib/gitlab/github_import/importer/repository_importer.rb
index bc3ea9e9226..e2dfb00dcc5 100644
--- a/lib/gitlab/github_import/importer/repository_importer.rb
+++ b/lib/gitlab/github_import/importer/repository_importer.rb
@@ -6,11 +6,12 @@ module Gitlab
class RepositoryImporter
include Gitlab::ShellAdapter
- attr_reader :project, :client
+ attr_reader :project, :client, :wiki_formatter
def initialize(project, client)
@project = project
@client = client
+ @wiki_formatter = ::Gitlab::LegacyGithubImport::WikiFormatter.new(project)
end
# Returns true if we should import the wiki for the project.
@@ -57,9 +58,7 @@ module Gitlab
end
def import_wiki_repository
- wiki_path = "#{project.disk_path}.wiki"
-
- gitlab_shell.import_repository(project.repository_storage, wiki_path, wiki_url)
+ gitlab_shell.import_wiki_repository(project, wiki_formatter)
true
rescue Gitlab::Shell::Error => e
@@ -72,7 +71,7 @@ module Gitlab
end
def wiki_url
- project.import_url.sub(/\.git\z/, '.wiki.git')
+ wiki_formatter.import_url
end
def update_clone_time
diff --git a/lib/gitlab/github_import/representation/lfs_object.rb b/lib/gitlab/github_import/representation/lfs_object.rb
index debe0fa0baf..a4606173f49 100644
--- a/lib/gitlab/github_import/representation/lfs_object.rb
+++ b/lib/gitlab/github_import/representation/lfs_object.rb
@@ -9,11 +9,11 @@ module Gitlab
attr_reader :attributes
- expose_attribute :oid, :download_link
+ expose_attribute :oid, :link, :size
# Builds a lfs_object
def self.from_api_response(lfs_object)
- new({ oid: lfs_object[0], download_link: lfs_object[1] })
+ new({ oid: lfs_object.oid, link: lfs_object.link, size: lfs_object.size })
end
# Builds a new lfs_object using a Hash that was built from a JSON payload.
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 9b1794eec91..3235d3ccc4e 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -24,6 +24,7 @@ module Gitlab
gon.emoji_sprites_css_path = ActionController::Base.helpers.stylesheet_path('emoji_sprites')
gon.test_env = Rails.env.test?
gon.suggested_label_colors = LabelsHelper.suggested_colors
+ gon.first_day_of_week = current_user&.first_day_of_week || Gitlab::CurrentSettings.first_day_of_week
if current_user
gon.current_user_id = current_user.id
diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb
index 4fbb87385c3..5ff415b6126 100644
--- a/lib/gitlab/gpg/commit.rb
+++ b/lib/gitlab/gpg/commit.rb
@@ -88,9 +88,10 @@ module Gitlab
def create_cached_signature!
using_keychain do |gpg_key|
- signature = GpgSignature.new(attributes(gpg_key))
- signature.save! unless Gitlab::Database.read_only?
- signature
+ attributes = attributes(gpg_key)
+ break GpgSignature.new(attributes) if Gitlab::Database.read_only?
+
+ GpgSignature.safe_create!(attributes)
end
end
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index 7987533978c..099677a791c 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -130,9 +130,14 @@ excluded_attributes:
snippets:
- :expired_at
merge_request_diff:
+ - :external_diff
+ - :stored_externally
+ - :external_diff_store
- :st_diffs
merge_request_diff_files:
- :diff
+ - :external_diff_offset
+ - :external_diff_size
issues:
- :milestone_id
merge_requests:
@@ -166,6 +171,7 @@ excluded_attributes:
error_tracking_setting:
- :encrypted_token
- :encrypted_token_iv
+ - :enabled
methods:
labels:
diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb
index a56ec65b9f1..51001750a6c 100644
--- a/lib/gitlab/import_export/project_tree_restorer.rb
+++ b/lib/gitlab/import_export/project_tree_restorer.rb
@@ -107,7 +107,7 @@ module Gitlab
def project_params
@project_params ||= begin
- attrs = json_params.merge(override_params)
+ attrs = json_params.merge(override_params).merge(visibility_level)
# Cleaning all imported and overridden params
Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: attrs,
@@ -127,6 +127,13 @@ module Gitlab
end
end
+ def visibility_level
+ level = override_params['visibility_level'] || json_params['visibility_level'] || @project.visibility_level
+ level = @project.group.visibility_level if @project.group && level > @project.group.visibility_level
+
+ { 'visibility_level' => level }
+ end
+
# Given a relation hash containing one or more models and its relationships,
# loops through each model and each object from a model type and
# and assigns its correspondent attributes hash from +tree_hash+
diff --git a/lib/gitlab/import_export/shared.rb b/lib/gitlab/import_export/shared.rb
index c13e6c1d83b..947caaaefee 100644
--- a/lib/gitlab/import_export/shared.rb
+++ b/lib/gitlab/import_export/shared.rb
@@ -8,6 +8,7 @@ module Gitlab
def initialize(project)
@project = project
@errors = []
+ @logger = Gitlab::Import::Logger.build
end
def active_export_count
@@ -23,19 +24,16 @@ module Gitlab
end
def error(error)
- error_out(error.message, caller[0].dup)
- add_error_message(error.message)
+ log_error(message: error.message, caller: caller[0].dup)
+ log_debug(backtrace: error.backtrace&.join("\n"))
+
+ Gitlab::Sentry.track_acceptable_exception(error, extra: log_base_data)
- # Debug:
- if error.backtrace
- Rails.logger.error("Import/Export backtrace: #{error.backtrace.join("\n")}")
- else
- Rails.logger.error("No backtrace found")
- end
+ add_error_message(error.message)
end
- def add_error_message(error_message)
- @errors << error_message
+ def add_error_message(message)
+ @errors << filtered_error_message(message)
end
def after_export_in_progress?
@@ -52,8 +50,25 @@ module Gitlab
@project.disk_path
end
- def error_out(message, caller)
- Rails.logger.error("Import/Export error raised on #{caller}: #{message}")
+ def log_error(details)
+ @logger.error(log_base_data.merge(details))
+ end
+
+ def log_debug(details)
+ @logger.debug(log_base_data.merge(details))
+ end
+
+ def log_base_data
+ {
+ importer: 'Import/Export',
+ import_jid: @project&.import_state&.import_jid,
+ project_id: @project&.id,
+ project_path: @project&.full_path
+ }
+ end
+
+ def filtered_error_message(message)
+ Projects::ImportErrorFilter.filter_message(message)
end
def after_export_lock_file
diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb
index b9903e37f40..7dfd9ed4f35 100644
--- a/lib/gitlab/kubernetes/helm/api.rb
+++ b/lib/gitlab/kubernetes/helm/api.rb
@@ -20,14 +20,7 @@ module Gitlab
kubeclient.create_pod(command.pod_resource)
end
- def update(command)
- namespace.ensure_exists!
-
- update_config_map(command)
-
- delete_pod!(command.pod_name)
- kubeclient.create_pod(command.pod_resource)
- end
+ alias_method :update, :install
##
# Returns Pod phase
@@ -62,6 +55,8 @@ module Gitlab
def create_config_map(command)
command.config_map_resource.tap do |config_map_resource|
+ break unless config_map_resource
+
if config_map_exists?(config_map_resource)
kubeclient.update_config_map(config_map_resource)
else
diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb
index a1ab5e048ac..f931248b747 100644
--- a/lib/gitlab/kubernetes/helm/install_command.rb
+++ b/lib/gitlab/kubernetes/helm/install_command.rb
@@ -42,8 +42,17 @@ module Gitlab
'helm repo update' if repository
end
+ # Uses `helm upgrade --install` which means we can use this for both
+ # installation and uprade of applications
def install_command
- command = ['helm', 'install', chart] + install_command_flags
+ command = ['helm', 'upgrade', name, chart] +
+ install_flag +
+ reset_values_flag +
+ optional_tls_flags +
+ optional_version_flag +
+ rbac_create_flag +
+ namespace_flag +
+ value_flag
command.shelljoin
end
@@ -56,17 +65,20 @@ module Gitlab
postinstall.join("\n") if postinstall
end
- def install_command_flags
- name_flag = ['--name', name]
- namespace_flag = ['--namespace', Gitlab::Kubernetes::Helm::NAMESPACE]
- value_flag = ['-f', "/data/helm/#{name}/config/values.yaml"]
+ def install_flag
+ ['--install']
+ end
- name_flag +
- optional_tls_flags +
- optional_version_flag +
- rbac_create_flag +
- namespace_flag +
- value_flag
+ def reset_values_flag
+ ['--reset-values']
+ end
+
+ def value_flag
+ ['-f', "/data/helm/#{name}/config/values.yaml"]
+ end
+
+ def namespace_flag
+ ['--namespace', Gitlab::Kubernetes::Helm::NAMESPACE]
end
def rbac_create_flag
diff --git a/lib/gitlab/kubernetes/helm/upgrade_command.rb b/lib/gitlab/kubernetes/helm/upgrade_command.rb
deleted file mode 100644
index 9daffc138b5..00000000000
--- a/lib/gitlab/kubernetes/helm/upgrade_command.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Kubernetes
- module Helm
- class UpgradeCommand
- include BaseCommand
- include ClientCommand
-
- attr_reader :name, :chart, :version, :repository, :files
-
- def initialize(name, chart:, files:, rbac:, version: nil, repository: nil)
- @name = name
- @chart = chart
- @rbac = rbac
- @version = version
- @files = files
- @repository = repository
- end
-
- def generate_script
- super + [
- init_command,
- wait_for_tiller_command,
- repository_command,
- script_command
- ].compact.join("\n")
- end
-
- def rbac?
- @rbac
- end
-
- def pod_name
- "upgrade-#{name}"
- end
-
- private
-
- def script_command
- upgrade_flags = "#{optional_version_flag}#{optional_tls_flags}" \
- " --reset-values" \
- " --install" \
- " --namespace #{::Gitlab::Kubernetes::Helm::NAMESPACE}" \
- " -f /data/helm/#{name}/config/values.yaml"
-
- "helm upgrade #{name} #{chart}#{upgrade_flags}"
- end
-
- def optional_version_flag
- " --version #{version}" if version
- end
-
- def optional_tls_flags
- return unless files.key?(:'ca.pem')
-
- " --tls" \
- " --tls-ca-cert #{files_dir}/ca.pem" \
- " --tls-cert #{files_dir}/cert.pem" \
- " --tls-key #{files_dir}/key.pem"
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/legacy_github_import/importer.rb b/lib/gitlab/legacy_github_import/importer.rb
index c526d31a591..f3323c98af2 100644
--- a/lib/gitlab/legacy_github_import/importer.rb
+++ b/lib/gitlab/legacy_github_import/importer.rb
@@ -267,7 +267,7 @@ module Gitlab
def import_wiki
unless project.wiki.repository_exists?
wiki = WikiFormatter.new(project)
- gitlab_shell.import_repository(project.repository_storage, wiki.disk_path, wiki.import_url)
+ gitlab_shell.import_wiki_repository(project, wiki)
end
rescue Gitlab::Shell::Error => e
# GitHub error message when the wiki repo has not been created,
diff --git a/lib/gitlab/legacy_github_import/wiki_formatter.rb b/lib/gitlab/legacy_github_import/wiki_formatter.rb
index ea52be5ee0f..cf1e21ad1e1 100644
--- a/lib/gitlab/legacy_github_import/wiki_formatter.rb
+++ b/lib/gitlab/legacy_github_import/wiki_formatter.rb
@@ -13,6 +13,10 @@ module Gitlab
project.wiki.disk_path
end
+ def full_path
+ project.wiki.full_path
+ end
+
def import_url
project.import_url.sub(/\.git\z/, ".wiki.git")
end
diff --git a/lib/gitlab/metrics/samplers/unicorn_sampler.rb b/lib/gitlab/metrics/samplers/unicorn_sampler.rb
index 4c4ec026823..4c5b849cc51 100644
--- a/lib/gitlab/metrics/samplers/unicorn_sampler.rb
+++ b/lib/gitlab/metrics/samplers/unicorn_sampler.rb
@@ -23,13 +23,13 @@ module Gitlab
def sample
Raindrops::Linux.tcp_listener_stats(tcp_listeners).each do |addr, stats|
- unicorn_active_connections.set({ type: 'tcp', address: addr }, stats.active)
- unicorn_queued_connections.set({ type: 'tcp', address: addr }, stats.queued)
+ unicorn_active_connections.set({ socket_type: 'tcp', socket_address: addr }, stats.active)
+ unicorn_queued_connections.set({ socket_type: 'tcp', socket_address: addr }, stats.queued)
end
Raindrops::Linux.unix_listener_stats(unix_listeners).each do |addr, stats|
- unicorn_active_connections.set({ type: 'unix', address: addr }, stats.active)
- unicorn_queued_connections.set({ type: 'unix', address: addr }, stats.queued)
+ unicorn_active_connections.set({ socket_type: 'unix', socket_address: addr }, stats.active)
+ unicorn_queued_connections.set({ socket_type: 'unix', socket_address: addr }, stats.queued)
end
end
diff --git a/lib/gitlab/patch/sprockets_base_file_digest_key.rb b/lib/gitlab/patch/sprockets_base_file_digest_key.rb
new file mode 100644
index 00000000000..3925cdbbada
--- /dev/null
+++ b/lib/gitlab/patch/sprockets_base_file_digest_key.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# This monkey patch prevent cache ballooning when caching tmp/cache/assets/sprockets
+# on the CI. See https://github.com/rails/sprockets/issues/563 and
+# https://github.com/rails/sprockets/compare/3.x...jmreid:no-mtime-for-digest-key.
+module Gitlab
+ module Patch
+ module SprocketsBaseFileDigestKey
+ def file_digest(path)
+ if stat = self.stat(path)
+ digest = self.stat_digest(path, stat)
+ integrity_uri = self.hexdigest_integrity_uri(digest)
+
+ key = Sprockets::UnloadedAsset.new(path, self).file_digest_key(integrity_uri)
+ cache.fetch(key) do
+ digest
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index fa68dead80b..3c888be0710 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -125,7 +125,8 @@ module Gitlab
# allow non-regex validations, etc), `NAMESPACE_FORMAT_REGEX_JS` serves as a Javascript-compatible version of
# `NAMESPACE_FORMAT_REGEX`, with the negative lookbehind assertion removed. This means that the client-side validation
# will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation.
- PATH_REGEX_STR = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*'.freeze
+ PATH_START_CHAR = '[a-zA-Z0-9_\.]'.freeze
+ PATH_REGEX_STR = PATH_START_CHAR + '[a-zA-Z0-9_\-\.]*'.freeze
NAMESPACE_FORMAT_REGEX_JS = PATH_REGEX_STR + '[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze
NO_SUFFIX_REGEX = /(?<!\.git|\.atom)/.freeze
diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb
index 3bfd6ee892c..ef656e5b2ce 100644
--- a/lib/gitlab/project_template.rb
+++ b/lib/gitlab/project_template.rb
@@ -2,14 +2,12 @@
module Gitlab
class ProjectTemplate
- attr_reader :title, :name, :description, :preview
+ attr_reader :title, :name, :description, :preview, :logo
- def initialize(name, title, description, preview)
- @name, @title, @description, @preview = name, title, description, preview
+ def initialize(name, title, description, preview, logo = 'illustrations/gitlab_logo.svg')
+ @name, @title, @description, @preview, @logo = name, title, description, preview, logo
end
- alias_method :logo, :name
-
def file
archive_path.open
end
@@ -27,9 +25,14 @@ module Gitlab
end
TEMPLATES_TABLE = [
- ProjectTemplate.new('rails', 'Ruby on Rails', 'Includes an MVC structure, Gemfile, Rakefile, along with many others, to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/rails'),
- ProjectTemplate.new('spring', 'Spring', 'Includes an MVC structure, mvnw and pom.xml to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/spring'),
- ProjectTemplate.new('express', 'NodeJS Express', 'Includes an MVC structure to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/express')
+ ProjectTemplate.new('rails', 'Ruby on Rails', _('Includes an MVC structure, Gemfile, Rakefile, along with many others, to help you get started.'), 'https://gitlab.com/gitlab-org/project-templates/rails', 'illustrations/logos/rails.svg'),
+ ProjectTemplate.new('spring', 'Spring', _('Includes an MVC structure, mvnw and pom.xml to help you get started.'), 'https://gitlab.com/gitlab-org/project-templates/spring', 'illustrations/logos/spring.svg'),
+ ProjectTemplate.new('express', 'NodeJS Express', _('Includes an MVC structure to help you get started.'), 'https://gitlab.com/gitlab-org/project-templates/express', 'illustrations/logos/express.svg'),
+ ProjectTemplate.new('hugo', 'Pages/Hugo', _('Everything you need to create a GitLab Pages site using Hugo.'), 'https://gitlab.com/pages/hugo'),
+ ProjectTemplate.new('jekyll', 'Pages/Jekyll', _('Everything you need to create a GitLab Pages site using Jekyll.'), 'https://gitlab.com/pages/jekyll'),
+ ProjectTemplate.new('plainhtml', 'Pages/Plain HTML', _('Everything you need to create a GitLab Pages site using plain HTML.'), 'https://gitlab.com/pages/plain-html'),
+ ProjectTemplate.new('gitbook', 'Pages/GitBook', _('Everything you need to create a GitLab Pages site using GitBook.'), 'https://gitlab.com/pages/gitbook'),
+ ProjectTemplate.new('hexo', 'Pages/Hexo', _('Everything you need to create a GitLab Pages site using Hexo.'), 'https://gitlab.com/pages/hexo')
].freeze
class << self
diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb
index bdf21cf3134..1153e69d3de 100644
--- a/lib/gitlab/shell.rb
+++ b/lib/gitlab/shell.rb
@@ -64,27 +64,48 @@ module Gitlab
end
end
+ # Convenience methods for initializing a new repository with a Project model.
+ def create_project_repository(project)
+ create_repository(project.repository_storage, project.disk_path, project.full_path)
+ end
+
+ def create_wiki_repository(project)
+ create_repository(project.repository_storage, project.wiki.disk_path, project.wiki.full_path)
+ end
+
# Init new repository
#
# storage - the shard key
- # name - project disk path
+ # disk_path - project disk path
+ # gl_project_path - project name
#
# Ex.
- # create_repository("default", "gitlab/gitlab-ci")
+ # create_repository("default", "path/to/gitlab-ci", "gitlab/gitlab-ci")
#
- def create_repository(storage, name)
- relative_path = name.dup
+ def create_repository(storage, disk_path, gl_project_path)
+ relative_path = disk_path.dup
relative_path << '.git' unless relative_path.end_with?('.git')
- repository = Gitlab::Git::Repository.new(storage, relative_path, '')
+ # During creation of a repository, gl_repository may not be known
+ # because that depends on a yet-to-be assigned project ID in the
+ # database (e.g. project-1234), so for now it is blank.
+ repository = Gitlab::Git::Repository.new(storage, relative_path, '', gl_project_path)
wrapped_gitaly_errors { repository.gitaly_repository_client.create_repository }
true
rescue => err # Once the Rugged codes gets removes this can be improved
- Rails.logger.error("Failed to add repository #{storage}/#{name}: #{err}")
+ Rails.logger.error("Failed to add repository #{storage}/#{disk_path}: #{err}")
false
end
+ def import_wiki_repository(project, wiki_formatter)
+ import_repository(project.repository_storage, wiki_formatter.disk_path, wiki_formatter.import_url, project.wiki.full_path)
+ end
+
+ def import_project_repository(project)
+ import_repository(project.repository_storage, project.disk_path, project.import_url, project.full_path)
+ end
+
# Import repository
#
# storage - project's storage name
@@ -94,13 +115,13 @@ module Gitlab
# Ex.
# import_repository("nfs-file06", "gitlab/gitlab-ci", "https://gitlab.com/gitlab-org/gitlab-test.git")
#
- def import_repository(storage, name, url)
+ def import_repository(storage, name, url, gl_project_path)
if url.start_with?('.', '/')
raise Error.new("don't use disk paths with import_repository: #{url.inspect}")
end
relative_path = "#{name}.git"
- cmd = GitalyGitlabProjects.new(storage, relative_path)
+ cmd = GitalyGitlabProjects.new(storage, relative_path, gl_project_path)
success = cmd.import_project(url, git_timeout)
raise Error, cmd.output unless success
@@ -125,18 +146,13 @@ module Gitlab
end
# Fork repository to new path
- # forked_from_storage - forked-from project's storage name
- # forked_from_disk_path - project disk relative path
- # forked_to_storage - forked-to project's storage name
- # forked_to_disk_path - forked project disk relative path
- #
- # Ex.
- # fork_repository("nfs-file06", "gitlab/gitlab-ci", "nfs-file07", "new-namespace/gitlab-ci")
- def fork_repository(forked_from_storage, forked_from_disk_path, forked_to_storage, forked_to_disk_path)
- forked_from_relative_path = "#{forked_from_disk_path}.git"
- fork_args = [forked_to_storage, "#{forked_to_disk_path}.git"]
+ # source_project - forked-from Project
+ # target_project - forked-to Project
+ def fork_repository(source_project, target_project)
+ forked_from_relative_path = "#{source_project.disk_path}.git"
+ fork_args = [target_project.repository_storage, "#{target_project.disk_path}.git", target_project.full_path]
- GitalyGitlabProjects.new(forked_from_storage, forked_from_relative_path).fork_repository(*fork_args)
+ GitalyGitlabProjects.new(source_project.repository_storage, forked_from_relative_path, source_project.full_path).fork_repository(*fork_args)
end
# Removes a repository from file system, using rm_diretory which is an alias
@@ -397,16 +413,17 @@ module Gitlab
end
class GitalyGitlabProjects
- attr_reader :shard_name, :repository_relative_path, :output
+ attr_reader :shard_name, :repository_relative_path, :output, :gl_project_path
- def initialize(shard_name, repository_relative_path)
+ def initialize(shard_name, repository_relative_path, gl_project_path)
@shard_name = shard_name
@repository_relative_path = repository_relative_path
@output = ''
+ @gl_project_path = gl_project_path
end
def import_project(source, _timeout)
- raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil)
+ raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil, gl_project_path)
Gitlab::GitalyClient::RepositoryService.new(raw_repository).import_repository(source)
true
@@ -415,9 +432,9 @@ module Gitlab
false
end
- def fork_repository(new_shard_name, new_repository_relative_path)
- target_repository = Gitlab::Git::Repository.new(new_shard_name, new_repository_relative_path, nil)
- raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil)
+ def fork_repository(new_shard_name, new_repository_relative_path, new_project_name)
+ target_repository = Gitlab::Git::Repository.new(new_shard_name, new_repository_relative_path, nil, new_project_name)
+ raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil, gl_project_path)
Gitlab::GitalyClient::RepositoryService.new(target_repository).fork_repository(raw_repository)
rescue GRPC::BadStatus => e
diff --git a/lib/gitlab/task_helpers.rb b/lib/gitlab/task_helpers.rb
index 224bb648d8f..8532845f3cb 100644
--- a/lib/gitlab/task_helpers.rb
+++ b/lib/gitlab/task_helpers.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rainbow/ext/string'
-require 'gitlab/utils/strong_memoize'
+require_dependency 'gitlab/utils/strong_memoize'
# rubocop:disable Rails/Output
module Gitlab
@@ -13,6 +13,12 @@ module Gitlab
extend self
+ def invoke_and_time_task(task)
+ start = Time.now
+ Rake::Task[task].invoke
+ puts "`#{task}` finished in #{Time.now - start} seconds"
+ end
+
# Ask if the user wants to continue
#
# Returns "yes" the user chose to continue
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 083c620267a..6bfcf83f388 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -81,6 +81,7 @@ module Gitlab
pages_domains: count(PagesDomain),
projects: count(Project),
projects_imported_from_github: count(Project.where(import_type: 'github')),
+ projects_with_repositories_enabled: count(ProjectFeature.where('repository_access_level > ?', ProjectFeature::DISABLED)),
protected_branches: count(ProtectedBranch),
releases: count(Release),
remote_mirrors: count(RemoteMirror),
diff --git a/lib/gitlab/utils/merge_hash.rb b/lib/gitlab/utils/merge_hash.rb
index fc237861e2f..48ba13b8561 100644
--- a/lib/gitlab/utils/merge_hash.rb
+++ b/lib/gitlab/utils/merge_hash.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require_dependency 'gitlab/utils'
+
module Gitlab
module Utils
module MergeHash
diff --git a/lib/gitlab/utils/override.rb b/lib/gitlab/utils/override.rb
index c87e97d0213..f5299439fce 100644
--- a/lib/gitlab/utils/override.rb
+++ b/lib/gitlab/utils/override.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require_dependency 'gitlab/utils'
+
module Gitlab
module Utils
module Override
diff --git a/lib/gitlab/utils/strong_memoize.rb b/lib/gitlab/utils/strong_memoize.rb
index aa1f8e2fdda..3021a91dd83 100644
--- a/lib/gitlab/utils/strong_memoize.rb
+++ b/lib/gitlab/utils/strong_memoize.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require_dependency 'gitlab/utils'
+
module Gitlab
module Utils
module StrongMemoize
diff --git a/lib/safe_zip/entry.rb b/lib/safe_zip/entry.rb
new file mode 100644
index 00000000000..664e2f52f91
--- /dev/null
+++ b/lib/safe_zip/entry.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+module SafeZip
+ class Entry
+ attr_reader :zip_archive, :zip_entry
+ attr_reader :path, :params
+
+ def initialize(zip_archive, zip_entry, params)
+ @zip_archive = zip_archive
+ @zip_entry = zip_entry
+ @params = params
+ @path = ::File.expand_path(zip_entry.name, params.extract_path)
+ end
+
+ def path_dir
+ ::File.dirname(path)
+ end
+
+ def real_path_dir
+ ::File.realpath(path_dir)
+ end
+
+ def exist?
+ ::File.exist?(path)
+ end
+
+ def extract
+ # do not extract if file is not part of target directory
+ return false unless matching_target_directory
+
+ # do not overwrite existing file
+ raise SafeZip::Extract::AlreadyExistsError, "File already exists #{zip_entry.name}" if exist?
+
+ create_path_dir
+
+ if zip_entry.file?
+ extract_file
+ elsif zip_entry.directory?
+ extract_dir
+ elsif zip_entry.symlink?
+ extract_symlink
+ else
+ raise SafeZip::Extract::UnsupportedEntryError, "File #{zip_entry.name} cannot be extracted"
+ end
+ rescue SafeZip::Extract::Error
+ raise
+ rescue => e
+ raise SafeZip::Extract::ExtractError, e.message
+ end
+
+ private
+
+ def extract_file
+ zip_archive.extract(zip_entry, path)
+ end
+
+ def extract_dir
+ FileUtils.mkdir(path)
+ end
+
+ def extract_symlink
+ source_path = read_symlink
+ real_source_path = expand_symlink(source_path)
+
+ # ensure that source path of symlink is within target directories
+ unless real_source_path.start_with?(matching_target_directory)
+ raise SafeZip::Extract::PermissionDeniedError, "Symlink cannot be created targeting: #{source_path}"
+ end
+
+ ::File.symlink(source_path, path)
+ end
+
+ def create_path_dir
+ # Create all directories, but ignore permissions
+ FileUtils.mkdir_p(path_dir)
+
+ # disallow to make path dirs to point to another directories
+ unless path_dir == real_path_dir
+ raise SafeZip::Extract::PermissionDeniedError, "Directory of #{zip_entry.name} points to another directory"
+ end
+ end
+
+ def matching_target_directory
+ params.matching_target_directory(path)
+ end
+
+ def read_symlink
+ zip_archive.read(zip_entry)
+ end
+
+ def expand_symlink(source_path)
+ ::File.realpath(source_path, path_dir)
+ rescue
+ raise SafeZip::Extract::SymlinkSourceDoesNotExistError, "Symlink source #{source_path} does not exist"
+ end
+ end
+end
diff --git a/lib/safe_zip/extract.rb b/lib/safe_zip/extract.rb
new file mode 100644
index 00000000000..679c021c730
--- /dev/null
+++ b/lib/safe_zip/extract.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module SafeZip
+ class Extract
+ Error = Class.new(StandardError)
+ PermissionDeniedError = Class.new(Error)
+ SymlinkSourceDoesNotExistError = Class.new(Error)
+ UnsupportedEntryError = Class.new(Error)
+ AlreadyExistsError = Class.new(Error)
+ NoMatchingError = Class.new(Error)
+ ExtractError = Class.new(Error)
+
+ attr_reader :archive_path
+
+ def initialize(archive_file)
+ @archive_path = archive_file
+ end
+
+ def extract(opts = {})
+ params = SafeZip::ExtractParams.new(**opts)
+
+ if Feature.enabled?(:safezip_use_rubyzip, default_enabled: true)
+ extract_with_ruby_zip(params)
+ else
+ legacy_unsafe_extract_with_system_zip(params)
+ end
+ end
+
+ private
+
+ def extract_with_ruby_zip(params)
+ ::Zip::File.open(archive_path) do |zip_archive|
+ # Extract all files in the following order:
+ # 1. Directories first,
+ # 2. Files next,
+ # 3. Symlinks last (or anything else)
+ extracted = extract_all_entries(zip_archive, params,
+ zip_archive.lazy.select(&:directory?))
+
+ extracted += extract_all_entries(zip_archive, params,
+ zip_archive.lazy.select(&:file?))
+
+ extracted += extract_all_entries(zip_archive, params,
+ zip_archive.lazy.reject(&:directory?).reject(&:file?))
+
+ raise NoMatchingError, 'No entries extracted' unless extracted > 0
+ end
+ end
+
+ def extract_all_entries(zip_archive, params, entries)
+ entries.count do |zip_entry|
+ SafeZip::Entry.new(zip_archive, zip_entry, params)
+ .extract
+ end
+ end
+
+ def legacy_unsafe_extract_with_system_zip(params)
+ # Requires UnZip at least 6.00 Info-ZIP.
+ # -n never overwrite existing files
+ args = %W(unzip -n -qq #{archive_path})
+
+ # We add * to end of directory, because we want to extract directory and all subdirectories
+ args += params.directories_wildcard
+
+ # Target directory where we extract
+ args += %W(-d #{params.extract_path})
+
+ unless system(*args)
+ raise Error, 'archive failed to extract'
+ end
+ end
+ end
+end
diff --git a/lib/safe_zip/extract_params.rb b/lib/safe_zip/extract_params.rb
new file mode 100644
index 00000000000..bd3b788bac9
--- /dev/null
+++ b/lib/safe_zip/extract_params.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module SafeZip
+ class ExtractParams
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :directories, :extract_path
+
+ def initialize(directories:, to:)
+ @directories = directories
+ @extract_path = ::File.realpath(to)
+ end
+
+ def matching_target_directory(path)
+ target_directories.find do |directory|
+ path.start_with?(directory)
+ end
+ end
+
+ def target_directories
+ strong_memoize(:target_directories) do
+ directories.map do |directory|
+ ::File.join(::File.expand_path(directory, extract_path), '')
+ end
+ end
+ end
+
+ def directories_wildcard
+ strong_memoize(:directories_wildcard) do
+ directories.map do |directory|
+ ::File.join(directory, '*')
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sentry/client.rb b/lib/sentry/client.rb
index 343f2c49a7f..4187014d49e 100644
--- a/lib/sentry/client.rb
+++ b/lib/sentry/client.rb
@@ -3,6 +3,7 @@
module Sentry
class Client
Error = Class.new(StandardError)
+ SentryError = Class.new(StandardError)
attr_accessor :url, :token
@@ -16,6 +17,13 @@ module Sentry
map_to_errors(issues)
end
+ def list_projects
+ projects = get_projects
+ map_to_projects(projects)
+ rescue KeyError => e
+ raise Client::SentryError, "Sentry API response is missing keys. #{e.message}"
+ end
+
private
def request_params
@@ -27,18 +35,23 @@ module Sentry
}
end
- def get_issues(issue_status:, limit:)
- resp = Gitlab::HTTP.get(
- issues_api_url,
- **request_params.merge(query: {
- query: "is:#{issue_status}",
- limit: limit
- })
- )
+ def http_get(url, params = {})
+ resp = Gitlab::HTTP.get(url, **request_params.merge(params))
handle_response(resp)
end
+ def get_issues(issue_status:, limit:)
+ http_get(issues_api_url, query: {
+ query: "is:#{issue_status}",
+ limit: limit
+ })
+ end
+
+ def get_projects
+ http_get(projects_api_url)
+ end
+
def handle_response(response)
unless response.code == 200
raise Client::Error, "Sentry response error: #{response.code}"
@@ -47,6 +60,13 @@ module Sentry
response.as_json
end
+ def projects_api_url
+ projects_url = URI(@url)
+ projects_url.path = '/api/0/projects/'
+
+ projects_url
+ end
+
def issues_api_url
issues_url = URI(@url + '/issues/')
issues_url.path.squeeze!('/')
@@ -55,9 +75,11 @@ module Sentry
end
def map_to_errors(issues)
- issues.map do |issue|
- map_to_error(issue)
- end
+ issues.map(&method(:map_to_error))
+ end
+
+ def map_to_projects(projects)
+ projects.map(&method(:map_to_project))
end
def issue_url(id)
@@ -100,5 +122,19 @@ module Sentry
project_slug: project.fetch('slug', nil)
)
end
+
+ def map_to_project(project)
+ organization = project.fetch('organization')
+
+ Gitlab::ErrorTracking::Project.new(
+ id: project.fetch('id'),
+ name: project.fetch('name'),
+ slug: project.fetch('slug'),
+ status: project.dig('status'),
+ organization_name: organization.fetch('name'),
+ organization_id: organization.fetch('id'),
+ organization_slug: organization.fetch('slug')
+ )
+ end
end
end
diff --git a/lib/tasks/gitlab/assets.rake b/lib/tasks/gitlab/assets.rake
index a42f02a84fd..7a42e4e92a0 100644
--- a/lib/tasks/gitlab/assets.rake
+++ b/lib/tasks/gitlab/assets.rake
@@ -1,13 +1,17 @@
namespace :gitlab do
namespace :assets do
desc 'GitLab | Assets | Compile all frontend assets'
- task compile: [
- 'yarn:check',
- 'gettext:po_to_json',
- 'rake:assets:precompile',
- 'webpack:compile',
- 'fix_urls'
- ]
+ task :compile do
+ require_dependency 'gitlab/task_helpers'
+
+ %w[
+ yarn:check
+ gettext:po_to_json
+ rake:assets:precompile
+ webpack:compile
+ gitlab:assets:fix_urls
+ ].each(&Gitlab::TaskHelpers.method(:invoke_and_time_task))
+ end
desc 'GitLab | Assets | Clean up old compiled frontend assets'
task clean: ['rake:assets:clean']
diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake
index e96fbb64372..3a1a36e36ce 100644
--- a/lib/tasks/gitlab/backup.rake
+++ b/lib/tasks/gitlab/backup.rake
@@ -47,9 +47,9 @@ namespace :gitlab do
# Drop all tables Load the schema to ensure we don't have any newer tables
# hanging out from a failed upgrade
- progress.puts 'Cleaning the database ... '.color(:blue)
+ puts_time 'Cleaning the database ... '.color(:blue)
Rake::Task['gitlab:db:drop_tables'].invoke
- progress.puts 'done'.color(:green)
+ puts_time 'done'.color(:green)
Rake::Task['gitlab:backup:db:restore'].invoke
rescue Gitlab::TaskAbortedByUserError
puts "Quitting...".color(:red)
@@ -72,165 +72,169 @@ namespace :gitlab do
namespace :repo do
task create: :gitlab_environment do
- progress.puts "Dumping repositories ...".color(:blue)
+ puts_time "Dumping repositories ...".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("repositories")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Repository.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring repositories ...".color(:blue)
+ puts_time "Restoring repositories ...".color(:blue)
Backup::Repository.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :db do
task create: :gitlab_environment do
- progress.puts "Dumping database ... ".color(:blue)
+ puts_time "Dumping database ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("db")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Database.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring database ... ".color(:blue)
+ puts_time "Restoring database ... ".color(:blue)
Backup::Database.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :builds do
task create: :gitlab_environment do
- progress.puts "Dumping builds ... ".color(:blue)
+ puts_time "Dumping builds ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("builds")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Builds.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring builds ... ".color(:blue)
+ puts_time "Restoring builds ... ".color(:blue)
Backup::Builds.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :uploads do
task create: :gitlab_environment do
- progress.puts "Dumping uploads ... ".color(:blue)
+ puts_time "Dumping uploads ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("uploads")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Uploads.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring uploads ... ".color(:blue)
+ puts_time "Restoring uploads ... ".color(:blue)
Backup::Uploads.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :artifacts do
task create: :gitlab_environment do
- progress.puts "Dumping artifacts ... ".color(:blue)
+ puts_time "Dumping artifacts ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("artifacts")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Artifacts.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring artifacts ... ".color(:blue)
+ puts_time "Restoring artifacts ... ".color(:blue)
Backup::Artifacts.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :pages do
task create: :gitlab_environment do
- progress.puts "Dumping pages ... ".color(:blue)
+ puts_time "Dumping pages ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("pages")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Pages.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring pages ... ".color(:blue)
+ puts_time "Restoring pages ... ".color(:blue)
Backup::Pages.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :lfs do
task create: :gitlab_environment do
- progress.puts "Dumping lfs objects ... ".color(:blue)
+ puts_time "Dumping lfs objects ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("lfs")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Lfs.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring lfs objects ... ".color(:blue)
+ puts_time "Restoring lfs objects ... ".color(:blue)
Backup::Lfs.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :registry do
task create: :gitlab_environment do
- progress.puts "Dumping container registry images ... ".color(:blue)
+ puts_time "Dumping container registry images ... ".color(:blue)
if Gitlab.config.registry.enabled
if ENV["SKIP"] && ENV["SKIP"].include?("registry")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Registry.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
else
- progress.puts "[DISABLED]".color(:cyan)
+ puts_time "[DISABLED]".color(:cyan)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring container registry images ... ".color(:blue)
+ puts_time "Restoring container registry images ... ".color(:blue)
if Gitlab.config.registry.enabled
Backup::Registry.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
else
- progress.puts "[DISABLED]".color(:cyan)
+ puts_time "[DISABLED]".color(:cyan)
end
end
end
+ def puts_time(msg)
+ progress.puts "#{Time.now} -- #{msg}"
+ end
+
def progress
if ENV['CRON']
# We need an object we can say 'puts' and 'print' to; let's use a
diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake
index 74cd70c6e9f..b94b21775ee 100644
--- a/lib/tasks/gitlab/db.rake
+++ b/lib/tasks/gitlab/db.rake
@@ -29,10 +29,11 @@ namespace :gitlab do
# If MySQL, turn off foreign key checks
connection.execute('SET FOREIGN_KEY_CHECKS=0') if Gitlab::Database.mysql?
- tables = connection.tables
+ tables = connection.data_sources
+ # Removes the entry from the array
tables.delete 'schema_migrations'
# Truncate schema_migrations to ensure migrations re-run
- connection.execute('TRUNCATE schema_migrations')
+ connection.execute('TRUNCATE schema_migrations') if connection.data_source_exists? 'schema_migrations'
# Drop tables with cascade to avoid dependent table errors
# PG: http://www.postgresql.org/docs/current/static/ddl-depend.html
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5b794ae587b..a3f78968a55 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -22,16 +22,6 @@ msgstr ""
msgid " or "
msgstr ""
-msgid "%d addition"
-msgid_plural "%d additions"
-msgstr[0] ""
-msgstr[1] ""
-
-msgid "%d changed file"
-msgid_plural "%d changed files"
-msgstr[0] ""
-msgstr[1] ""
-
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] ""
@@ -42,10 +32,8 @@ msgid_plural "%d commits behind"
msgstr[0] ""
msgstr[1] ""
-msgid "%d deleted"
-msgid_plural "%d deletions"
-msgstr[0] ""
-msgstr[1] ""
+msgid "%d commits"
+msgstr ""
msgid "%d exporter"
msgid_plural "%d exporters"
@@ -138,9 +126,6 @@ msgstr ""
msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
msgstr ""
-msgid "%{nip_domain} can be used as an alternative to a custom domain."
-msgstr ""
-
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr ""
@@ -468,9 +453,30 @@ msgstr ""
msgid "AdminSettings|When creating a new environment variable it will be protected by default."
msgstr ""
+msgid "AdminUsers|2FA Disabled"
+msgstr ""
+
+msgid "AdminUsers|2FA Enabled"
+msgstr ""
+
+msgid "AdminUsers|Active"
+msgstr ""
+
+msgid "AdminUsers|Admin"
+msgstr ""
+
+msgid "AdminUsers|Admins"
+msgstr ""
+
msgid "AdminUsers|Block user"
msgstr ""
+msgid "AdminUsers|Blocked"
+msgstr ""
+
+msgid "AdminUsers|Cannot unblock LDAP blocked users"
+msgstr ""
+
msgid "AdminUsers|Delete User %{username} and contributions?"
msgstr ""
@@ -483,12 +489,39 @@ msgstr ""
msgid "AdminUsers|Delete user and contributions"
msgstr ""
+msgid "AdminUsers|External"
+msgstr ""
+
+msgid "AdminUsers|It's you!"
+msgstr ""
+
+msgid "AdminUsers|New user"
+msgstr ""
+
+msgid "AdminUsers|No users found"
+msgstr ""
+
+msgid "AdminUsers|Search by name, email or username"
+msgstr ""
+
+msgid "AdminUsers|Search users"
+msgstr ""
+
+msgid "AdminUsers|Sort by"
+msgstr ""
+
msgid "AdminUsers|To confirm, type %{projectName}"
msgstr ""
msgid "AdminUsers|To confirm, type %{username}"
msgstr ""
+msgid "AdminUsers|User will be blocked"
+msgstr ""
+
+msgid "AdminUsers|Without projects"
+msgstr ""
+
msgid "Advanced permissions, Large File Storage and Two-Factor authentication settings."
msgstr ""
@@ -552,9 +585,6 @@ msgstr ""
msgid "An empty GitLab User field will add the FogBugz user's full name (e.g. \"By John Smith\") in the description of all issues and comments. It will also associate and/or assign these issues and comments with the project creator."
msgstr ""
-msgid "An error accured whilst committing your changes."
-msgstr ""
-
msgid "An error has occurred"
msgstr ""
@@ -639,6 +669,9 @@ msgstr ""
msgid "An error occurred while validating username"
msgstr ""
+msgid "An error occurred whilst committing your changes."
+msgstr ""
+
msgid "An error occurred whilst fetching the job trace."
msgstr ""
@@ -711,6 +744,9 @@ msgstr ""
msgid "Archived projects"
msgstr ""
+msgid "Are you sure"
+msgstr ""
+
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
@@ -849,15 +885,6 @@ msgstr ""
msgid "Auto DevOps, runners and job artifacts"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
-msgstr ""
-
-msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
-msgstr ""
-
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
-msgstr ""
-
msgid "Auto-cancel redundant, pending pipelines"
msgstr ""
@@ -909,6 +936,9 @@ msgstr ""
msgid "Avatar for %{assigneeName}"
msgstr ""
+msgid "Avatar for %{name}"
+msgstr ""
+
msgid "Avatar will be removed. Are you sure?"
msgstr ""
@@ -1014,6 +1044,9 @@ msgstr ""
msgid "Bitbucket import"
msgstr ""
+msgid "Block"
+msgstr ""
+
msgid "Blocked"
msgstr ""
@@ -1224,9 +1257,6 @@ msgstr ""
msgid "CICD|Deployment strategy"
msgstr ""
-msgid "CICD|Deployment strategy needs a domain name to work correctly."
-msgstr ""
-
msgid "CICD|Jobs"
msgstr ""
@@ -1236,7 +1266,7 @@ msgstr ""
msgid "CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found."
msgstr ""
-msgid "CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages."
+msgid "CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly."
msgstr ""
msgid "CICD|instance enabled"
@@ -1509,6 +1539,12 @@ msgstr ""
msgid "Closed (moved)"
msgstr ""
+msgid "ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}."
+msgstr ""
+
+msgid "ClusterIntegration| can be used instead of a custom domain."
+msgstr ""
+
msgid "ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}"
msgstr ""
@@ -1518,6 +1554,9 @@ msgstr ""
msgid "ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which may incur additional costs depending on the hosting provider your Kubernetes cluster is installed on. If you are using Google Kubernetes Engine, you can %{pricingLink}."
msgstr ""
+msgid "ClusterIntegration|%{title} upgraded successfully."
+msgstr ""
+
msgid "ClusterIntegration|API URL"
msgstr ""
@@ -1539,6 +1578,9 @@ msgstr ""
msgid "ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}"
msgstr ""
+msgid "ClusterIntegration|Alternatively"
+msgstr ""
+
msgid "ClusterIntegration|An error occured while trying to fetch project zones: %{error}"
msgstr ""
@@ -1560,6 +1602,9 @@ msgstr ""
msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
msgstr ""
+msgid "ClusterIntegration|Base domain"
+msgstr ""
+
msgid "ClusterIntegration|CA Certificate"
msgstr ""
@@ -1836,6 +1881,9 @@ msgstr ""
msgid "ClusterIntegration|Request to begin installing failed"
msgstr ""
+msgid "ClusterIntegration|Retry upgrade"
+msgstr ""
+
msgid "ClusterIntegration|Save changes"
msgstr ""
@@ -1878,12 +1926,18 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
+msgid "ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again."
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
msgid "ClusterIntegration|Something went wrong while installing %{title}"
msgstr ""
+msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain."
+msgstr ""
+
msgid "ClusterIntegration|The IP address is in the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time."
msgstr ""
@@ -1899,6 +1953,18 @@ msgstr ""
msgid "ClusterIntegration|Token"
msgstr ""
+msgid "ClusterIntegration|Upgrade"
+msgstr ""
+
+msgid "ClusterIntegration|Upgrade failed"
+msgstr ""
+
+msgid "ClusterIntegration|Upgraded"
+msgstr ""
+
+msgid "ClusterIntegration|Upgrading"
+msgstr ""
+
msgid "ClusterIntegration|Validating project billing status"
msgstr ""
@@ -2348,6 +2414,9 @@ msgstr ""
msgid "Created by me"
msgstr ""
+msgid "Created on"
+msgstr ""
+
msgid "Created on:"
msgstr ""
@@ -2384,6 +2453,9 @@ msgstr ""
msgid "Customize how Google Code email addresses and usernames are imported into GitLab. In the next step, you'll be able to select the projects you want to import."
msgstr ""
+msgid "Customize language and region related settings."
+msgstr ""
+
msgid "Customize your pipeline configuration, view your pipeline status and coverage report."
msgstr ""
@@ -2426,6 +2498,9 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Data is still calculating..."
+msgstr ""
+
msgid "Debug"
msgstr ""
@@ -2444,6 +2519,12 @@ msgstr ""
msgid "Default Branch"
msgstr ""
+msgid "Default first day of the week"
+msgstr ""
+
+msgid "Default first day of the week in calendars and date pickers."
+msgstr ""
+
msgid "Default: Directly import the Google Code email address or username"
msgstr ""
@@ -2483,6 +2564,9 @@ msgstr ""
msgid "Delete list"
msgstr ""
+msgid "Delete source branch"
+msgstr ""
+
msgid "Delete this attachment"
msgstr ""
@@ -2620,6 +2704,9 @@ msgstr ""
msgid "DeployTokens|Your new project deploy token has been created."
msgstr ""
+msgid "Deployed"
+msgstr ""
+
msgid "Deployed to"
msgstr ""
@@ -2707,6 +2794,9 @@ msgstr ""
msgid "Dismiss"
msgstr ""
+msgid "Dismiss ConvDev introduction"
+msgstr ""
+
msgid "Dismiss Cycle Analytics introduction box"
msgstr ""
@@ -2992,6 +3082,9 @@ msgstr ""
msgid "Error Tracking"
msgstr ""
+msgid "Error deleting %{issuableType}"
+msgstr ""
+
msgid "Error fetching contributors data."
msgstr ""
@@ -3040,6 +3133,9 @@ msgstr ""
msgid "Error saving label update."
msgstr ""
+msgid "Error updating %{issuableType}"
+msgstr ""
+
msgid "Error updating status for all todos."
msgstr ""
@@ -3097,6 +3193,21 @@ msgstr ""
msgid "Everyone can contribute"
msgstr ""
+msgid "Everything you need to create a GitLab Pages site using GitBook."
+msgstr ""
+
+msgid "Everything you need to create a GitLab Pages site using Hexo."
+msgstr ""
+
+msgid "Everything you need to create a GitLab Pages site using Hugo."
+msgstr ""
+
+msgid "Everything you need to create a GitLab Pages site using Jekyll."
+msgstr ""
+
+msgid "Everything you need to create a GitLab Pages site using plain HTML."
+msgstr ""
+
msgid "Except policy:"
msgstr ""
@@ -3151,6 +3262,9 @@ msgstr ""
msgid "External URL"
msgstr ""
+msgid "External Wiki"
+msgstr ""
+
msgid "Facebook"
msgstr ""
@@ -3193,6 +3307,9 @@ msgstr ""
msgid "Failure"
msgstr ""
+msgid "Fast-forward merge without a merge commit"
+msgstr ""
+
msgid "Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)"
msgstr ""
@@ -3205,6 +3322,11 @@ msgstr ""
msgid "Fields on this page are now uneditable, you can configure"
msgstr ""
+msgid "File"
+msgid_plural "Files"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "File added"
msgstr ""
@@ -3277,6 +3399,9 @@ msgstr ""
msgid "Finished"
msgstr ""
+msgid "First day of the week"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr ""
@@ -3828,21 +3953,39 @@ msgstr ""
msgid "In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}."
msgstr ""
+msgid "In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index."
+msgstr ""
+
msgid "In the next step, you'll be able to select the projects you want to import."
msgstr ""
msgid "Include a Terms of Service agreement and Privacy Policy that all users must accept."
msgstr ""
+msgid "Include merge request description"
+msgstr ""
+
msgid "Include the username in the URL if required: <code>https://username@gitlab.company.com/group/project.git</code>."
msgstr ""
+msgid "Includes an MVC structure to help you get started."
+msgstr ""
+
+msgid "Includes an MVC structure, Gemfile, Rakefile, along with many others, to help you get started."
+msgstr ""
+
+msgid "Includes an MVC structure, mvnw and pom.xml to help you get started."
+msgstr ""
+
msgid "Incompatible Project"
msgstr ""
msgid "Indicates whether this runner can pick jobs without tags"
msgstr ""
+msgid "Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}."
+msgstr ""
+
msgid "Inline"
msgstr ""
@@ -3897,6 +4040,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
+msgid "Introducing Your Conversational Development Index"
+msgstr ""
+
msgid "Invitation"
msgstr ""
@@ -4100,6 +4246,9 @@ msgstr[1] ""
msgid "Last Pipeline"
msgstr ""
+msgid "Last activity"
+msgstr ""
+
msgid "Last commit"
msgstr ""
@@ -4136,6 +4285,9 @@ msgstr ""
msgid "Latest pipeline for this branch"
msgstr ""
+msgid "Lead"
+msgstr ""
+
msgid "Learn more"
msgstr ""
@@ -4207,6 +4359,9 @@ msgstr ""
msgid "Loading…"
msgstr ""
+msgid "Localization"
+msgstr ""
+
msgid "Lock"
msgstr ""
@@ -4357,9 +4512,18 @@ msgstr ""
msgid "Merge Requests"
msgstr ""
+msgid "Merge commit message"
+msgstr ""
+
msgid "Merge events"
msgstr ""
+msgid "Merge immediately"
+msgstr ""
+
+msgid "Merge in progress"
+msgstr ""
+
msgid "Merge request"
msgstr ""
@@ -4369,9 +4533,18 @@ msgstr ""
msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
msgstr ""
+msgid "Merge when pipeline succeeds"
+msgstr ""
+
+msgid "MergeRequests|Add a reply"
+msgstr ""
+
msgid "MergeRequests|Jump to next unresolved discussion"
msgstr ""
+msgid "MergeRequests|Reply..."
+msgstr ""
+
msgid "MergeRequests|Resolve this discussion in a new issue"
msgstr ""
@@ -4408,10 +4581,10 @@ msgstr ""
msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}"
msgstr ""
-msgid "MergeRequest|Filter files"
+msgid "MergeRequest|No files found"
msgstr ""
-msgid "MergeRequest|No files found"
+msgid "MergeRequest|Search files"
msgstr ""
msgid "Merged"
@@ -4444,9 +4617,6 @@ msgstr ""
msgid "Metrics|Learn about environments"
msgstr ""
-msgid "Metrics|No data to display"
-msgstr ""
-
msgid "Metrics|No deployed environments"
msgstr ""
@@ -4522,6 +4692,15 @@ msgstr ""
msgid "Modal|Close"
msgstr ""
+msgid "Modify commit messages"
+msgstr ""
+
+msgid "Modify merge commit"
+msgstr ""
+
+msgid "Monday"
+msgstr ""
+
msgid "Monitor your errors by integrating with Sentry"
msgstr ""
@@ -4809,9 +4988,6 @@ msgstr ""
msgid "Notes|Show history only"
msgstr ""
-msgid "Nothing here."
-msgstr ""
-
msgid "Notification events"
msgstr ""
@@ -4997,6 +5173,9 @@ msgstr ""
msgid "Pages Domains"
msgstr ""
+msgid "Pages getting started guide"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -5513,6 +5692,9 @@ msgstr ""
msgid "Profiles|Username successfully changed"
msgstr ""
+msgid "Profiles|Using emojis in names seems fun, but please try to set a status message instead"
+msgstr ""
+
msgid "Profiles|What's your status?"
msgstr ""
@@ -5708,9 +5890,6 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
-msgid "PrometheusDashboard|Time"
-msgstr ""
-
msgid "PrometheusService|%{exporters} with %{metrics} were found"
msgstr ""
@@ -5932,6 +6111,9 @@ msgstr ""
msgid "Reopen milestone"
msgstr ""
+msgid "Reply to comment"
+msgstr ""
+
msgid "Reply to this email directly or %{view_it_on_gitlab}."
msgstr ""
@@ -6333,9 +6515,6 @@ msgstr ""
msgid "Serverless"
msgstr ""
-msgid "ServerlessDetails|Copy URL to clipboard"
-msgstr ""
-
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
@@ -6348,19 +6527,13 @@ msgstr ""
msgid "ServerlessDetails|pods in use"
msgstr ""
-msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
-msgstr ""
-
-msgid "Serverless|An error occurred while retrieving serverless components"
-msgstr ""
-
-msgid "Serverless|Cluster Env"
+msgid "ServerlessURL|Copy URL to clipboard"
msgstr ""
-msgid "Serverless|Description"
+msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr ""
-msgid "Serverless|Function"
+msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Getting started with serverless"
@@ -6372,18 +6545,12 @@ msgstr ""
msgid "Serverless|Install Knative"
msgstr ""
-msgid "Serverless|Last Update"
-msgstr ""
-
msgid "Serverless|Learn more about Serverless"
msgstr ""
msgid "Serverless|No functions available"
msgstr ""
-msgid "Serverless|Runtime"
-msgstr ""
-
msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:"
msgstr ""
@@ -6527,6 +6694,12 @@ msgstr ""
msgid "Snippets"
msgstr ""
+msgid "Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again."
+msgstr ""
+
+msgid "Someone edited this merge request at the same time you did. Please refresh the page to see changes."
+msgstr ""
+
msgid "Something went wrong on our end"
msgstr ""
@@ -6551,6 +6724,9 @@ msgstr ""
msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr ""
+msgid "Something went wrong while deleting the source branch. Please try again."
+msgstr ""
+
msgid "Something went wrong while fetching comments. Please try again."
msgstr ""
@@ -6563,6 +6739,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong while merging this merge request. Please try again."
+msgstr ""
+
msgid "Something went wrong while reopening the %{issuable}. Please try again later"
msgstr ""
@@ -6650,6 +6829,9 @@ msgstr ""
msgid "SortOptions|Oldest joined"
msgstr ""
+msgid "SortOptions|Oldest last activity"
+msgstr ""
+
msgid "SortOptions|Oldest sign in"
msgstr ""
@@ -6662,6 +6844,9 @@ msgstr ""
msgid "SortOptions|Priority"
msgstr ""
+msgid "SortOptions|Recent last activity"
+msgstr ""
+
msgid "SortOptions|Recent sign in"
msgstr ""
@@ -6701,6 +6886,9 @@ msgstr ""
msgid "Specify the following URL during the Runner setup:"
msgstr ""
+msgid "Squash commit message"
+msgstr ""
+
msgid "Squash commits"
msgstr ""
@@ -6839,6 +7027,9 @@ msgstr ""
msgid "Suggested change"
msgstr ""
+msgid "Sunday"
+msgstr ""
+
msgid "Support for custom certificates is disabled. Ask your system's administrator to enable it."
msgstr ""
@@ -6851,6 +7042,9 @@ msgstr ""
msgid "System Info"
msgstr ""
+msgid "System default (%{default})"
+msgstr ""
+
msgid "System metrics (Custom)"
msgstr ""
@@ -7650,9 +7844,15 @@ msgstr ""
msgid "Unable to load the diff. %{button_try_again}"
msgstr ""
+msgid "Unblock"
+msgstr ""
+
msgid "Undo"
msgstr ""
+msgid "Unfortunately, your email message to GitLab could not be processed."
+msgstr ""
+
msgid "Unlock"
msgstr ""
@@ -7713,6 +7913,9 @@ msgstr ""
msgid "Update"
msgstr ""
+msgid "Update failed"
+msgstr ""
+
msgid "Update now"
msgstr ""
@@ -7797,12 +8000,24 @@ msgstr ""
msgid "UserProfile|Edit profile"
msgstr ""
+msgid "UserProfile|Explore public groups to find projects to contribute to."
+msgstr ""
+
msgid "UserProfile|Groups"
msgstr ""
+msgid "UserProfile|Groups are the best way to manage projects and members."
+msgstr ""
+
+msgid "UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!"
+msgstr ""
+
msgid "UserProfile|Most Recent Activity"
msgstr ""
+msgid "UserProfile|No snippets found."
+msgstr ""
+
msgid "UserProfile|Overview"
msgstr ""
@@ -7815,6 +8030,9 @@ msgstr ""
msgid "UserProfile|Snippets"
msgstr ""
+msgid "UserProfile|Snippets in GitLab can either be private, internal, or public."
+msgstr ""
+
msgid "UserProfile|Subscribe"
msgstr ""
@@ -7824,12 +8042,27 @@ msgstr ""
msgid "UserProfile|This user has a private profile"
msgstr ""
+msgid "UserProfile|This user hasn't contributed to any projects"
+msgstr ""
+
msgid "UserProfile|View all"
msgstr ""
msgid "UserProfile|View user in admin area"
msgstr ""
+msgid "UserProfile|You can create a group for several dependent projects."
+msgstr ""
+
+msgid "UserProfile|You haven't created any personal projects."
+msgstr ""
+
+msgid "UserProfile|You haven't created any snippets."
+msgstr ""
+
+msgid "UserProfile|Your projects can be available publicly, internally, or privately, at your choice."
+msgstr ""
+
msgid "Users"
msgstr ""
@@ -7851,6 +8084,9 @@ msgstr ""
msgid "Various email settings."
msgstr ""
+msgid "Various localization settings."
+msgstr ""
+
msgid "Various settings that affect GitLab performance."
msgstr ""
@@ -8127,6 +8363,9 @@ msgstr ""
msgid "Yesterday"
msgstr ""
+msgid "You"
+msgstr ""
+
msgid "You are an admin, which means granting access to <strong>%{client_name}</strong> will allow them to interact with GitLab as an admin as well. Proceed with caution."
msgstr ""
@@ -8175,6 +8414,9 @@ msgstr ""
msgid "You can only edit files when you are on a branch"
msgstr ""
+msgid "You can only merge once the items above are resolved"
+msgstr ""
+
msgid "You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}"
msgstr ""
@@ -8265,6 +8507,9 @@ msgstr ""
msgid "YouTube"
msgstr ""
+msgid "Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers."
+msgstr ""
+
msgid "Your Groups"
msgstr ""
@@ -8431,6 +8676,9 @@ msgstr ""
msgid "in project %{link_to_project}"
msgstr ""
+msgid "index"
+msgstr ""
+
msgid "issue boards"
msgstr ""
@@ -8463,6 +8711,12 @@ msgstr[1] ""
msgid "missing"
msgstr ""
+msgid "mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}."
+msgstr ""
+
+msgid "mrWidgetCommitsAdded|1 merge commit"
+msgstr ""
+
msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
msgstr ""
@@ -8670,6 +8924,15 @@ msgstr ""
msgid "notification emails"
msgstr ""
+msgid "nounSeries|%{firstItem} and %{lastItem}"
+msgstr ""
+
+msgid "nounSeries|%{item}, %{nextItem}"
+msgstr ""
+
+msgid "nounSeries|%{item}, and %{lastItem}"
+msgstr ""
+
msgid "or"
msgstr ""
@@ -8715,6 +8978,9 @@ msgid_plural "replies"
msgstr[0] ""
msgstr[1] ""
+msgid "score"
+msgstr ""
+
msgid "should be higher than %{access} inherited membership from group %{group_name}"
msgstr ""
diff --git a/package.json b/package.json
index 13c0527c4a3..7110baef8b3 100644
--- a/package.json
+++ b/package.json
@@ -28,8 +28,8 @@
"@babel/plugin-syntax-import-meta": "^7.2.0",
"@babel/preset-env": "^7.3.1",
"@gitlab/csslab": "^1.8.0",
- "@gitlab/svgs": "^1.48.0",
- "@gitlab/ui": "^1.22.1",
+ "@gitlab/svgs": "^1.51.0",
+ "@gitlab/ui": "^2.0.2",
"apollo-boost": "^0.1.20",
"apollo-client": "^2.4.5",
"autosize": "^4.0.0",
@@ -56,11 +56,12 @@
"d3-time": "^1.0.8",
"d3-time-format": "^2.1.1",
"dateformat": "^3.0.3",
- "deckar01-task_list": "^2.0.1",
+ "deckar01-task_list": "^2.2.0",
"diff": "^3.4.0",
- "document-register-element": "1.3.0",
+ "document-register-element": "1.13.1",
"dropzone": "^4.2.0",
"echarts": "^4.2.0-rc.2",
+ "emoji-regex": "^7.0.3",
"emoji-unicode-version": "^0.2.1",
"exports-loader": "^0.7.0",
"file-loader": "^3.0.1",
diff --git a/qa/qa.rb b/qa/qa.rb
index 7aaf56bd51f..8c85513198b 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -99,6 +99,7 @@ module QA
autoload :LDAPNoTLS, 'qa/scenario/test/integration/ldap_no_tls'
autoload :LDAPTLS, 'qa/scenario/test/integration/ldap_tls'
autoload :InstanceSAML, 'qa/scenario/test/integration/instance_saml'
+ autoload :OAuth, 'qa/scenario/test/integration/oauth'
autoload :Kubernetes, 'qa/scenario/test/integration/kubernetes'
autoload :Mattermost, 'qa/scenario/test/integration/mattermost'
autoload :ObjectStorage, 'qa/scenario/test/integration/object_storage'
@@ -273,9 +274,11 @@ module QA
module Settings
autoload :Repository, 'qa/page/admin/settings/repository'
+ autoload :General, 'qa/page/admin/settings/general'
module Component
autoload :RepositoryStorage, 'qa/page/admin/settings/component/repository_storage'
+ autoload :AccountAndLimit, 'qa/page/admin/settings/component/account_and_limit'
end
end
end
@@ -290,6 +293,7 @@ module QA
#
module Component
autoload :ClonePanel, 'qa/page/component/clone_panel'
+ autoload :LazyLoader, 'qa/page/component/lazy_loader'
autoload :LegacyClonePanel, 'qa/page/component/legacy_clone_panel'
autoload :Dropzone, 'qa/page/component/dropzone'
autoload :GroupsFilter, 'qa/page/component/groups_filter'
@@ -341,6 +345,13 @@ module QA
autoload :Login, 'qa/vendor/saml_idp/page/login'
end
end
+
+ module Github
+ module Page
+ autoload :Base, 'qa/vendor/github/page/base'
+ autoload :Login, 'qa/vendor/github/page/login'
+ end
+ end
end
# Classes that provide support to other parts of the framework.
diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb
index ac8dcbf0d83..0aa94101098 100644
--- a/qa/qa/git/repository.rb
+++ b/qa/qa/git/repository.rb
@@ -5,15 +5,19 @@ require 'uri'
require 'open3'
require 'fileutils'
require 'tmpdir'
+require 'tempfile'
+require 'securerandom'
module QA
module Git
class Repository
include Scenario::Actable
- attr_writer :password, :use_lfs
+ attr_writer :use_lfs
attr_accessor :env_vars
+ InvalidCredentialsError = Class.new(RuntimeError)
+
def initialize
# We set HOME to the current working directory (which is a
# temporary directory created in .perform()) so the temporarily dropped
@@ -28,6 +32,14 @@ module QA
end
end
+ def password=(password)
+ @password = password
+
+ raise InvalidCredentialsError, "Please provide a username when setting a password" unless username
+
+ try_add_credentials_to_netrc
+ end
+
def uri=(address)
@uri = URI(address)
end
@@ -148,16 +160,7 @@ module QA
return unless add_credentials?
return if netrc_already_contains_content?
- # Despite libcurl supporting a custom .netrc location through the
- # CURLOPT_NETRC_FILE environment variable, git does not support it :(
- # Info: https://curl.haxx.se/libcurl/c/CURLOPT_NETRC_FILE.html
- #
- # This will create a .netrc in the correct working directory, which is
- # a temporary directory created in .perform()
- #
- FileUtils.mkdir_p(tmp_home_dir)
- File.open(netrc_file_path, 'a') { |file| file.puts(netrc_content) }
- File.chmod(0600, netrc_file_path)
+ save_netrc_content
end
private
@@ -175,7 +178,6 @@ module QA
def add_credentials?
return false if !username || !password
return true unless ssh_key_set?
- return true if ssh_key_set? && use_lfs?
false
end
@@ -214,6 +216,23 @@ module QA
end
end
+ def read_netrc_content
+ File.exist?(netrc_file_path) ? File.readlines(netrc_file_path) : []
+ end
+
+ def save_netrc_content
+ # Despite libcurl supporting a custom .netrc location through the
+ # CURLOPT_NETRC_FILE environment variable, git does not support it :(
+ # Info: https://curl.haxx.se/libcurl/c/CURLOPT_NETRC_FILE.html
+ #
+ # This will create a .netrc in the correct working directory, which is
+ # a temporary directory created in .perform()
+ #
+ FileUtils.mkdir_p(tmp_home_dir)
+ File.open(netrc_file_path, 'a') { |file| file.puts(netrc_content) }
+ File.chmod(0600, netrc_file_path)
+ end
+
def tmp_home_dir
@tmp_home_dir ||= File.join(Dir.tmpdir, "qa-netrc-credentials", $$.to_s)
end
@@ -227,8 +246,7 @@ module QA
end
def netrc_already_contains_content?
- File.exist?(netrc_file_path) &&
- File.readlines(netrc_file_path).grep(/^#{netrc_content}$/).any?
+ read_netrc_content.grep(/^#{netrc_content}$/).any?
end
end
end
diff --git a/qa/qa/page/admin/menu.rb b/qa/qa/page/admin/menu.rb
index e8c7d274966..25564f2dc6e 100644
--- a/qa/qa/page/admin/menu.rb
+++ b/qa/qa/page/admin/menu.rb
@@ -9,6 +9,7 @@ module QA
element :admin_sidebar_submenu
element :admin_settings_item
element :admin_settings_repository_item
+ element :admin_settings_general_item
end
def go_to_repository_settings
@@ -19,6 +20,14 @@ module QA
end
end
+ def go_to_general_settings
+ hover_settings do
+ within_submenu do
+ click_element :admin_settings_general_item
+ end
+ end
+ end
+
private
def hover_settings
diff --git a/qa/qa/page/admin/settings/component/account_and_limit.rb b/qa/qa/page/admin/settings/component/account_and_limit.rb
new file mode 100644
index 00000000000..a61c8cc77cd
--- /dev/null
+++ b/qa/qa/page/admin/settings/component/account_and_limit.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Admin
+ module Settings
+ module Component
+ class AccountAndLimit < Page::Base
+ view 'app/views/admin/application_settings/_account_and_limit.html.haml' do
+ element :receive_max_input_size_field
+ element :save_changes_button
+ end
+
+ def set_max_file_size(size)
+ fill_element :receive_max_input_size_field, size
+ end
+
+ def save_settings
+ click_element :save_changes_button
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/admin/settings/general.rb b/qa/qa/page/admin/settings/general.rb
new file mode 100644
index 00000000000..93b290f7e03
--- /dev/null
+++ b/qa/qa/page/admin/settings/general.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Admin
+ module Settings
+ class General < Page::Base
+ include QA::Page::Settings::Common
+
+ view 'app/views/admin/application_settings/show.html.haml' do
+ element :account_and_limit_settings
+ end
+
+ def expand_account_and_limit(&block)
+ expand_section(:account_and_limit_settings) do
+ Component::AccountAndLimit.perform(&block)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/component/lazy_loader.rb b/qa/qa/page/component/lazy_loader.rb
new file mode 100644
index 00000000000..6f74a4691ba
--- /dev/null
+++ b/qa/qa/page/component/lazy_loader.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Component
+ module LazyLoader
+ def self.included(base)
+ base.view 'app/assets/javascripts/lazy_loader.js' do
+ element :js_lazy_loaded
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/label/index.rb b/qa/qa/page/label/index.rb
index 97ce8f0eba5..f0d323ca3b4 100644
--- a/qa/qa/page/label/index.rb
+++ b/qa/qa/page/label/index.rb
@@ -1,7 +1,11 @@
+# frozen_string_literal: true
+
module QA
module Page
module Label
class Index < Page::Base
+ include Component::LazyLoader
+
view 'app/views/shared/labels/_nav.html.haml' do
element :label_create_new
end
@@ -10,10 +14,6 @@ module QA
element :label_svg
end
- view 'app/assets/javascripts/lazy_loader.js' do
- element :js_lazy_loaded
- end
-
def go_to_new_label
# The 'labels.svg' takes a fraction of a second to load after which the "New label" button shifts up a bit
# This can cause webdriver to miss the hit so we wait for the svg to load (implicitly with has_element?)
diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb
index cb83ace20b6..e476cbe29a2 100644
--- a/qa/qa/page/main/login.rb
+++ b/qa/qa/page/main/login.rb
@@ -31,25 +31,18 @@ module QA
element :register_tab
end
- view 'app/views/devise/shared/_omniauth_box.html.haml' do
+ view 'app/helpers/auth_helper.rb' do
element :saml_login_button
+ element :github_login_button
end
view 'app/views/layouts/devise.html.haml' do
element :login_page
end
- def initialize
- # The login page is usually the entry point for all the scenarios so
- # we need to wait for the instance to start. That said, in some cases
- # we are already logged-in so we check both cases here.
- # Check if we're already logged in first. If we don't then we have to
- # wait 10 seconds for the check for the login page to fail every time
- # we use this class when we're already logged in (E.g., whenever we
- # create a personal access token to use for API access).
- wait(max: 500) do
- Page::Main::Menu.act { has_personal_area?(wait: 0) } ||
- has_element?(:login_page)
+ def page_loaded?
+ wait(max: 60) do
+ has_element?(:login_page)
end
end
@@ -132,6 +125,16 @@ module QA
click_element :standard_tab
end
+ def sign_in_with_github
+ set_initial_password_if_present
+ click_element :github_login_button
+ end
+
+ def sign_in_with_saml
+ set_initial_password_if_present
+ click_element :saml_login_button
+ end
+
private
def sign_in_using_ldap_credentials
@@ -142,11 +145,6 @@ module QA
click_element :sign_in_button
end
- def sign_in_with_saml
- set_initial_password_if_present
- click_element :saml_login_button
- end
-
def sign_in_using_gitlab_credentials(user)
switch_to_sign_in_tab if has_sign_in_tab?
switch_to_standard_tab if has_standard_tab?
diff --git a/qa/qa/page/project/job/show.rb b/qa/qa/page/project/job/show.rb
index d688f15914c..49c676c01f2 100644
--- a/qa/qa/page/project/job/show.rb
+++ b/qa/qa/page/project/job/show.rb
@@ -16,11 +16,19 @@ module QA::Page
element :status_badge
end
+ view 'app/assets/javascripts/jobs/components/stages_dropdown.vue' do
+ element :pipeline_path
+ end
+
def completed?
COMPLETED_STATUSES.include?(status_badge)
end
- def passed?
+ def successful?(timeout: 60)
+ wait(reload: false, max: timeout) do
+ completed? && !trace_loading?
+ end
+
status_badge == PASSED_STATUS
end
diff --git a/qa/qa/page/project/pipeline/show.rb b/qa/qa/page/project/pipeline/show.rb
index b22396fd67a..f192f1fc64b 100644
--- a/qa/qa/page/project/pipeline/show.rb
+++ b/qa/qa/page/project/pipeline/show.rb
@@ -11,7 +11,7 @@ module QA::Page
view 'app/assets/javascripts/pipelines/components/graph/job_item.vue' do
element :job_component, /class.*ci-job-component.*/ # rubocop:disable QA/ElementWithPattern
- element :job_link, /class.*js-pipeline-graph-job-link.*/ # rubocop:disable QA/ElementWithPattern
+ element :job_link
end
view 'app/assets/javascripts/vue_shared/components/ci_icon.vue' do
@@ -32,6 +32,10 @@ module QA::Page
end
end
+ def go_to_job(job_name)
+ find_element(:job_link, job_name).click
+ end
+
def go_to_first_job
css = '.js-pipeline-graph-job-link'
diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb
index 12c2409a5a7..2de39b8ebf5 100644
--- a/qa/qa/page/project/settings/ci_cd.rb
+++ b/qa/qa/page/project/settings/ci_cd.rb
@@ -13,9 +13,7 @@ module QA # rubocop:disable Naming/FileName
view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do
element :enable_auto_devops_field, 'check_box :enabled' # rubocop:disable QA/ElementWithPattern
- element :domain_field, 'text_field :domain' # rubocop:disable QA/ElementWithPattern
element :enable_auto_devops_button, "%strong= s_('CICD|Default to Auto DevOps pipeline')" # rubocop:disable QA/ElementWithPattern
- element :domain_input, "%strong= _('Domain')" # rubocop:disable QA/ElementWithPattern
element :save_changes_button, "submit _('Save changes')" # rubocop:disable QA/ElementWithPattern
end
@@ -31,10 +29,9 @@ module QA # rubocop:disable Naming/FileName
end
end
- def enable_auto_devops_with_domain(domain)
+ def enable_auto_devops
expand_section(:autodevops_settings) do
check 'Default to Auto DevOps pipeline'
- fill_in 'Domain', with: domain
click_on 'Save changes'
end
end
diff --git a/qa/qa/page/project/wiki/new.rb b/qa/qa/page/project/wiki/new.rb
index 2498af8600c..b90e03be36a 100644
--- a/qa/qa/page/project/wiki/new.rb
+++ b/qa/qa/page/project/wiki/new.rb
@@ -1,42 +1,58 @@
+# frozen_string_literal: true
+
module QA
module Page
module Project
module Wiki
class New < Page::Base
+ include Component::LazyLoader
+
view 'app/views/projects/wikis/_form.html.haml' do
- element :wiki_title_textbox, 'text_field :title' # rubocop:disable QA/ElementWithPattern
- element :wiki_content_textarea, "render 'projects/zen', f: f, attr: :content" # rubocop:disable QA/ElementWithPattern
- element :wiki_message_textbox, 'text_field :message' # rubocop:disable QA/ElementWithPattern
- element :save_changes_button, 'submit _("Save changes")' # rubocop:disable QA/ElementWithPattern
- element :create_page_button, 'submit s_("Wiki|Create page")' # rubocop:disable QA/ElementWithPattern
+ element :wiki_title_textbox
+ element :wiki_content_textarea
+ element :wiki_message_textbox
+ element :save_changes_button
+ element :create_page_button
end
view 'app/views/shared/empty_states/_wikis.html.haml' do
- element :create_link, 'Create your first page' # rubocop:disable QA/ElementWithPattern
+ element :create_first_page_link
+ end
+
+ view 'app/views/shared/empty_states/_wikis_layout.html.haml' do
+ element :svg_content
end
def go_to_create_first_page
- click_link 'Create your first page'
+ # The svg takes a fraction of a second to load after which the
+ # "Create your first page" button shifts up a bit. This can cause
+ # webdriver to miss the hit so we wait for the svg to load before
+ # clicking the button.
+ within_element(:svg_content) do
+ has_element? :js_lazy_loaded
+ end
+
+ click_element :create_first_page_link
end
def set_title(title)
- fill_in 'wiki_title', with: title
+ fill_element :wiki_title_textbox, title
end
def set_content(content)
- fill_in 'wiki_content', with: content
+ fill_element :wiki_content_textarea, content
end
def set_message(message)
- fill_in 'wiki_message', with: message
+ fill_element :wiki_message_textbox, message
end
def save_changes
- click_on 'Save changes'
+ click_element :save_changes_button
end
def create_new_page
- click_on 'Create page'
+ click_element :create_page_button
end
end
end
diff --git a/qa/qa/resource/kubernetes_cluster.rb b/qa/qa/resource/kubernetes_cluster.rb
index d67e5f6da20..986b31da528 100644
--- a/qa/qa/resource/kubernetes_cluster.rb
+++ b/qa/qa/resource/kubernetes_cluster.rb
@@ -6,12 +6,16 @@ module QA
module Resource
class KubernetesCluster < Base
attr_writer :project, :cluster,
- :install_helm_tiller, :install_ingress, :install_prometheus, :install_runner
+ :install_helm_tiller, :install_ingress, :install_prometheus, :install_runner, :domain
attribute :ingress_ip do
Page::Project::Operations::Kubernetes::Show.perform(&:ingress_ip)
end
+ attribute :domain do
+ "#{ingress_ip}.nip.io"
+ end
+
def fabricate!
@project.visit!
diff --git a/qa/qa/resource/repository/push.rb b/qa/qa/resource/repository/push.rb
index 32f15547da2..a5827fb6e73 100644
--- a/qa/qa/resource/repository/push.rb
+++ b/qa/qa/resource/repository/push.rb
@@ -67,8 +67,6 @@ module QA
email = user.email
end
- repository.try_add_credentials_to_netrc
-
@output += repository.clone
repository.configure_identity(username, email)
diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb
index 23a2ace6a55..dd0ddbdbd6b 100644
--- a/qa/qa/runtime/env.rb
+++ b/qa/qa/runtime/env.rb
@@ -100,6 +100,14 @@ module QA
ENV['GITLAB_ADMIN_PASSWORD']
end
+ def github_username
+ ENV['GITHUB_USERNAME']
+ end
+
+ def github_password
+ ENV['GITHUB_PASSWORD']
+ end
+
def forker?
!!(forker_username && forker_password)
end
diff --git a/qa/qa/scenario/test/integration/oauth.rb b/qa/qa/scenario/test/integration/oauth.rb
new file mode 100644
index 00000000000..912156fbc29
--- /dev/null
+++ b/qa/qa/scenario/test/integration/oauth.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module QA
+ module Scenario
+ module Test
+ module Integration
+ class OAuth < Test::Instance::All
+ tags :oauth
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb
new file mode 100644
index 00000000000..a118176eb8a
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module QA
+ context 'Manage', :orchestrated, :oauth do
+ describe 'OAuth login' do
+ it 'User logs in to GitLab with GitHub OAuth' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+
+ Page::Main::Login.perform(&:sign_in_with_github)
+ Vendor::Github::Page::Login.perform(&:login)
+
+ expect(page).to have_content('Welcome to GitLab')
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb
index 6632c2977ef..2fb8402edd8 100644
--- a/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb
+++ b/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
module QA
- context 'Manage', :smoke do
+ # Failure issue: https://gitlab.com/gitlab-org/quality/nightly/issues/72
+ context 'Manage', :smoke, :quarantine do
describe 'Project creation' do
it 'user creates a new project' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/view_project_activity_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/view_project_activity_spec.rb
index d4cedc9362d..e172206eb88 100644
--- a/qa/qa/specs/features/browser_ui/1_manage/project/view_project_activity_spec.rb
+++ b/qa/qa/specs/features/browser_ui/1_manage/project/view_project_activity_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
module QA
- context 'Manage' do
+ # Failure issue: https://gitlab.com/gitlab-org/quality/staging/issues/21
+ context 'Manage', :quarantine do
describe 'Project activity' do
it 'user creates an event in the activity page upon Git push' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/squash_merge_request_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/squash_merge_request_spec.rb
index 10cc0480794..545da0a8b85 100644
--- a/qa/qa/specs/features/browser_ui/3_create/merge_request/squash_merge_request_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/squash_merge_request_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
module QA
- context 'Create' do
+ # Failure issue: https://gitlab.com/gitlab-org/quality/staging/issues/31
+ context 'Create', :quarantine do
describe 'Merge request squashing' do
it 'user squashes commits while merging' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb
new file mode 100644
index 00000000000..23ea55c2e61
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module QA
+ context 'Create' do
+ describe 'push after setting the file size limit via admin/application_settings' do
+ before(:all) do
+ push = Resource::Repository::ProjectPush.fabricate! do |p|
+ p.file_name = 'README.md'
+ p.file_content = '# This is a test project'
+ p.commit_message = 'Add README.md'
+ end
+
+ @project = push.project
+ end
+
+ before do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.perform(&:sign_in_using_credentials)
+ end
+
+ after(:all) do
+ # need to set the default value after test
+ # default value for file size limit is empty
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.perform(&:sign_in_using_credentials)
+
+ set_file_size_limit('')
+ end
+
+ it 'push successful when the file size is under the limit' do
+ set_file_size_limit(5)
+ expect(page).to have_content("Application settings saved successfully")
+
+ push = push_new_file('oversize_file_1.bin')
+ expect(push.output).not_to have_content 'remote: fatal: pack exceeds maximum allowed size'
+ end
+
+ it 'push fails when the file size is above the limit' do
+ set_file_size_limit(1)
+ expect(page).to have_content("Application settings saved successfully")
+
+ push = push_new_file('oversize_file_2.bin')
+ expect(push.output).to have_content 'remote: fatal: pack exceeds maximum allowed size'
+ end
+
+ def set_file_size_limit(limit)
+ Page::Main::Menu.perform(&:go_to_admin_area)
+ Page::Admin::Menu.perform(&:go_to_general_settings)
+
+ Page::Admin::Settings::General.perform do |setting|
+ setting.expand_account_and_limit do |page|
+ page.set_max_file_size(limit)
+ page.save_settings
+ end
+ end
+ end
+
+ def push_new_file(file_name)
+ @project.visit!
+
+ Resource::Repository::ProjectPush.fabricate! do |p|
+ p.project = @project
+ p.file_name = file_name
+ p.file_content = SecureRandom.random_bytes(2000000)
+ p.commit_message = 'Adding a new file'
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb
index a7d0998d42c..29589ec870a 100644
--- a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb
@@ -3,22 +3,15 @@
module QA
context 'Create' do
describe 'Wiki management' do
- def login
- Runtime::Browser.visit(:gitlab, Page::Main::Login)
- Page::Main::Login.act { sign_in_using_credentials }
- end
-
def validate_content(content)
expect(page).to have_content('Wiki was successfully updated')
expect(page).to have_content(/#{content}/)
end
- before do
- login
- end
+ it 'user creates, edits, clones, and pushes to the wiki' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.perform(&:sign_in_using_credentials)
- # Failure reported: https://gitlab.com/gitlab-org/quality/nightly/issues/24
- it 'user creates, edits, clones, and pushes to the wiki', :quarantine do
wiki = Resource::Wiki.fabricate! do |resource|
resource.title = 'Home'
resource.content = '# My First Wiki Content'
@@ -27,7 +20,7 @@ module QA
validate_content('My First Wiki Content')
- Page::Project::Wiki::Edit.act { go_to_edit_page }
+ Page::Project::Wiki::Edit.perform(&:go_to_edit_page)
Page::Project::Wiki::New.perform do |page|
page.set_content("My Second Wiki Content")
page.save_changes
@@ -41,7 +34,7 @@ module QA
push.file_content = '# My Third Wiki Content'
push.commit_message = 'Update Home.md'
end
- Page::Project::Menu.act { click_wiki }
+ Page::Project::Menu.perform(&:click_wiki)
expect(page).to have_content('My Third Wiki Content')
end
diff --git a/qa/qa/specs/features/browser_ui/4_verify/ci_variable/add_ci_variable_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/ci_variable/add_ci_variable_spec.rb
index 0837b720df1..e444bc7ef1b 100644
--- a/qa/qa/specs/features/browser_ui/4_verify/ci_variable/add_ci_variable_spec.rb
+++ b/qa/qa/specs/features/browser_ui/4_verify/ci_variable/add_ci_variable_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
module QA
- context 'Verify' do
+ # Failure issue: https://gitlab.com/gitlab-org/quality/staging/issues/30
+ context 'Verify', :quarantine do
describe 'CI variable support' do
it 'user adds a CI variable' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb
index 6f39a755392..aa01e5a618e 100644
--- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb
+++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
module QA
- context 'Release' do
+ # Failure issue: https://gitlab.com/gitlab-org/quality/staging/issues/26
+ context 'Release', :quarantine do
describe 'Deploy key creation' do
it 'user adds a deploy key' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
index e2320c92343..97d36095a69 100644
--- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
+++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
@@ -3,7 +3,8 @@
require 'digest/sha1'
module QA
- context 'Release', :docker do
+ # Failure issue: https://gitlab.com/gitlab-org/quality/nightly/issues/70
+ context 'Release', :docker, :quarantine do
describe 'Git clone using a deploy key' do
def login
Runtime::Browser.visit(:gitlab, Page::Main::Login)
@@ -95,11 +96,7 @@ module QA
Page::Project::Pipeline::Show.act { go_to_first_job }
Page::Project::Job::Show.perform do |job|
- job.wait(reload: false) do
- job.completed? && !job.trace_loading?
- end
-
- expect(job.passed?).to be_truthy, "Job status did not become \"passed\"."
+ expect(job).to be_successful, "Job status did not become \"passed\"."
expect(job.output).to include(sha1sum)
end
end
diff --git a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
index 553550eef8b..5c8ec465143 100644
--- a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
+++ b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
@@ -52,13 +52,13 @@ module QA
end
kubernetes_cluster.populate(:ingress_ip)
-
@project.visit!
Page::Project::Menu.act { click_ci_cd_settings }
Page::Project::Settings::CICD.perform do |p|
- p.enable_auto_devops_with_domain(
- "#{kubernetes_cluster.ingress_ip}.nip.io")
+ p.enable_auto_devops
end
+
+ kubernetes_cluster.populate(:domain)
end
after(:all) do
@@ -75,9 +75,30 @@ module QA
Page::Project::Pipeline::Index.act { go_to_latest_pipeline }
Page::Project::Pipeline::Show.perform do |pipeline|
- expect(pipeline).to have_build('build', status: :success, wait: 600)
- expect(pipeline).to have_build('test', status: :success, wait: 600)
- expect(pipeline).to have_build('production', status: :success, wait: 1200)
+ pipeline.go_to_job('build')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('test')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('production')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 1200), "Job did not pass"
+
+ job.click_element(:pipeline_path)
end
Page::Project::Menu.act { click_operations_environments }
@@ -115,9 +136,30 @@ module QA
Page::Project::Pipeline::Index.act { go_to_latest_pipeline }
Page::Project::Pipeline::Show.perform do |pipeline|
- expect(pipeline).to have_build('build', status: :success, wait: 600)
- expect(pipeline).to have_build('test', status: :success, wait: 600)
- expect(pipeline).to have_build('production', status: :success, wait: 1200)
+ pipeline.go_to_job('build')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('test')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('production')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 1200), "Job did not pass"
+
+ job.click_element(:pipeline_path)
end
Page::Project::Menu.act { click_operations_environments }
diff --git a/qa/qa/vendor/github/page/base.rb b/qa/qa/vendor/github/page/base.rb
new file mode 100644
index 00000000000..3b96180afe9
--- /dev/null
+++ b/qa/qa/vendor/github/page/base.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module QA
+ module Vendor
+ module Github
+ module Page
+ class Base
+ include Capybara::DSL
+ include Scenario::Actable
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/vendor/github/page/login.rb b/qa/qa/vendor/github/page/login.rb
new file mode 100644
index 00000000000..6d8f9aa7c12
--- /dev/null
+++ b/qa/qa/vendor/github/page/login.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'capybara/dsl'
+
+module QA
+ module Vendor
+ module Github
+ module Page
+ class Login < Page::Base
+ def login
+ fill_in 'login', with: QA::Runtime::Env.github_username
+ fill_in 'password', with: QA::Runtime::Env.github_password
+ click_on 'Sign in'
+
+ unless has_no_text?("Authorize GitLab-OAuth")
+ click_on 'Authorize gitlab-qa' if has_button?('Authorize gitlab-qa')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/spec/git/repository_spec.rb b/qa/spec/git/repository_spec.rb
index faa154c78da..4a350cd6c42 100644
--- a/qa/spec/git/repository_spec.rb
+++ b/qa/spec/git/repository_spec.rb
@@ -1,69 +1,119 @@
describe QA::Git::Repository do
include Support::StubENV
- let(:repository) { described_class.new }
+ shared_context 'git directory' do
+ let(:repository) { described_class.new }
+ let(:tmp_git_dir) { Dir.mktmpdir }
+ let(:tmp_netrc_dir) { Dir.mktmpdir }
- before do
- stub_env('GITLAB_USERNAME', 'root')
- cd_empty_temp_directory
- set_bad_uri
- repository.use_default_credentials
- end
+ before do
+ stub_env('GITLAB_USERNAME', 'root')
+ cd_empty_temp_directory
+ set_bad_uri
- describe '#clone' do
- it 'is unable to resolve host' do
- expect(repository.clone).to include("fatal: unable to access 'http://root@foo/bar.git/'")
+ allow(repository).to receive(:tmp_home_dir).and_return(tmp_netrc_dir)
end
- end
- describe '#push_changes' do
- before do
- `git init` # need a repo to push from
+ after do
+ # Switch to a safe dir before deleting tmp dirs to avoid dir access errors
+ FileUtils.cd __dir__
+ FileUtils.remove_entry_secure(tmp_git_dir, true)
+ FileUtils.remove_entry_secure(tmp_netrc_dir, true)
end
- it 'fails to push changes' do
- expect(repository.push_changes).to include("error: failed to push some refs to 'http://root@foo/bar.git'")
+ def cd_empty_temp_directory
+ FileUtils.cd tmp_git_dir
+ end
+
+ def set_bad_uri
+ repository.uri = 'http://foo/bar.git'
end
end
- describe '#git_protocol=' do
- [0, 1, 2].each do |version|
- it "configures git to use protocol version #{version}" do
- expect(repository).to receive(:run).with("git config protocol.version #{version}")
- repository.git_protocol = version
+ context 'with default credentials' do
+ include_context 'git directory' do
+ before do
+ repository.use_default_credentials
end
end
- it 'raises an error if the version is unsupported' do
- expect { repository.git_protocol = 'foo' }.to raise_error(ArgumentError, "Please specify the protocol you would like to use: 0, 1, or 2")
+ describe '#clone' do
+ it 'is unable to resolve host' do
+ expect(repository.clone).to include("fatal: unable to access 'http://root@foo/bar.git/'")
+ end
end
- end
- describe '#fetch_supported_git_protocol' do
- it "reports the detected version" do
- expect(repository).to receive(:run).and_return("packet: git< version 2")
- expect(repository.fetch_supported_git_protocol).to eq('2')
+ describe '#push_changes' do
+ before do
+ `git init` # need a repo to push from
+ end
+
+ it 'fails to push changes' do
+ expect(repository.push_changes).to include("error: failed to push some refs to 'http://root@foo/bar.git'")
+ end
end
- it 'reports unknown if version is unknown' do
- expect(repository).to receive(:run).and_return("packet: git< version -1")
- expect(repository.fetch_supported_git_protocol).to eq('unknown')
+ describe '#git_protocol=' do
+ [0, 1, 2].each do |version|
+ it "configures git to use protocol version #{version}" do
+ expect(repository).to receive(:run).with("git config protocol.version #{version}")
+ repository.git_protocol = version
+ end
+ end
+
+ it 'raises an error if the version is unsupported' do
+ expect { repository.git_protocol = 'foo' }.to raise_error(ArgumentError, "Please specify the protocol you would like to use: 0, 1, or 2")
+ end
end
- it 'reports unknown if content does not identify a version' do
- expect(repository).to receive(:run).and_return("foo")
- expect(repository.fetch_supported_git_protocol).to eq('unknown')
+ describe '#fetch_supported_git_protocol' do
+ it "reports the detected version" do
+ expect(repository).to receive(:run).and_return("packet: git< version 2")
+ expect(repository.fetch_supported_git_protocol).to eq('2')
+ end
+
+ it 'reports unknown if version is unknown' do
+ expect(repository).to receive(:run).and_return("packet: git< version -1")
+ expect(repository.fetch_supported_git_protocol).to eq('unknown')
+ end
+
+ it 'reports unknown if content does not identify a version' do
+ expect(repository).to receive(:run).and_return("foo")
+ expect(repository.fetch_supported_git_protocol).to eq('unknown')
+ end
end
- end
- def cd_empty_temp_directory
- tmp_dir = 'tmp/git-repository-spec/'
- FileUtils.rm_rf(tmp_dir) if ::File.exist?(tmp_dir)
- FileUtils.mkdir_p tmp_dir
- FileUtils.cd tmp_dir
+ describe '#use_default_credentials' do
+ it 'adds credentials to .netrc' do
+ expect(File.read(File.join(tmp_netrc_dir, '.netrc')))
+ .to eq("machine foo login #{QA::Runtime::User.default_username} password #{QA::Runtime::User.default_password}\n")
+ end
+ end
end
- def set_bad_uri
- repository.uri = 'http://foo/bar.git'
+ context 'with specific credentials' do
+ include_context 'git directory'
+
+ context 'before setting credentials' do
+ it 'does not add credentials to .netrc' do
+ expect(repository).not_to receive(:save_netrc_content)
+ end
+ end
+
+ describe '#password=' do
+ it 'raises an error if no username was given' do
+ expect { repository.password = 'foo' }
+ .to raise_error(QA::Git::Repository::InvalidCredentialsError,
+ "Please provide a username when setting a password")
+ end
+
+ it 'adds credentials to .netrc' do
+ repository.username = 'user'
+ repository.password = 'foo'
+
+ expect(File.read(File.join(tmp_netrc_dir, '.netrc')))
+ .to eq("machine foo login user password foo\n")
+ end
+ end
end
end
diff --git a/qa/spec/scenario/test/integration/oauth_spec.rb b/qa/spec/scenario/test/integration/oauth_spec.rb
new file mode 100644
index 00000000000..c1c320be576
--- /dev/null
+++ b/qa/spec/scenario/test/integration/oauth_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+describe QA::Scenario::Test::Integration::OAuth do
+ context '#perform' do
+ it_behaves_like 'a QA scenario class' do
+ let(:tags) { [:oauth] }
+ end
+ end
+end
diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb
index 3537ba7c235..0f3cf5f4408 100644
--- a/qa/spec/spec_helper.rb
+++ b/qa/spec/spec_helper.rb
@@ -3,6 +3,20 @@ require_relative '../qa'
Dir[::File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
RSpec.configure do |config|
+ ServerNotRespondingError = Class.new(RuntimeError)
+
+ # The login page could take some time to load the first time it is visited.
+ # We visit the login page and wait for it to properly load only once at the beginning of the suite.
+ config.before(:suite) do
+ if QA::Runtime::Scenario.respond_to?(:gitlab_address)
+ QA::Runtime::Browser.visit(:gitlab, QA::Page::Main::Login)
+
+ unless QA::Page::Main::Login.perform(&:page_loaded?)
+ raise ServerNotRespondingError, "Login page did not load at #{QA::Page::Main::Login.perform(&:current_url)}"
+ end
+ end
+ end
+
config.before do |example|
QA::Runtime::Logger.debug("Starting test: #{example.full_description}") if QA::Runtime::Env.debug?
diff --git a/scripts/clean-old-cached-assets b/scripts/clean-old-cached-assets
new file mode 100755
index 00000000000..7a3a62a477a
--- /dev/null
+++ b/scripts/clean-old-cached-assets
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+# Clean up cached files that are older than 1 week
+find tmp/cache/assets/sprockets/ -type f -mtime +7 -execdir rm -- "{}" \;
+
+du -d 0 -h tmp/cache/assets/sprockets | cut -f1 | xargs -I % echo "tmp/cache/assets/sprockets/ is currently %"
diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh
index 6e0dee9e090..f610485a700 100755
--- a/scripts/review_apps/review-apps.sh
+++ b/scripts/review_apps/review-apps.sh
@@ -143,11 +143,16 @@ HELM_CMD=$(cat << EOF
--set global.hosts.hostSuffix="$HOST_SUFFIX" \
--set global.hosts.domain="$REVIEW_APPS_DOMAIN" \
--set certmanager.install=false \
+ --set prometheus.install=false \
--set global.ingress.configureCertmanager=false \
--set global.ingress.tls.secretName=tls-cert \
--set global.ingress.annotations."external-dns\.alpha\.kubernetes\.io/ttl"="10"
+ --set nginx-ingress.defaultBackend.resources.requests.memory=7Mi \
+ --set nginx-ingress.controller.resources.requests.memory=440M \
+ --set nginx-ingress.controller.replicaCount=2 \
--set gitlab.unicorn.resources.requests.cpu=200m \
--set gitlab.sidekiq.resources.requests.cpu=100m \
+ --set gitlab.sidekiq.resources.requests.memory=800M \
--set gitlab.gitlab-shell.resources.requests.cpu=100m \
--set redis.resources.requests.cpu=100m \
--set minio.resources.requests.cpu=100m \
diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb
index 379b2d6b935..a07113a6156 100644
--- a/spec/controllers/concerns/send_file_upload_spec.rb
+++ b/spec/controllers/concerns/send_file_upload_spec.rb
@@ -53,19 +53,38 @@ describe SendFileUpload do
end
context 'with attachment' do
- let(:params) { { attachment: 'test.js' } }
+ let(:filename) { 'test.js' }
+ let(:params) { { attachment: filename } }
it 'sends a file with content-type of text/plain' do
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
expected_params = {
content_type: 'text/plain',
filename: 'test.js',
- disposition: 'attachment'
+ disposition: "attachment; filename*=UTF-8''test.js"
}
expect(controller).to receive(:send_file).with(uploader.path, expected_params)
subject
end
+ context 'with non-ASCII encoded filename' do
+ let(:filename) { 'テスト.txt' }
+
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ it 'sends content-disposition for non-ASCII encoded filenames' do
+ expected_params = {
+ filename: filename,
+ disposition: "attachment; filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88.txt"
+ }
+ expect(controller).to receive(:send_file).with(uploader.path, expected_params)
+
+ subject
+ end
+ end
+
context 'with a proxied file in object storage' do
before do
stub_uploads_object_storage(uploader: uploader_class)
@@ -76,7 +95,7 @@ describe SendFileUpload do
it 'sends a file with a custom type' do
headers = double
- expected_headers = %r(response-content-disposition=attachment%3Bfilename%3D%22test.js%22&response-content-type=application/ecmascript)
+ expected_headers = %r(response-content-disposition=attachment%3B%20filename%3D%22test.js%22%3B%20filename%2A%3DUTF-8%27%27test.js&response-content-type=application/ecmascript)
expect(Gitlab::Workhorse).to receive(:send_url).with(expected_headers).and_call_original
expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-url:/)
diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb
index 0f28499194e..360030102e0 100644
--- a/spec/controllers/groups/clusters_controller_spec.rb
+++ b/spec/controllers/groups/clusters_controller_spec.rb
@@ -429,12 +429,14 @@ describe Groups::ClustersController do
end
let(:cluster) { create(:cluster, :provided_by_user, cluster_type: :group_type, groups: [group]) }
+ let(:domain) { 'test-domain.com' }
let(:params) do
{
cluster: {
enabled: false,
- name: 'my-new-cluster-name'
+ name: 'my-new-cluster-name',
+ base_domain: domain
}
}
end
@@ -447,6 +449,20 @@ describe Groups::ClustersController do
expect(flash[:notice]).to eq('Kubernetes cluster was successfully updated.')
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
+ expect(cluster.domain).to eq('test-domain.com')
+ end
+
+ context 'when domain is invalid' do
+ let(:domain) { 'not-a-valid-domain' }
+
+ it 'should not update cluster attributes' do
+ go
+
+ cluster.reload
+ expect(response).to render_template(:show)
+ expect(cluster.name).not_to eq('my-new-cluster-name')
+ expect(cluster.domain).not_to eq('test-domain.com')
+ end
end
context 'when format is json' do
@@ -456,7 +472,8 @@ describe Groups::ClustersController do
{
cluster: {
enabled: false,
- name: 'my-new-cluster-name'
+ name: 'my-new-cluster-name',
+ domain: domain
}
}
end
diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb
index ed38dadfd6b..3a801fabafc 100644
--- a/spec/controllers/groups/group_members_controller_spec.rb
+++ b/spec/controllers/groups/group_members_controller_spec.rb
@@ -126,7 +126,7 @@ describe Groups::GroupMembersController do
it '[HTML] removes user from members' do
delete :destroy, params: { group_id: group, id: member }
- expect(response).to set_flash.to 'User was successfully removed from group.'
+ expect(response).to set_flash.to 'User was successfully removed from group and any subresources.'
expect(response).to redirect_to(group_group_members_path(group))
expect(group.members).not_to include member
end
diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb
index 51793f2c048..0bc09c86939 100644
--- a/spec/controllers/import/bitbucket_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_controller_spec.rb
@@ -8,6 +8,7 @@ describe Import::BitbucketController do
let(:secret) { "sekrettt" }
let(:refresh_token) { SecureRandom.hex(15) }
let(:access_params) { { token: token, expires_at: nil, expires_in: nil, refresh_token: nil } }
+ let(:code) { SecureRandom.hex(8) }
def assign_session_tokens
session[:bitbucket_token] = token
@@ -32,10 +33,16 @@ describe Import::BitbucketController do
expires_in: expires_in,
refresh_token: refresh_token)
allow_any_instance_of(OAuth2::Client)
- .to receive(:get_token).and_return(access_token)
+ .to receive(:get_token)
+ .with(hash_including(
+ 'grant_type' => 'authorization_code',
+ 'code' => code,
+ redirect_uri: users_import_bitbucket_callback_url),
+ {})
+ .and_return(access_token)
stub_omniauth_provider('bitbucket')
- get :callback
+ get :callback, params: { code: code }
expect(session[:bitbucket_token]).to eq(token)
expect(session[:bitbucket_refresh_token]).to eq(refresh_token)
diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb
index 780e49f7b93..bca5f3f6589 100644
--- a/spec/controllers/import/github_controller_spec.rb
+++ b/spec/controllers/import/github_controller_spec.rb
@@ -12,9 +12,15 @@ describe Import::GithubController do
it "redirects to GitHub for an access token if logged in with GitHub" do
allow(controller).to receive(:logged_in_with_provider?).and_return(true)
- expect(controller).to receive(:go_to_provider_for_permissions)
+ expect(controller).to receive(:go_to_provider_for_permissions).and_call_original
+ allow_any_instance_of(Gitlab::LegacyGithubImport::Client)
+ .to receive(:authorize_url)
+ .with(users_import_github_callback_url)
+ .and_call_original
get :new
+
+ expect(response).to have_http_status(302)
end
it "prompts for an access token if GitHub not configured" do
diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb
index 59463462e5a..232a5e2793b 100644
--- a/spec/controllers/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/omniauth_callbacks_controller_spec.rb
@@ -45,6 +45,29 @@ describe OmniauthCallbacksController, type: :controller do
end
end
+ context 'when sign in fails' do
+ include RoutesHelpers
+
+ let(:extern_uid) { 'my-uid' }
+ let(:provider) { :saml }
+
+ def stub_route_as(path)
+ allow(@routes).to receive(:generate_extras) { [path, []] }
+ end
+
+ it 'it calls through to the failure handler' do
+ request.env['omniauth.error'] = OneLogin::RubySaml::ValidationError.new("Fingerprint mismatch")
+ request.env['omniauth.error.strategy'] = OmniAuth::Strategies::SAML.new(nil)
+ stub_route_as('/users/auth/saml/callback')
+
+ ForgeryProtection.with_forgery_protection do
+ post :failure
+ end
+
+ expect(flash[:alert]).to match(/Fingerprint mismatch/)
+ end
+ end
+
context 'when a redirect fragment is provided' do
let(:provider) { :jwt }
let(:extern_uid) { 'my-uid' }
diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb
index 012f016b091..760c0fab130 100644
--- a/spec/controllers/profiles/preferences_controller_spec.rb
+++ b/spec/controllers/profiles/preferences_controller_spec.rb
@@ -42,7 +42,8 @@ describe Profiles::PreferencesController do
prefs = {
color_scheme_id: '1',
dashboard: 'stars',
- theme_id: '2'
+ theme_id: '2',
+ first_day_of_week: '1'
}.with_indifferent_access
expect(user).to receive(:assign_attributes).with(ActionController::Parameters.new(prefs).permit!)
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
index bd10de45b67..29df00e6bb0 100644
--- a/spec/controllers/projects/artifacts_controller_spec.rb
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -26,8 +26,15 @@ describe Projects::ArtifactsController do
end
context 'when no file type is supplied' do
+ let(:filename) { job.artifacts_file.filename }
+
it 'sends the artifacts file' do
- expect(controller).to receive(:send_file).with(job.artifacts_file.path, hash_including(disposition: 'attachment')).and_call_original
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ expect(controller).to receive(:send_file)
+ .with(
+ job.artifacts_file.file.path,
+ hash_including(disposition: %Q(attachment; filename*=UTF-8''#{filename}))).and_call_original
download_artifact
end
@@ -46,6 +53,7 @@ describe Projects::ArtifactsController do
context 'when codequality file type is supplied' do
let(:file_type) { 'codequality' }
+ let(:filename) { job.job_artifacts_codequality.filename }
context 'when file is stored locally' do
before do
@@ -53,7 +61,11 @@ describe Projects::ArtifactsController do
end
it 'sends the codequality report' do
- expect(controller).to receive(:send_file).with(job.job_artifacts_codequality.file.path, hash_including(disposition: 'attachment')).and_call_original
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ expect(controller).to receive(:send_file)
+ .with(job.job_artifacts_codequality.file.path,
+ hash_including(disposition: %Q(attachment; filename*=UTF-8''#{filename}))).and_call_original
download_artifact(file_type: file_type)
end
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 94fb85f217c..aa97a417a98 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -47,9 +47,43 @@ describe Projects::EnvironmentsController do
let(:environments) { json_response['environments'] }
+ context 'with default parameters' do
+ before do
+ get :index, params: environment_params(format: :json)
+ end
+
+ it 'responds with a flat payload describing available environments' do
+ expect(environments.count).to eq 3
+ expect(environments.first['name']).to eq 'production'
+ expect(environments.second['name']).to eq 'staging/review-1'
+ expect(environments.third['name']).to eq 'staging/review-2'
+ expect(json_response['available_count']).to eq 3
+ expect(json_response['stopped_count']).to eq 1
+ end
+
+ it 'sets the polling interval header' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Poll-Interval']).to eq("3000")
+ end
+ end
+
+ context 'when a folder-based nested structure is requested' do
+ before do
+ get :index, params: environment_params(format: :json, nested: true)
+ end
+
+ it 'responds with a payload containing the latest environment for each folder' do
+ expect(environments.count).to eq 2
+ expect(environments.first['name']).to eq 'production'
+ expect(environments.second['name']).to eq 'staging'
+ expect(environments.second['size']).to eq 2
+ expect(environments.second['latest']['name']).to eq 'staging/review-2'
+ end
+ end
+
context 'when requesting available environments scope' do
before do
- get :index, params: environment_params(format: :json, scope: :available)
+ get :index, params: environment_params(format: :json, nested: true, scope: :available)
end
it 'responds with a payload describing available environments' do
@@ -64,16 +98,11 @@ describe Projects::EnvironmentsController do
expect(json_response['available_count']).to eq 3
expect(json_response['stopped_count']).to eq 1
end
-
- it 'sets the polling interval header' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers['Poll-Interval']).to eq("3000")
- end
end
context 'when requesting stopped environments scope' do
before do
- get :index, params: environment_params(format: :json, scope: :stopped)
+ get :index, params: environment_params(format: :json, nested: true, scope: :stopped)
end
it 'responds with a payload describing stopped environments' do
@@ -393,6 +422,79 @@ describe Projects::EnvironmentsController do
end
end
+ describe 'GET #search' do
+ before do
+ create(:environment, name: 'staging', project: project)
+ create(:environment, name: 'review/patch-1', project: project)
+ create(:environment, name: 'review/patch-2', project: project)
+ end
+
+ let(:query) { 'pro' }
+
+ it 'responds with status code 200' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response).to contain_exactly('production')
+ end
+
+ context 'when query is review' do
+ let(:query) { 'review' }
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response).to contain_exactly('review/patch-1', 'review/patch-2')
+ end
+ end
+
+ context 'when query is empty' do
+ let(:query) { '' }
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response)
+ .to contain_exactly('production', 'staging', 'review/patch-1', 'review/patch-2')
+ end
+ end
+
+ context 'when query is review/patch-3' do
+ let(:query) { 'review/patch-3' }
+
+ it 'responds with status code 204' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when query is partially matched in the middle of environment name' do
+ let(:query) { 'patch' }
+
+ it 'responds with status code 204' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when query contains a wildcard character' do
+ let(:query) { 'review%' }
+
+ it 'prevents wildcard injection' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
diff --git a/spec/controllers/projects/error_tracking_controller_spec.rb b/spec/controllers/projects/error_tracking_controller_spec.rb
index 6464398cea1..844c61f1ace 100644
--- a/spec/controllers/projects/error_tracking_controller_spec.rb
+++ b/spec/controllers/projects/error_tracking_controller_spec.rb
@@ -107,8 +107,11 @@ describe Projects::ErrorTrackingController do
let(:http_status) { :no_content }
before do
- expect(list_issues_service).to receive(:execute)
- .and_return(status: :error, message: error_message, http_status: http_status)
+ expect(list_issues_service).to receive(:execute).and_return(
+ status: :error,
+ message: error_message,
+ http_status: http_status
+ )
end
it 'returns http_status with message' do
@@ -122,6 +125,113 @@ describe Projects::ErrorTrackingController do
end
end
+ describe 'POST #list_projects' do
+ context 'with insufficient permissions' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns 404' do
+ post :list_projects, params: list_projects_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'with an anonymous user' do
+ before do
+ sign_out(user)
+ end
+
+ it 'redirects to sign-in page' do
+ post :list_projects, params: list_projects_params
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with authorized user' do
+ let(:list_projects_service) { spy(:list_projects_service) }
+ let(:sentry_project) { build(:error_tracking_project) }
+
+ let(:permitted_params) do
+ ActionController::Parameters.new(
+ list_projects_params[:error_tracking_setting]
+ ).permit!
+ end
+
+ before do
+ allow(ErrorTracking::ListProjectsService)
+ .to receive(:new).with(project, user, permitted_params)
+ .and_return(list_projects_service)
+ end
+
+ context 'service result is successful' do
+ before do
+ expect(list_projects_service).to receive(:execute)
+ .and_return(status: :success, projects: [sentry_project])
+ end
+
+ it 'returns a list of projects' do
+ post :list_projects, params: list_projects_params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('error_tracking/list_projects')
+ expect(json_response['projects']).to eq([sentry_project].as_json)
+ end
+ end
+
+ context 'service result is erroneous' do
+ let(:error_message) { 'error message' }
+
+ context 'without http_status' do
+ before do
+ expect(list_projects_service).to receive(:execute)
+ .and_return(status: :error, message: error_message)
+ end
+
+ it 'returns 400 with message' do
+ get :list_projects, params: list_projects_params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq(error_message)
+ end
+ end
+
+ context 'with explicit http_status' do
+ let(:http_status) { :no_content }
+
+ before do
+ expect(list_projects_service).to receive(:execute).and_return(
+ status: :error,
+ message: error_message,
+ http_status: http_status
+ )
+ end
+
+ it 'returns http_status with message' do
+ get :list_projects, params: list_projects_params
+
+ expect(response).to have_gitlab_http_status(http_status)
+ expect(json_response['message']).to eq(error_message)
+ end
+ end
+ end
+ end
+
+ private
+
+ def list_projects_params(opts = {})
+ project_params(
+ format: :json,
+ error_tracking_setting: {
+ api_host: 'gitlab.com',
+ token: 'token'
+ }
+ )
+ end
+ end
+
private
def project_params(opts = {})
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index c2afff6b732..c34d7c13d57 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -133,7 +133,7 @@ describe Projects::IssuesController do
it 'redirects to signin if not logged in' do
get :new, params: { namespace_id: project.namespace, project_id: project }
- expect(flash[:notice]).to eq 'Please sign in to create the new issue.'
+ expect(flash[:alert]).to eq 'You need to sign in or sign up before continuing.'
expect(response).to redirect_to(new_user_session_path)
end
@@ -379,6 +379,23 @@ describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(200)
end
end
+
+ context 'when getting the changes' do
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ it 'returns the necessary data' do
+ go(id: issue.iid)
+
+ data = JSON.parse(response.body)
+
+ expect(data).to include('title_text', 'description', 'description_text')
+ expect(data).to include('task_status', 'lock_version')
+ end
+ end
end
describe 'Confidential Issues' do
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index ca5ff9b1e3b..79f97aa4170 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -387,6 +387,23 @@ describe Projects::MergeRequestsController do
end
end
+ context 'when a squash commit message is passed' do
+ let(:message) { 'My custom squash commit message' }
+
+ it 'passes the same message to SquashService' do
+ params = { squash: '1', squash_commit_message: message }
+
+ expect_next_instance_of(MergeRequests::SquashService, project, user, params.merge(merge_request: merge_request)) do |squash_service|
+ expect(squash_service).to receive(:execute).and_return({
+ status: :success,
+ squash_sha: SecureRandom.hex(20)
+ })
+ end
+
+ merge_with_sha(params)
+ end
+ end
+
context 'when the pipeline succeeds is passed' do
let!(:head_pipeline) do
create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch, head_pipeline_of: merge_request)
diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
index 80506249ea9..fa732437fc1 100644
--- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb
+++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
@@ -3,9 +3,14 @@ require 'spec_helper'
describe Projects::PipelineSchedulesController do
include AccessMatchersForController
+ set(:user) { create(:user) }
set(:project) { create(:project, :public, :repository) }
set(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
+ before do
+ project.add_developer(user)
+ end
+
describe 'GET #index' do
render_views
@@ -14,6 +19,10 @@ describe Projects::PipelineSchedulesController do
create(:ci_pipeline_schedule, :inactive, project: project)
end
+ before do
+ sign_in(user)
+ end
+
it 'renders the index view' do
visit_pipelines_schedules
@@ -21,7 +30,7 @@ describe Projects::PipelineSchedulesController do
expect(response).to render_template(:index)
end
- it 'avoids N + 1 queries' do
+ it 'avoids N + 1 queries', :request_store do
control_count = ActiveRecord::QueryRecorder.new { visit_pipelines_schedules }.count
create_list(:ci_pipeline_schedule, 2, project: project)
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 97e04a63d4a..ece8532cb84 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -5,7 +5,7 @@ describe Projects::PipelinesController do
set(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
- let(:feature) { ProjectFeature::DISABLED }
+ let(:feature) { ProjectFeature::ENABLED }
before do
stub_not_protect_default_branch
@@ -186,6 +186,27 @@ describe Projects::PipelinesController do
end
end
+ context 'when builds are disabled' do
+ let(:feature) { ProjectFeature::DISABLED }
+
+ it 'users can not see internal pipelines' do
+ get_pipeline_json
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when pipeline is external' do
+ let(:pipeline) { create(:ci_pipeline, source: :external, project: project) }
+
+ it 'users can see the external pipeline' do
+ get_pipeline_json
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['id']).to be(pipeline.id)
+ end
+ end
+ end
+
def get_pipeline_json
get :show, params: { namespace_id: project.namespace, project_id: project, id: pipeline }, format: :json
end
@@ -326,16 +347,14 @@ describe Projects::PipelinesController do
format: :json
end
- context 'when builds are enabled' do
- let(:feature) { ProjectFeature::ENABLED }
-
- it 'retries a pipeline without returning any content' do
- expect(response).to have_gitlab_http_status(:no_content)
- expect(build.reload).to be_retried
- end
+ it 'retries a pipeline without returning any content' do
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(build.reload).to be_retried
end
context 'when builds are disabled' do
+ let(:feature) { ProjectFeature::DISABLED }
+
it 'fails to retry pipeline' do
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -355,16 +374,14 @@ describe Projects::PipelinesController do
format: :json
end
- context 'when builds are enabled' do
- let(:feature) { ProjectFeature::ENABLED }
-
- it 'cancels a pipeline without returning any content' do
- expect(response).to have_gitlab_http_status(:no_content)
- expect(pipeline.reload).to be_canceled
- end
+ it 'cancels a pipeline without returning any content' do
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(pipeline.reload).to be_canceled
end
context 'when builds are disabled' do
+ let(:feature) { ProjectFeature::DISABLED }
+
it 'fails to retry pipeline' do
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index 4a5d2bdecb7..601a292bf54 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -152,6 +152,16 @@ describe Projects::ServicesController do
expect(service.namespace).not_to eq('updated_namespace')
end
end
+
+ context 'when activating JIRA service from a template' do
+ let(:template_service) { create(:jira_service, project: project, template: true) }
+
+ it 'activate JIRA service from template' do
+ put :update, params: { namespace_id: project.namespace, project_id: project, id: service.to_param, service: { active: true } }
+
+ expect(flash[:notice]).to eq 'JIRA activated.'
+ end
+ end
end
describe "GET #edit" do
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
index 27edf226ca3..af61026098b 100644
--- a/spec/controllers/users_controller_spec.rb
+++ b/spec/controllers/users_controller_spec.rb
@@ -206,6 +206,38 @@ describe UsersController do
end
end
+ describe 'GET #contributed' do
+ let(:project) { create(:project, :public) }
+ let(:current_user) { create(:user) }
+
+ before do
+ sign_in(current_user)
+
+ project.add_developer(public_user)
+ project.add_developer(private_user)
+ end
+
+ context 'with public profile' do
+ it 'renders contributed projects' do
+ create(:push_event, project: project, author: public_user)
+
+ get :contributed, params: { username: public_user.username }
+
+ expect(assigns[:contributed_projects]).not_to be_empty
+ end
+ end
+
+ context 'with private profile' do
+ it 'does not render contributed projects' do
+ create(:push_event, project: project, author: private_user)
+
+ get :contributed, params: { username: private_user.username }
+
+ expect(assigns[:contributed_projects]).to be_empty
+ end
+ end
+ end
+
describe 'GET #snippets' do
before do
sign_in(user)
diff --git a/spec/factories/commits.rb b/spec/factories/commits.rb
index 818f7b046f6..2bcc4b6cf52 100644
--- a/spec/factories/commits.rb
+++ b/spec/factories/commits.rb
@@ -16,14 +16,24 @@ FactoryBot.define do
commit
end
+
project
+ skip_create # Commits cannot be persisted
+
initialize_with do
new(git_commit, project)
end
after(:build) do |commit, evaluator|
allow(commit).to receive(:author).and_return(evaluator.author || build_stubbed(:author))
+ allow(commit).to receive(:parent_ids).and_return([])
+ end
+
+ trait :merge_commit do
+ after(:build) do |commit|
+ allow(commit).to receive(:parent_ids).and_return(Array.new(2) { SecureRandom.hex(20) })
+ end
end
trait :without_author do
diff --git a/spec/factories/error_tracking/project.rb b/spec/factories/error_tracking/project.rb
new file mode 100644
index 00000000000..5e9219b241f
--- /dev/null
+++ b/spec/factories/error_tracking/project.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :error_tracking_project, class: Gitlab::ErrorTracking::Project do
+ id '1'
+ name 'Sentry Example'
+ slug 'sentry-example'
+ status 'active'
+ organization_name 'Sentry'
+ organization_id '1'
+ organization_slug 'sentry'
+
+ skip_create
+ end
+end
diff --git a/spec/factories/project_error_tracking_settings.rb b/spec/factories/project_error_tracking_settings.rb
index fbd8dfd395c..be30bd0260a 100644
--- a/spec/factories/project_error_tracking_settings.rb
+++ b/spec/factories/project_error_tracking_settings.rb
@@ -6,5 +6,7 @@ FactoryBot.define do
api_url 'https://gitlab.com/api/0/projects/sentry-org/sentry-project'
enabled true
token 'access_token_123'
+ project_name 'Sentry Project'
+ organization_name 'Sentry Org'
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 1906c06a211..18fab395cc2 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -1,7 +1,7 @@
require_relative '../support/helpers/test_env'
FactoryBot.define do
- PAGES_ACCESS_LEVEL_SCHEMA_VERSION = 20180423204600
+ PAGES_ACCESS_LEVEL_SCHEMA_VERSION ||= 20180423204600
# Project without repository
#
diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb
index 3c2ae5b3c6a..57215c0d1e9 100644
--- a/spec/features/admin/admin_appearance_spec.rb
+++ b/spec/features/admin/admin_appearance_spec.rb
@@ -10,10 +10,10 @@ describe 'Admin Appearance' do
fill_in 'appearance_title', with: 'MyCompany'
fill_in 'appearance_description', with: 'dev server'
fill_in 'appearance_new_project_guidelines', with: 'Custom project guidelines'
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(current_path).to eq admin_appearances_path
- expect(page).to have_content 'Appearance settings'
+ expect(page).to have_content 'Appearance'
expect(page).to have_field('appearance_title', with: 'MyCompany')
expect(page).to have_field('appearance_description', with: 'dev server')
@@ -57,7 +57,7 @@ describe 'Admin Appearance' do
visit admin_appearances_path
attach_file(:appearance_logo, logo_fixture)
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_css(logo_selector)
click_link 'Remove logo'
@@ -69,7 +69,7 @@ describe 'Admin Appearance' do
visit admin_appearances_path
attach_file(:appearance_header_logo, logo_fixture)
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_css(header_logo_selector)
click_link 'Remove header logo'
@@ -81,7 +81,7 @@ describe 'Admin Appearance' do
visit admin_appearances_path
attach_file(:appearance_favicon, logo_fixture)
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_css('.appearance-light-logo-preview')
@@ -91,7 +91,7 @@ describe 'Admin Appearance' do
# allowed file types
attach_file(:appearance_favicon, Rails.root.join('spec', 'fixtures', 'sanitized.svg'))
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_content 'Favicon You are not allowed to upload "svg" files, allowed types: png, ico'
end
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 931095936a6..b1c6f308bc6 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
describe "Admin::Users" do
- include Spec::Support::Helpers::Features::ListRowsHelpers
+ include Spec::Support::Helpers::Features::ResponsiveTableHelpers
let!(:user) do
create(:omniauth_user, provider: 'twitter', extern_uid: '123456')
end
- let!(:current_user) { create(:admin) }
+ let!(:current_user) { create(:admin, last_activity_on: 5.days.ago) }
before do
sign_in(current_user)
@@ -25,6 +25,8 @@ describe "Admin::Users" do
it "has users list" do
expect(page).to have_content(current_user.email)
expect(page).to have_content(current_user.name)
+ expect(page).to have_content(current_user.created_at.strftime("%e %b, %Y"))
+ expect(page).to have_content(current_user.last_activity_on.strftime("%e %b, %Y"))
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
expect(page).to have_link('Block', href: block_admin_user_path(user))
@@ -32,10 +34,24 @@ describe "Admin::Users" do
expect(page).to have_button('Delete user and contributions')
end
+ describe "view extra user information", :js do
+ it 'does not have the user popover open' do
+ expect(page).not_to have_selector('#__BV_popover_1__')
+ end
+
+ it 'shows the user popover on hover' do
+ first_user_link = page.first('.js-user-link')
+
+ first_user_link.hover
+
+ expect(page).to have_selector('#__BV_popover_1__')
+ end
+ end
+
describe 'search and sort' do
before do
- create(:user, name: 'Foo Bar')
- create(:user, name: 'Foo Baz')
+ create(:user, name: 'Foo Bar', last_activity_on: 3.days.ago)
+ create(:user, name: 'Foo Baz', last_activity_on: 2.days.ago)
create(:user, name: 'Dmitriy')
end
@@ -75,6 +91,24 @@ describe "Admin::Users" do
expect(first_row.text).to include('Foo Bar')
expect(second_row.text).to include('Foo Baz')
end
+
+ it 'sorts users by recent last activity' do
+ visit admin_users_path(search_query: 'Foo')
+
+ sort_by('Recent last activity')
+
+ expect(first_row.text).to include('Foo Baz')
+ expect(second_row.text).to include('Foo Bar')
+ end
+
+ it 'sorts users by oldest last activity' do
+ visit admin_users_path(search_query: 'Foo')
+
+ sort_by('Oldest last activity')
+
+ expect(first_row.text).to include('Foo Bar')
+ expect(second_row.text).to include('Foo Baz')
+ end
end
describe 'Two-factor Authentication filters' do
diff --git a/spec/features/clusters/cluster_detail_page_spec.rb b/spec/features/clusters/cluster_detail_page_spec.rb
new file mode 100644
index 00000000000..0a9c4bcaf12
--- /dev/null
+++ b/spec/features/clusters/cluster_detail_page_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Clusterable > Show page' do
+ let(:current_user) { create(:user) }
+
+ before do
+ sign_in(current_user)
+ end
+
+ shared_examples 'editing domain' do
+ before do
+ clusterable.add_maintainer(current_user)
+ end
+
+ it 'allow the user to set domain' do
+ visit cluster_path
+
+ within '#cluster-integration' do
+ fill_in('cluster_base_domain', with: 'test.com')
+ click_on 'Save changes'
+ end
+
+ expect(page.status_code).to eq(200)
+ expect(page).to have_content('Kubernetes cluster was successfully updated.')
+ end
+
+ context 'when there is a cluster with ingress and external ip' do
+ before do
+ cluster.create_application_ingress!(external_ip: '192.168.1.100')
+
+ visit cluster_path
+ end
+
+ it 'shows help text with the domain as an alternative to custom domain' do
+ within '#cluster-integration' do
+ expect(page).to have_content('Alternatively 192.168.1.100.nip.io can be used instead of a custom domain')
+ end
+ end
+ end
+
+ context 'when there is no ingress' do
+ it 'alternative to custom domain is not shown' do
+ visit cluster_path
+
+ within '#cluster-integration' do
+ expect(page).not_to have_content('can be used instead of a custom domain.')
+ end
+ end
+ end
+ end
+
+ context 'when clusterable is a project' do
+ it_behaves_like 'editing domain' do
+ let(:clusterable) { create(:project) }
+ let(:cluster) { create(:cluster, :provided_by_gcp, :project, projects: [clusterable]) }
+ let(:cluster_path) { project_cluster_path(clusterable, cluster) }
+ end
+ end
+
+ context 'when clusterable is a group' do
+ it_behaves_like 'editing domain' do
+ let(:clusterable) { create(:group) }
+ let(:cluster) { create(:cluster, :provided_by_gcp, :group, groups: [clusterable]) }
+ let(:cluster_path) { group_cluster_path(clusterable, cluster) }
+ end
+ end
+end
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index edca8f9df08..6c4b04ab76b 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -147,6 +147,27 @@ describe 'Dashboard Projects' do
expect(page).to have_link('Commit: passed')
end
end
+
+ context 'guest user of project and project has private pipelines' do
+ let(:guest_user) { create(:user) }
+
+ before do
+ project.update(public_builds: false)
+ project.add_guest(guest_user)
+ sign_in(guest_user)
+ end
+
+ it 'shows that the last pipeline passed' do
+ visit dashboard_projects_path
+
+ page.within('.controls') do
+ expect(page).not_to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']")
+ expect(page).not_to have_css('.ci-status-link')
+ expect(page).not_to have_css('.ci-status-icon-success')
+ expect(page).not_to have_link('Commit: passed')
+ end
+ end
+ end
end
context 'last push widget', :use_clean_rails_memory_store_caching do
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb
index 16754035076..60ddb02da2c 100644
--- a/spec/features/markdown/copy_as_gfm_spec.rb
+++ b/spec/features/markdown/copy_as_gfm_spec.rb
@@ -843,6 +843,7 @@ describe 'Copy as GFM', :js do
def verify(selector, gfm, target: nil)
html = html_for_selector(selector)
output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target)
+ wait_for_requests
expect(output_gfm.strip).to eq(gfm.strip)
end
end
@@ -861,6 +862,9 @@ describe 'Copy as GFM', :js do
def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil)
js = <<~JS
(function(html) {
+ // Setting it off so the import already starts
+ window.CopyAsGFM.nodeToGFM(document.createElement('div'));
+
var transformer = window.CopyAsGFM[#{transformer.inspect}];
var node = document.createElement('div');
@@ -875,9 +879,18 @@ describe 'Copy as GFM', :js do
node = transformer(node, target);
if (!node) return null;
- return window.CopyAsGFM.nodeToGFM(node);
+
+ window.gfmCopytestRes = null;
+ window.CopyAsGFM.nodeToGFM(node)
+ .then((res) => {
+ window.gfmCopytestRes = res;
+ });
})("#{escape_javascript(html)}")
JS
- page.evaluate_script(js)
+ page.execute_script(js)
+
+ loop until page.evaluate_script('window.gfmCopytestRes !== null')
+
+ page.evaluate_script('window.gfmCopytestRes')
end
end
diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb
index 3b37ede8579..8815643ca96 100644
--- a/spec/features/markdown/markdown_spec.rb
+++ b/spec/features/markdown/markdown_spec.rb
@@ -13,7 +13,7 @@ require 'erb'
#
# Raw Markdown
# -> `markdown` helper
-# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML
+# -> CommonMark::Render::GitlabHTML converts Markdown to HTML
# -> Post-process HTML
# -> `gfm` helper
# -> HTML::Pipeline
@@ -324,31 +324,6 @@ describe 'GitLab Markdown', :aggregate_failures do
end
end
- context 'Redcarpet documents' do
- before do
- allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet')
- @html = markdown(@feat.raw_markdown)
- end
-
- it 'processes certain elements differently' do
- aggregate_failures 'parses superscript' do
- expect(doc).to have_selector('sup', count: 3)
- end
-
- aggregate_failures 'permits style attribute in th elements' do
- expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center'
- expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right'
- expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left'
- end
-
- aggregate_failures 'permits style attribute in td elements' do
- expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center'
- expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right'
- expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left'
- end
- end
- end
-
# Fake a `current_user` helper
def current_user
@feat.user
diff --git a/spec/features/markdown/math_spec.rb b/spec/features/markdown/math_spec.rb
index 678ce80b382..16ad0d456be 100644
--- a/spec/features/markdown/math_spec.rb
+++ b/spec/features/markdown/math_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe 'Math rendering', :js do
+ let!(:project) { create(:project, :public) }
+
it 'renders inline and display math correctly' do
description = <<~MATH
This math is inline $`a^2+b^2=c^2`$.
@@ -11,7 +13,6 @@ describe 'Math rendering', :js do
```
MATH
- project = create(:project, :public)
issue = create(:issue, project: project, description: description)
visit project_issue_path(project, issue)
@@ -19,4 +20,19 @@ describe 'Math rendering', :js do
expect(page).to have_selector('.katex .mord.mathdefault', text: 'b')
expect(page).to have_selector('.katex-display .mord.mathdefault', text: 'b')
end
+
+ it 'only renders non XSS links' do
+ description = <<~MATH
+ This link is valid $`\\href{javascript:alert('xss');}{xss}`$.
+
+ This link is valid $`\\href{https://gitlab.com}{Gitlab}`$.
+ MATH
+
+ issue = create(:issue, project: project, description: description)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_selector('.katex-error', text: "\href{javascript:alert('xss');}{xss}")
+ expect(page).to have_selector('.katex-html a', text: 'Gitlab')
+ end
end
diff --git a/spec/features/merge_request/user_accepts_merge_request_spec.rb b/spec/features/merge_request/user_accepts_merge_request_spec.rb
index 00ac7c72a11..5fa23dbb998 100644
--- a/spec/features/merge_request/user_accepts_merge_request_spec.rb
+++ b/spec/features/merge_request/user_accepts_merge_request_spec.rb
@@ -80,8 +80,8 @@ describe 'User accepts a merge request', :js do
end
it 'accepts a merge request' do
- click_button('Modify commit message')
- fill_in('Commit message', with: 'wow such merge')
+ find('.js-mr-widget-commits-count').click
+ fill_in('merge-message-edit', with: 'wow such merge')
click_button('Merge')
diff --git a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
index 8d2d4279d3c..c6b11fce388 100644
--- a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
+++ b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
@@ -13,7 +13,7 @@ describe 'Merge request < User customizes merge commit message', :js do
description: "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}"
)
end
- let(:textbox) { page.find(:css, '.js-commit-message', visible: false) }
+ let(:textbox) { page.find(:css, '#merge-message-edit', visible: false) }
let(:default_message) do
[
"Merge branch 'feature' into 'master'",
@@ -38,16 +38,16 @@ describe 'Merge request < User customizes merge commit message', :js do
end
it 'toggles commit message between message with description and without description' do
- expect(page).not_to have_selector('.js-commit-message')
- click_button "Modify commit message"
+ expect(page).not_to have_selector('#merge-message-edit')
+ first('.js-mr-widget-commits-count').click
expect(textbox).to be_visible
expect(textbox.value).to eq(default_message)
- click_link "Include description in commit message"
+ check('Include merge request description')
expect(textbox.value).to eq(message_with_description)
- click_link "Don't include description in commit message"
+ uncheck('Include merge request description')
expect(textbox.value).to eq(default_message)
end
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index ee5f5377ca6..1bbcf455ac7 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -66,6 +66,38 @@ describe 'Merge request > User posts notes', :js do
is_expected.to have_css('.js-note-text', visible: true)
end
end
+
+ describe 'when reply_to_individual_notes feature flag is not set' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not show a reply button' do
+ expect(page).to have_no_selector('.js-reply-button')
+ end
+ end
+
+ describe 'when reply_to_individual_notes feature flag is set' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows a reply button' do
+ reply_button = find('.js-reply-button', match: :first)
+
+ expect(reply_button).to have_selector('.ic-comment')
+ end
+
+ it 'shows reply placeholder when clicking reply button' do
+ reply_button = find('.js-reply-button', match: :first)
+
+ reply_button.click
+
+ expect(page).to have_selector('.discussion-reply-holder')
+ end
+ end
end
describe 'when previewing a note' do
diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb
index 50c723776a3..16c058ab6bd 100644
--- a/spec/features/merge_request/user_resolves_conflicts_spec.rb
+++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb
@@ -37,6 +37,8 @@ describe 'Merge request > User resolves conflicts', :js do
click_on 'Changes'
wait_for_requests
+ find('.js-toggle-tree-list').click
+
within find('.diff-file', text: 'files/ruby/popen.rb') do
expect(page).to have_selector('.line_content.new', text: "vars = { 'PWD' => path }")
expect(page).to have_selector('.line_content.new', text: "options = { chdir: path }")
diff --git a/spec/features/merge_request/user_sees_versions_spec.rb b/spec/features/merge_request/user_sees_versions_spec.rb
index 63d8decc2d2..aa91ade46ca 100644
--- a/spec/features/merge_request/user_sees_versions_spec.rb
+++ b/spec/features/merge_request/user_sees_versions_spec.rb
@@ -42,7 +42,7 @@ describe 'Merge request > User sees versions', :js do
expect(page).to have_content 'latest version'
end
- expect(page).to have_content '8 changed files'
+ expect(page).to have_content '8 Files'
end
it_behaves_like 'allows commenting',
@@ -76,7 +76,7 @@ describe 'Merge request > User sees versions', :js do
end
it 'shows comments that were last relevant at that version' do
- expect(page).to have_content '5 changed files'
+ expect(page).to have_content '5 Files'
position = Gitlab::Diff::Position.new(
old_path: ".gitmodules",
@@ -120,8 +120,15 @@ describe 'Merge request > User sees versions', :js do
diff_id: merge_request_diff3.id,
start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9'
)
- expect(page).to have_content '4 changed files'
- expect(page).to have_content '15 additions 6 deletions'
+ expect(page).to have_content '4 Files'
+
+ additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-addition')
+ .ancestor('.diff-stats-group').text
+ deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-deletion')
+ .ancestor('.diff-stats-group').text
+
+ expect(additions_content).to eq '15'
+ expect(deletions_content).to eq '6'
position = Gitlab::Diff::Position.new(
old_path: ".gitmodules",
@@ -141,8 +148,14 @@ describe 'Merge request > User sees versions', :js do
end
it 'show diff between new and old version' do
- expect(page).to have_content '4 changed files'
- expect(page).to have_content '15 additions 6 deletions'
+ additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-addition')
+ .ancestor('.diff-stats-group').text
+ deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-deletion')
+ .ancestor('.diff-stats-group').text
+
+ expect(page).to have_content '4 Files'
+ expect(additions_content).to eq '15'
+ expect(deletions_content).to eq '6'
end
it 'returns to latest version when "Show latest version" button is clicked' do
@@ -150,7 +163,7 @@ describe 'Merge request > User sees versions', :js do
page.within '.mr-version-dropdown' do
expect(page).to have_content 'latest version'
end
- expect(page).to have_content '8 changed files'
+ expect(page).to have_content '8 Files'
end
it_behaves_like 'allows commenting',
@@ -176,7 +189,7 @@ describe 'Merge request > User sees versions', :js do
find('.btn-default').click
click_link 'version 1'
end
- expect(page).to have_content '0 changed files'
+ expect(page).to have_content '0 Files'
end
end
@@ -202,7 +215,7 @@ describe 'Merge request > User sees versions', :js do
expect(page).to have_content 'version 1'
end
- expect(page).to have_content '0 changed files'
+ expect(page).to have_content '0 Files'
end
end
diff --git a/spec/features/merge_requests/user_squashes_merge_request_spec.rb b/spec/features/merge_requests/user_squashes_merge_request_spec.rb
index 47f9f10815c..bf9c55cf22c 100644
--- a/spec/features/merge_requests/user_squashes_merge_request_spec.rb
+++ b/spec/features/merge_requests/user_squashes_merge_request_spec.rb
@@ -14,7 +14,7 @@ describe 'User squashes a merge request', :js do
latest_master_commits = project.repository.commits_between(original_head.sha, 'master').map(&:raw)
squash_commit = an_object_having_attributes(sha: a_string_matching(/\h{40}/),
- message: "Csv\n",
+ message: a_string_starting_with(project.merge_requests.first.default_squash_commit_message),
author_name: user.name,
committer_name: user.name)
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
index f45bcabd196..b43711f6ef6 100644
--- a/spec/features/profiles/user_edit_profile_spec.rb
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -10,6 +10,7 @@ describe 'User edit profile' do
def submit_settings
click_button 'Update profile settings'
+ wait_for_requests if respond_to?(:wait_for_requests)
end
it 'changes user profile' do
@@ -35,6 +36,17 @@ describe 'User edit profile' do
expect(page).to have_content('Profile was successfully updated')
end
+ it 'shows an error if the full name contains an emoji', :js do
+ simulate_input('#user_name', 'Martin 😀')
+ submit_settings
+
+ page.within('.qa-full-name') do
+ expect(page).to have_css '.gl-field-error-outline'
+ expect(find('.gl-field-error')).not_to have_selector('.hidden')
+ expect(find('.gl-field-error')).to have_content('Using emojis in names seems fun, but please try to set a status message instead')
+ end
+ end
+
context 'user avatar' do
before do
attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif'))
@@ -61,6 +73,11 @@ describe 'User edit profile' do
end
context 'user status', :js do
+ def visit_user
+ visit user_path(user)
+ wait_for_requests
+ end
+
def select_emoji(emoji_name, is_modal = false)
emoji_menu_class = is_modal ? '.js-modal-status-emoji-menu' : '.js-status-emoji-menu'
toggle_button = find('.js-toggle-emoji-menu')
@@ -71,18 +88,16 @@ describe 'User edit profile' do
context 'profile edit form' do
it 'shows the user status form' do
- visit(profile_path)
-
expect(page).to have_content('Current status')
end
it 'adds emoji to user status' do
emoji = 'biohazard'
- visit(profile_path)
select_emoji(emoji)
submit_settings
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
end
@@ -90,11 +105,11 @@ describe 'User edit profile' do
it 'adds message to user status' do
message = 'I have something to say'
- visit(profile_path)
fill_in 'js-status-message-field', with: message
submit_settings
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji('speech_balloon')
expect(page).to have_content message
@@ -104,12 +119,12 @@ describe 'User edit profile' do
it 'adds message and emoji to user status' do
emoji = 'tanabata_tree'
message = 'Playing outside'
- visit(profile_path)
select_emoji(emoji)
fill_in 'js-status-message-field', with: message
submit_settings
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
expect(page).to have_content message
@@ -119,7 +134,8 @@ describe 'User edit profile' do
it 'clears the user status' do
user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(user_status.emoji)
expect(page).to have_content user_status.message
@@ -129,15 +145,13 @@ describe 'User edit profile' do
click_button 'js-clear-user-status-button'
submit_settings
- wait_for_requests
+ visit_user
- visit user_path(user)
expect(page).not_to have_selector '.cover-status'
end
it 'displays a default emoji if only message is entered' do
message = 'a status without emoji'
- visit(profile_path)
fill_in 'js-status-message-field', with: message
within('.js-toggle-emoji-menu') do
@@ -162,6 +176,7 @@ describe 'User edit profile' do
page.within "#set-user-status-modal" do
click_button 'Set status'
end
+ wait_for_requests
end
before do
@@ -202,7 +217,8 @@ describe 'User edit profile' do
select_emoji(emoji, true)
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
end
@@ -225,7 +241,8 @@ describe 'User edit profile' do
find('.js-status-message-field').native.send_keys(message)
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji('speech_balloon')
expect(page).to have_content message
@@ -240,7 +257,8 @@ describe 'User edit profile' do
find('.js-status-message-field').native.send_keys(message)
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
expect(page).to have_content message
@@ -250,7 +268,9 @@ describe 'User edit profile' do
it 'clears the user status with the "X" button' do
user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
- visit user_path(user)
+ visit_user
+ wait_for_requests
+
within('.cover-status') do
expect(page).to have_emoji(user_status.emoji)
expect(page).to have_content user_status.message
@@ -265,14 +285,18 @@ describe 'User edit profile' do
find('.js-clear-user-status-button').click
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+ wait_for_requests
+
expect(page).not_to have_selector '.cover-status'
end
it 'clears the user status with the "Remove status" button' do
user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
- visit user_path(user)
+ visit_user
+ wait_for_requests
+
within('.cover-status') do
expect(page).to have_emoji(user_status.emoji)
expect(page).to have_content user_status.message
@@ -288,7 +312,8 @@ describe 'User edit profile' do
click_button 'Remove status'
end
- visit user_path(user)
+ visit_user
+
expect(page).not_to have_selector '.cover-status'
end
diff --git a/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb
index 554f0b49052..5cb015e80be 100644
--- a/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb
+++ b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb
@@ -7,7 +7,7 @@ describe "User downloads artifacts" do
shared_examples "downloading" do
it "downloads the zip" do
- expect(page.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"})
+ expect(page.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename*=UTF-8''#{job.artifacts_file.filename}; filename="#{job.artifacts_file.filename}"})
expect(page.response_headers['Content-Transfer-Encoding']).to eq("binary")
expect(page.response_headers['Content-Type']).to eq("application/zip")
expect(page.source.b).to eq(job.artifacts_file.file.read.b)
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index e2f9e7e9cc5..3edcc7ac2cd 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -5,8 +5,8 @@ describe 'File blob', :js do
let(:project) { create(:project, :public, :repository) }
- def visit_blob(path, anchor: nil, ref: 'master', legacy_render: nil)
- visit project_blob_path(project, File.join(ref, path), anchor: anchor, legacy_render: legacy_render)
+ def visit_blob(path, anchor: nil, ref: 'master')
+ visit project_blob_path(project, File.join(ref, path), anchor: anchor)
wait_for_requests
end
@@ -171,21 +171,6 @@ describe 'File blob', :js do
end
end
end
-
- context 'when rendering legacy markdown' do
- before do
- visit_blob('files/commonmark/file.md', legacy_render: 1)
-
- wait_for_requests
- end
-
- it 'renders using RedCarpet' do
- aggregate_failures do
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
- end
- end
end
context 'Markdown file (stored in LFS)' do
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index d5b20605860..6e6c299ee2e 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -81,17 +81,6 @@ describe 'Editing file blob', :js do
expect(page).to have_content("sublist")
expect(page).not_to have_xpath("//ol//li//ul")
end
-
- it 'renders content with RedCarpet when legacy_render is set' do
- visit project_edit_blob_path(project, tree_join(branch, readme_file_path), legacy_render: 1)
- fill_editor(content: "1. one\\n - sublist\\n")
- click_link 'Preview'
- wait_for_requests
-
- # the above generates a sublist list in RedCarpet
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
end
end
diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb
index fab9e035d53..2c8d014c36d 100644
--- a/spec/features/projects/clusters/applications_spec.rb
+++ b/spec/features/projects/clusters/applications_spec.rb
@@ -48,9 +48,9 @@ describe 'Clusters Applications', :js do
it 'they see status transition' do
page.within('.js-cluster-application-row-helm') do
- # FE sends request and gets the response, then the buttons is "Install"
+ # FE sends request and gets the response, then the buttons is "Installing"
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
wait_until_helm_created!
@@ -118,7 +118,7 @@ describe 'Clusters Applications', :js do
page.within('.js-cluster-application-row-cert_manager') do
expect(email_form_value).to eq(cluster.user.email)
- expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
page.find('.js-email').set("new_email@example.org")
Clusters::Cluster.last.application_cert_manager.make_installing!
@@ -153,9 +153,9 @@ describe 'Clusters Applications', :js do
it 'they see status transition' do
page.within('.js-cluster-application-row-ingress') do
- # FE sends request and gets the response, then the buttons is "Install"
+ # FE sends request and gets the response, then the buttons is "Installing"
expect(page).to have_css('.js-cluster-application-install-button[disabled]')
- expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
Clusters::Cluster.last.application_ingress.make_installing!
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 24830b2bd3e..65ce872363f 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -220,7 +220,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
artifact_request = requests.find { |req| req.url.match(%r{artifacts/download}) }
- expect(artifact_request.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"})
+ expect(artifact_request.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename*=UTF-8''#{job.artifacts_file.filename}; filename="#{job.artifacts_file.filename}"})
expect(artifact_request.response_headers['Content-Transfer-Encoding']).to eq("binary")
expect(artifact_request.response_headers['Content-Type']).to eq("image/gif")
expect(artifact_request.body).to eq(job.artifacts_file.file.read.b)
diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb
index 766c63725b3..aa71669de98 100644
--- a/spec/features/projects/serverless/functions_spec.rb
+++ b/spec/features/projects/serverless/functions_spec.rb
@@ -1,6 +1,10 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Functions', :js do
+ include KubernetesHelpers
+
let(:project) { create(:project) }
let(:user) { create(:user) }
@@ -34,11 +38,14 @@ describe 'Functions', :js do
end
context 'when the user has a cluster and knative installed and visits the serverless page' do
- let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:service) { cluster.platform_kubernetes }
let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:project) { knative.cluster.project }
before do
+ stub_kubeclient_knative_services
+ stub_kubeclient_service_pods
visit project_serverless_functions_path(project)
end
diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb
index 6f8ec0015ad..4c85abe9971 100644
--- a/spec/features/projects/settings/pipelines_settings_spec.rb
+++ b/spec/features/projects/settings/pipelines_settings_spec.rb
@@ -98,14 +98,12 @@ describe "Projects > Settings > Pipelines settings" do
expect(page).not_to have_content('instance enabled')
expect(find_field('project_auto_devops_attributes_enabled')).not_to be_checked
check 'Default to Auto DevOps pipeline'
- fill_in('project_auto_devops_attributes_domain', with: 'test.com')
click_on 'Save changes'
end
expect(page.status_code).to eq(200)
expect(project.auto_devops).to be_present
expect(project.auto_devops).to be_enabled
- expect(project.auto_devops.domain).to eq('test.com')
page.within '#autodevops-settings' do
expect(find_field('project_auto_devops_attributes_enabled')).to be_checked
@@ -113,29 +111,6 @@ describe "Projects > Settings > Pipelines settings" do
end
end
end
-
- context 'when there is a cluster with ingress and external_ip' do
- before do
- cluster = create(:cluster, projects: [project])
- cluster.create_application_ingress!(external_ip: '192.168.1.100')
- end
-
- it 'shows the help text with the nip.io domain as an alternative to custom domain' do
- visit project_settings_ci_cd_path(project)
- expect(page).to have_content('192.168.1.100.nip.io can be used as an alternative to a custom domain')
- end
- end
-
- context 'when there is no ingress' do
- before do
- create(:cluster, projects: [project])
- end
-
- it 'alternative to custom domain is not shown' do
- visit project_settings_ci_cd_path(project)
- expect(page).not_to have_content('can be used as an alternative to a custom domain')
- end
- end
end
describe 'runners registration token' do
diff --git a/spec/features/projects/settings/user_changes_default_branch_spec.rb b/spec/features/projects/settings/user_changes_default_branch_spec.rb
index fcf05e04a5c..7dc18601f50 100644
--- a/spec/features/projects/settings/user_changes_default_branch_spec.rb
+++ b/spec/features/projects/settings/user_changes_default_branch_spec.rb
@@ -15,6 +15,9 @@ describe 'Projects > Settings > User changes default branch' do
let(:project) { create(:project, :repository, namespace: user.namespace) }
it 'allows to change the default branch', :js do
+ # Otherwise, running JS may overwrite our change to project_default_branch
+ wait_for_requests
+
select2('fix', from: '#project_default_branch')
page.within '#default-branch-settings' do
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index f505023d1d0..3b469fee867 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -175,20 +175,6 @@ describe 'Projects > Wiki > User previews markdown changes', :js do
expect(page).to have_content("sublist")
expect(page).not_to have_xpath("//ol//li//ul")
end
-
- it 'renders content with RedCarpet when legacy_render is set' do
- wiki_page = create(:wiki_page,
- wiki: project.wiki,
- attrs: { title: 'home', content: "Empty content" })
- visit(project_wiki_edit_path(project, wiki_page, legacy_render: 1))
-
- fill_in :wiki_content, with: "1. one\n - sublist\n"
- click_on "Preview"
-
- # the above generates a sublist list in RedCarpet
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
end
end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index f7efc3f325c..bc36c6f948f 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -110,16 +110,23 @@ describe 'Project' do
it 'shows project topics' do
project.update_attribute(:tag_list, 'topic1')
+
visit path
+
expect(page).to have_css('.home-panel-topic-list')
- expect(page).to have_content('topic1')
+ expect(page).to have_link('Topic1', href: explore_projects_path(tag: 'topic1'))
end
it 'shows up to 3 project tags' do
project.update_attribute(:tag_list, 'topic1, topic2, topic3, topic4')
+
visit path
+
expect(page).to have_css('.home-panel-topic-list')
- expect(page).to have_content('topic1, topic2, topic3 + 1 more')
+ expect(page).to have_link('Topic1', href: explore_projects_path(tag: 'topic1'))
+ expect(page).to have_link('Topic2', href: explore_projects_path(tag: 'topic2'))
+ expect(page).to have_link('Topic3', href: explore_projects_path(tag: 'topic3'))
+ expect(page).to have_content('+ 1 more')
end
end
diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb
index 843dbcd5b4d..e23000fa676 100644
--- a/spec/features/security/project/internal_access_spec.rb
+++ b/spec/features/security/project/internal_access_spec.rb
@@ -452,9 +452,9 @@ describe "Internal Project Access" do
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
- it { is_expected.to be_allowed_for(:reporter).of(project) }
- it { is_expected.to be_allowed_for(:guest).of(project) }
- it { is_expected.to be_allowed_for(:user) }
+ it { is_expected.to be_denied_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb
index cf0837c1e67..f380bc122a7 100644
--- a/spec/features/security/project/private_access_spec.rb
+++ b/spec/features/security/project/private_access_spec.rb
@@ -485,7 +485,7 @@ describe "Private Project Access" do
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
- it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:reporter).of(project) }
it { is_expected.to be_denied_for(:guest).of(project) }
it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb
index 7e1b735fd3d..57d56371719 100644
--- a/spec/features/security/project/public_access_spec.rb
+++ b/spec/features/security/project/public_access_spec.rb
@@ -272,11 +272,11 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
- it { is_expected.to be_allowed_for(:reporter).of(project) }
- it { is_expected.to be_allowed_for(:guest).of(project) }
- it { is_expected.to be_allowed_for(:user) }
- it { is_expected.to be_allowed_for(:external) }
- it { is_expected.to be_allowed_for(:visitor) }
+ it { is_expected.to be_denied_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments" do
diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb
index 367a479f62a..74fdfcf492e 100644
--- a/spec/features/snippets/show_spec.rb
+++ b/spec/features/snippets/show_spec.rb
@@ -79,35 +79,6 @@ describe 'Snippet', :js do
expect(page).not_to have_xpath("//ol//li//ul")
end
end
-
- context 'when rendering legacy markdown' do
- before do
- visit snippet_path(snippet, legacy_render: 1)
-
- wait_for_requests
- end
-
- it 'renders using RedCarpet' do
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
- end
-
- context 'with cached CommonMark html' do
- let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) }
-
- it 'renders correctly' do
- expect(page).not_to have_xpath("//ol//li//ul")
- end
- end
-
- context 'with cached Redcarpet html' do
- let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, cached_markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION) }
-
- it 'renders correctly' do
- expect(page).to have_xpath("//ol//li//ul")
- end
- end
end
context 'switching to the simple viewer' do
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index 9c9127980a1..6fe840dccf6 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -36,19 +36,6 @@ describe 'Task Lists' do
MARKDOWN
end
- let(:nested_tasks_markdown_redcarpet) do
- <<-EOT.strip_heredoc
- - [ ] Task a
- - [x] Task a.1
- - [ ] Task a.2
- - [ ] Task b
-
- 1. [ ] Task 1
- 1. [ ] Task 1.1
- 1. [x] Task 1.2
- EOT
- end
-
let(:nested_tasks_markdown) do
<<-EOT.strip_heredoc
- [ ] Task a
@@ -153,59 +140,6 @@ describe 'Task Lists' do
expect(page).to have_content("1 of 1 task completed")
end
end
-
- shared_examples 'shared nested tasks' do
- before do
- allow(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet')
- visit_issue(project, issue)
- end
- it 'renders' do
- expect(page).to have_selector('ul.task-list', count: 2)
- expect(page).to have_selector('li.task-list-item', count: 7)
- expect(page).to have_selector('ul input[checked]', count: 1)
- expect(page).to have_selector('ol input[checked]', count: 1)
- end
-
- it 'solves tasks' do
- expect(page).to have_content("2 of 7 tasks completed")
-
- page.find('li.task-list-item', text: 'Task b').find('input').click
- page.find('li.task-list-item ul li.task-list-item', text: 'Task a.2').find('input').click
- page.find('li.task-list-item ol li.task-list-item', text: 'Task 1.1').find('input').click
-
- expect(page).to have_content("5 of 7 tasks completed")
-
- visit_issue(project, issue) # reload to see new system notes
-
- expect(page).to have_content('marked the task Task b as complete')
- expect(page).to have_content('marked the task Task a.2 as complete')
- expect(page).to have_content('marked the task Task 1.1 as complete')
- end
- end
-
- describe 'nested tasks', :js do
- context 'with Redcarpet' do
- let(:issue) { create(:issue, description: nested_tasks_markdown_redcarpet, author: user, project: project) }
-
- before do
- allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet')
- visit_issue(project, issue)
- end
-
- it_behaves_like 'shared nested tasks'
- end
-
- context 'with CommonMark' do
- let(:issue) { create(:issue, description: nested_tasks_markdown, author: user, project: project) }
-
- before do
- allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('CommonMark')
- visit_issue(project, issue)
- end
-
- it_behaves_like 'shared nested tasks'
- end
- end
end
describe 'for Notes' do
diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb
index 3708f0ee477..3db9ae7a951 100644
--- a/spec/features/users/overview_spec.rb
+++ b/spec/features/users/overview_spec.rb
@@ -34,7 +34,7 @@ describe 'Overview tab on a user profile', :js do
it 'does not show any entries in the list of activities' do
page.within('.activities-block') do
expect(page).to have_selector('.loading', visible: false)
- expect(page).to have_content('No activities found')
+ expect(page).to have_content('Join or create a group to start contributing by commenting on issues or submitting merge requests!')
expect(page).not_to have_selector('.event-item')
end
end
@@ -96,7 +96,7 @@ describe 'Overview tab on a user profile', :js do
it 'it shows an empty project list with an info message' do
page.within('.projects-block') do
expect(page).to have_selector('.loading', visible: false)
- expect(page).to have_content('This user doesn\'t have any personal projects')
+ expect(page).to have_content('You haven\'t created any personal projects.')
expect(page).not_to have_selector('.project-row')
end
end
diff --git a/spec/finders/contributed_projects_finder_spec.rb b/spec/finders/contributed_projects_finder_spec.rb
index 81fb4e3561c..ee84fd067d4 100644
--- a/spec/finders/contributed_projects_finder_spec.rb
+++ b/spec/finders/contributed_projects_finder_spec.rb
@@ -31,4 +31,16 @@ describe ContributedProjectsFinder do
it { is_expected.to match_array([private_project, internal_project, public_project]) }
end
+
+ context 'user with private profile' do
+ it 'does not return contributed projects' do
+ private_user = create(:user, private_profile: true)
+ public_project.add_maintainer(private_user)
+ create(:push_event, project: public_project, author: private_user)
+
+ projects = described_class.new(private_user).execute(current_user)
+
+ expect(projects).to be_empty
+ end
+ end
end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 682fae06434..34cb09942be 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -314,6 +314,14 @@ describe IssuesFinder do
end
end
+ context 'filtering by issue term in title' do
+ let(:params) { { search: 'git', in: 'title' } }
+
+ it 'returns issues with title match for search term' do
+ expect(issues).to contain_exactly(issue1)
+ end
+ end
+
context 'filtering by issues iids' do
let(:params) { { iids: issue3.iid } }
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index ff4c6b8dd42..107da08a0a9 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -68,20 +68,34 @@ describe MergeRequestsFinder do
expect(merge_requests.size).to eq(2)
end
- it 'filters by group' do
- params = { group_id: group.id }
+ context 'filtering by group' do
+ it 'includes all merge requests when user has access' do
+ params = { group_id: group.id }
- merge_requests = described_class.new(user, params).execute
+ merge_requests = described_class.new(user, params).execute
- expect(merge_requests.size).to eq(3)
- end
+ expect(merge_requests.size).to eq(3)
+ end
- it 'filters by group including subgroups', :nested_groups do
- params = { group_id: group.id, include_subgroups: true }
+ it 'excludes merge requests from projects the user does not have access to' do
+ private_project = create_project_without_n_plus_1(:private, group: group)
+ private_mr = create(:merge_request, :simple, author: user, source_project: private_project, target_project: private_project)
+ params = { group_id: group.id }
- merge_requests = described_class.new(user, params).execute
+ private_project.add_guest(user)
+ merge_requests = described_class.new(user, params).execute
- expect(merge_requests.size).to eq(6)
+ expect(merge_requests.size).to eq(3)
+ expect(merge_requests).not_to include(private_mr)
+ end
+
+ it 'filters by group including subgroups', :nested_groups do
+ params = { group_id: group.id, include_subgroups: true }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests.size).to eq(6)
+ end
end
it 'filters by non_archived' do
diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json
index 138a6c5ed6b..5ebc09a96dc 100644
--- a/spec/fixtures/api/schemas/cluster_status.json
+++ b/spec/fixtures/api/schemas/cluster_status.json
@@ -34,7 +34,8 @@
"status_reason": { "type": ["string", "null"] },
"external_ip": { "type": ["string", "null"] },
"hostname": { "type": ["string", "null"] },
- "email": { "type": ["string", "null"] }
+ "email": { "type": ["string", "null"] },
+ "update_available": { "type": ["boolean", "null"] }
},
"required" : [ "name", "status" ]
}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json
index 4c04c838cb8..3006b482d41 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_basic.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json
@@ -22,7 +22,8 @@
"type": [ "array", "null" ]
},
"task_status": { "type": "string" },
- "task_status_short": { "type": "string" }
+ "task_status_short": { "type": "string" },
+ "lock_version": { "type": ["string", "null"] }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json
index 1bd39a46830..67c209f3fc3 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_widget.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json
@@ -44,7 +44,7 @@
"merge_user": { "type": ["object", "null"] },
"diff_head_sha": { "type": ["string", "null"] },
"diff_head_commit_short_id": { "type": ["string", "null"] },
- "merge_commit_message": { "type": ["string", "null"] },
+ "default_merge_commit_message": { "type": ["string", "null"] },
"pipeline": { "type": ["object", "null"] },
"merge_pipeline": { "type": ["object", "null"] },
"work_in_progress": { "type": "boolean" },
@@ -102,7 +102,9 @@
"new_blob_path": { "type": ["string", "null"] },
"merge_check_path": { "type": "string" },
"ci_environments_status_path": { "type": "string" },
- "merge_commit_message_with_description": { "type": "string" },
+ "default_merge_commit_message_with_description": { "type": "string" },
+ "default_squash_commit_message": { "type": "string" },
+ "commits_without_merge_commits": { "type": "array" },
"diverged_commits_count": { "type": "integer" },
"commit_change_content_path": { "type": "string" },
"merge_commit_path": { "type": ["string", "null"] },
diff --git a/spec/fixtures/api/schemas/error_tracking/list_projects.json b/spec/fixtures/api/schemas/error_tracking/list_projects.json
new file mode 100644
index 00000000000..2aaa525e38f
--- /dev/null
+++ b/spec/fixtures/api/schemas/error_tracking/list_projects.json
@@ -0,0 +1,13 @@
+{
+ "type": "object",
+ "required": [
+ "projects"
+ ],
+ "properties": {
+ "projects": {
+ "type": "array",
+ "items": { "$ref": "project.json" }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/error_tracking/project.json b/spec/fixtures/api/schemas/error_tracking/project.json
new file mode 100644
index 00000000000..f6d611133c7
--- /dev/null
+++ b/spec/fixtures/api/schemas/error_tracking/project.json
@@ -0,0 +1,19 @@
+{
+ "type": "object",
+ "required" : [
+ "id",
+ "slug",
+ "organization_slug",
+ "name"
+ ],
+ "properties" : {
+ "id": { "type": "string"},
+ "name": { "type": "string" },
+ "slug": { "type": "string" },
+ "status": { "type": "string" },
+ "organization_name": { "type": "string" },
+ "organization_slug": { "type": "string" },
+ "organization_id": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/group_labels.json b/spec/fixtures/api/schemas/public_api/v4/group_labels.json
new file mode 100644
index 00000000000..f6c327abfdd
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/group_labels.json
@@ -0,0 +1,18 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties" : {
+ "id" : { "type": "integer" },
+ "name" : { "type": "string "},
+ "color" : { "type": "string "},
+ "description" : { "type": "string "},
+ "open_issues_count" : { "type": "integer "},
+ "closed_issues_count" : { "type": "integer "},
+ "open_merge_requests_count" : { "type": "integer "},
+ "subscribed" : { "type": "boolean" },
+ "priority" : { "type": "null" }
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb
index e5d01c3bd03..bbeacf1707b 100644
--- a/spec/fixtures/markdown.md.erb
+++ b/spec/fixtures/markdown.md.erb
@@ -6,7 +6,7 @@ started.
## Markdown
-GitLab uses [Redcarpet](http://git.io/ld_NVQ) to parse all Markdown into
+GitLab uses [Commonmark](https://git.io/fhDag) to parse all Markdown into
HTML.
It has some special features. Let's try 'em out!
diff --git a/spec/fixtures/pages_non_writeable.zip b/spec/fixtures/pages_non_writeable.zip
new file mode 100644
index 00000000000..69f175d8504
--- /dev/null
+++ b/spec/fixtures/pages_non_writeable.zip
Binary files differ
diff --git a/spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zip b/spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zip
new file mode 100644
index 00000000000..b9ae1548713
--- /dev/null
+++ b/spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zip
Binary files differ
diff --git a/spec/fixtures/safe_zip/invalid-symlinks-outside.zip b/spec/fixtures/safe_zip/invalid-symlinks-outside.zip
new file mode 100644
index 00000000000..c184a1dafe2
--- /dev/null
+++ b/spec/fixtures/safe_zip/invalid-symlinks-outside.zip
Binary files differ
diff --git a/spec/fixtures/safe_zip/valid-non-writeable.zip b/spec/fixtures/safe_zip/valid-non-writeable.zip
new file mode 100644
index 00000000000..69f175d8504
--- /dev/null
+++ b/spec/fixtures/safe_zip/valid-non-writeable.zip
Binary files differ
diff --git a/spec/fixtures/safe_zip/valid-simple.zip b/spec/fixtures/safe_zip/valid-simple.zip
new file mode 100644
index 00000000000..a56b8b41dcc
--- /dev/null
+++ b/spec/fixtures/safe_zip/valid-simple.zip
Binary files differ
diff --git a/spec/fixtures/safe_zip/valid-symlinks-first.zip b/spec/fixtures/safe_zip/valid-symlinks-first.zip
new file mode 100644
index 00000000000..f5952ef71c9
--- /dev/null
+++ b/spec/fixtures/safe_zip/valid-symlinks-first.zip
Binary files differ
diff --git a/spec/fixtures/sentry/list_projects_sample_response.json b/spec/fixtures/sentry/list_projects_sample_response.json
new file mode 100644
index 00000000000..fd79b0d0f30
--- /dev/null
+++ b/spec/fixtures/sentry/list_projects_sample_response.json
@@ -0,0 +1,81 @@
+[
+ {
+ "status": "active",
+ "features": [
+ "data-forwarding",
+ "rate-limits",
+ "releases"
+ ],
+ "color": "#5c3fbf",
+ "isInternal": false,
+ "isPublic": false,
+ "dateCreated": "2018-12-11T10:41:22.476Z",
+ "id": "2",
+ "slug": "sentry-example",
+ "name": "sentry-example",
+ "hasAccess": true,
+ "isBookmarked": false,
+ "platform": "node",
+ "firstEvent": "2018-12-12T15:07:18Z",
+ "avatar": {
+ "avatarUuid": null,
+ "avatarType": "letter_avatar"
+ },
+ "isMember": true,
+ "organization": {
+ "status": {
+ "id": "active",
+ "name": "active"
+ },
+ "require2FA": false,
+ "avatar": {
+ "avatarUuid": null,
+ "avatarType": "letter_avatar"
+ },
+ "name": "Sentry",
+ "dateCreated": "2018-12-11T10:21:47.431Z",
+ "id": "1",
+ "isEarlyAdopter": false,
+ "slug": "sentry"
+ }
+ },
+ {
+ "status": "active",
+ "features": [
+ "data-forwarding",
+ "rate-limits"
+ ],
+ "color": "#bf873f",
+ "isInternal": true,
+ "isPublic": false,
+ "dateCreated": "2018-12-11T10:21:47.440Z",
+ "id": "1",
+ "slug": "internal",
+ "name": "Internal",
+ "hasAccess": true,
+ "isBookmarked": false,
+ "platform": null,
+ "firstEvent": "2018-12-11T10:54:35Z",
+ "avatar": {
+ "avatarUuid": null,
+ "avatarType": "letter_avatar"
+ },
+ "isMember": true,
+ "organization": {
+ "status": {
+ "id": "active",
+ "name": "active"
+ },
+ "require2FA": false,
+ "avatar": {
+ "avatarUuid": null,
+ "avatarType": "letter_avatar"
+ },
+ "name": "Sentry",
+ "dateCreated": "2018-12-11T10:21:47.431Z",
+ "id": "1",
+ "isEarlyAdopter": false,
+ "slug": "sentry"
+ }
+ }
+]
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index ca90673521c..1a54ab540fc 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -32,6 +32,26 @@ describe Resolvers::IssuesResolver do
expect(resolve_issues).to contain_exactly(issue, issue2)
end
+
+ it 'finds a specific issue with iids' do
+ expect(resolve_issues(iids: issue.iid)).to contain_exactly(issue)
+ end
+
+ it 'finds multiple issues with iids' do
+ expect(resolve_issues(iids: [issue.iid, issue2.iid]))
+ .to contain_exactly(issue, issue2)
+ end
+
+ it 'finds only the issues within the project we are looking at' do
+ another_project = create(:project)
+ iids = [issue, issue2].map(&:iid)
+
+ iids.each do |iid|
+ create(:issue, project: another_project, iid: iid)
+ end
+
+ expect(resolve_issues(iids: iids)).to contain_exactly(issue, issue2)
+ end
end
def resolve_issues(args = {}, context = { current_user: current_user })
diff --git a/spec/helpers/auto_devops_helper_spec.rb b/spec/helpers/auto_devops_helper_spec.rb
index 75c30dbfe48..223e562238d 100644
--- a/spec/helpers/auto_devops_helper_spec.rb
+++ b/spec/helpers/auto_devops_helper_spec.rb
@@ -90,39 +90,4 @@ describe AutoDevopsHelper do
it { is_expected.to eq(false) }
end
end
-
- describe '.auto_devops_warning_message' do
- subject { helper.auto_devops_warning_message(project) }
-
- context 'when the service is missing' do
- before do
- allow(helper).to receive(:missing_auto_devops_service?).and_return(true)
- end
-
- context 'when the domain is missing' do
- before do
- allow(helper).to receive(:missing_auto_devops_domain?).and_return(true)
- end
-
- it { is_expected.to match(/Auto Review Apps and Auto Deploy need a domain name and a .* to work correctly./) }
- end
-
- context 'when the domain is not missing' do
- before do
- allow(helper).to receive(:missing_auto_devops_domain?).and_return(false)
- end
-
- it { is_expected.to match(/Auto Review Apps and Auto Deploy need a .* to work correctly./) }
- end
- end
-
- context 'when the domain is missing' do
- before do
- allow(helper).to receive(:missing_auto_devops_service?).and_return(false)
- allow(helper).to receive(:missing_auto_devops_domain?).and_return(true)
- end
-
- it { is_expected.to eq('Auto Review Apps and Auto Deploy need a domain name to work correctly.') }
- end
- end
end
diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb
index 3820cf5cb9d..23d7e41803e 100644
--- a/spec/helpers/emails_helper_spec.rb
+++ b/spec/helpers/emails_helper_spec.rb
@@ -1,6 +1,20 @@
require 'spec_helper'
describe EmailsHelper do
+ describe 'sanitize_name' do
+ context 'when name contains a valid URL string' do
+ it 'returns name with `.` replaced with `_` to prevent mail clients from auto-linking URLs' do
+ expect(sanitize_name('https://about.gitlab.com')).to eq('https://about_gitlab_com')
+ expect(sanitize_name('www.gitlab.com')).to eq('www_gitlab_com')
+ expect(sanitize_name('//about.gitlab.com/handbook/security/#best-practices')).to eq('//about_gitlab_com/handbook/security/#best-practices')
+ end
+
+ it 'returns name as it is when it does not contain a URL' do
+ expect(sanitize_name('Foo Bar')).to eq('Foo Bar')
+ end
+ end
+ end
+
describe 'password_reset_token_valid_time' do
def validate_time_string(time_limit, expected_string)
Devise.reset_password_within = time_limit
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 03e3a72a82f..8b82dea2524 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -188,8 +188,8 @@ describe IssuablesHelper do
issuableRef: "##{issue.iid}",
markdownPreviewPath: "/#{@project.full_path}/preview_markdown",
markdownDocsPath: '/help/user/markdown',
- markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION,
issuableTemplates: [],
+ lockVersion: issue.lock_version,
projectPath: @project.path,
projectNamespace: @project.namespace.path,
initialTitleHtml: issue.title,
diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb
index a0c0af94fa5..c3956ba08fd 100644
--- a/spec/helpers/markup_helper_spec.rb
+++ b/spec/helpers/markup_helper_spec.rb
@@ -212,17 +212,6 @@ describe MarkupHelper do
helper.render_wiki_content(@wiki)
end
- it 'uses Wiki pipeline for markdown files with RedCarpet if feature disabled' do
- stub_feature_flags(commonmark_for_repositories: false)
- allow(@wiki).to receive(:format).and_return(:markdown)
-
- expect(helper).to receive(:markdown_unsafe).with('wiki content',
- pipeline: :wiki, project: project, project_wiki: @wiki, page_slug: "nested/page",
- issuable_state_filter_enabled: true, markdown_engine: :redcarpet)
-
- helper.render_wiki_content(@wiki)
- end
-
it "uses Asciidoctor for asciidoc files" do
allow(@wiki).to receive(:format).and_return(:asciidoc)
@@ -273,16 +262,6 @@ describe MarkupHelper do
it 'defaults to CommonMark' do
expect(helper.markup('foo.md', 'x^2')).to include('x^2')
end
-
- it 'honors markdown_engine for RedCarpet' do
- expect(helper.markup('foo.md', 'x^2', { markdown_engine: :redcarpet })).to include('x<sup>2</sup>')
- end
-
- it 'uses RedCarpet if feature disabled' do
- stub_feature_flags(commonmark_for_repositories: false)
-
- expect(helper.markup('foo.md', 'x^2', { markdown_engine: :redcarpet })).to include('x<sup>2</sup>')
- end
end
describe '#first_line_in_markdown' do
diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb
index 4590904c93d..908e8960f37 100644
--- a/spec/helpers/members_helper_spec.rb
+++ b/spec/helpers/members_helper_spec.rb
@@ -16,7 +16,7 @@ describe MembersHelper do
it { expect(remove_member_message(project_member_invite)).to eq "Are you sure you want to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.full_name} project?" }
it { expect(remove_member_message(project_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{project.full_name} project?" }
it { expect(remove_member_message(project_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{project.full_name} project?" }
- it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group?" }
+ it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group and any subresources?" }
it { expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group?" }
it { expect(remove_member_message(group_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{group.name} group?" }
it { expect(remove_member_message(group_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{group.name} group?" }
@@ -33,7 +33,7 @@ describe MembersHelper do
it { expect(remove_member_title(project_member)).to eq 'Remove user from project' }
it { expect(remove_member_title(project_member_request)).to eq 'Deny access request from project' }
- it { expect(remove_member_title(group_member)).to eq 'Remove user from group' }
+ it { expect(remove_member_title(group_member)).to eq 'Remove user from group and any subresources' }
it { expect(remove_member_title(group_member_request)).to eq 'Deny access request from group' }
end
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index c112c8ed633..4c395248644 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -35,6 +35,30 @@ describe PreferencesHelper do
end
end
+ describe '#first_day_of_week_choices' do
+ it 'returns Sunday and Monday as choices' do
+ expect(helper.first_day_of_week_choices).to eq [
+ ['Sunday', 0],
+ ['Monday', 1]
+ ]
+ end
+ end
+
+ describe '#first_day_of_week_choices_with_default' do
+ it 'returns choices including system default' do
+ expect(helper.first_day_of_week_choices_with_default).to eq [
+ ['System default (Sunday)', nil], ['Sunday', 0], ['Monday', 1]
+ ]
+ end
+
+ it 'returns choices including system default set to Monday' do
+ stub_application_setting(first_day_of_week: 1)
+ expect(helper.first_day_of_week_choices_with_default).to eq [
+ ['System default (Monday)', nil], ['Sunday', 0], ['Monday', 1]
+ ]
+ end
+ end
+
describe '#user_application_theme' do
context 'with a user' do
it "returns user's theme's css_class" do
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 88b5d87f087..49895b0680b 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -354,8 +354,35 @@ describe ProjectsHelper do
allow(project).to receive(:builds_enabled?).and_return(false)
end
- it "do not include pipelines tab" do
- is_expected.not_to include(:pipelines)
+ context 'when user has access to builds' do
+ it "does include pipelines tab" do
+ is_expected.to include(:pipelines)
+ end
+ end
+
+ context 'when user does not have access to builds' do
+ before do
+ allow(helper).to receive(:can?) { false }
+ end
+
+ it "does not include pipelines tab" do
+ is_expected.not_to include(:pipelines)
+ end
+ end
+ end
+
+ context 'when project has external wiki' do
+ it 'includes external wiki tab' do
+ project.create_external_wiki_service(active: true, properties: { 'external_wiki_url' => 'https://gitlab.com' })
+
+ is_expected.to include(:external_wiki)
+ end
+ end
+
+ context 'when project does not have external wiki' do
+ it 'does not include external wiki tab' do
+ expect(project.external_wiki).to be_nil
+ is_expected.not_to include(:external_wiki)
end
end
end
@@ -508,18 +535,6 @@ describe ProjectsHelper do
end
end
- describe '#legacy_render_context' do
- it 'returns the redcarpet engine' do
- params = { legacy_render: '1' }
-
- expect(helper.legacy_render_context(params)).to include(markdown_engine: :redcarpet)
- end
-
- it 'returns nothing' do
- expect(helper.legacy_render_context({})).to be_empty
- end
- end
-
describe '#explore_projects_tab?' do
subject { helper.explore_projects_tab? }
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index 34d9115a1f6..f3649495493 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -51,7 +51,7 @@ describe UsersHelper do
false | 'mockRegexPattern' | { user_internal_regex_pattern: nil, user_internal_regex_options: nil }
true | nil | { user_internal_regex_pattern: nil, user_internal_regex_options: nil }
true | '' | { user_internal_regex_pattern: nil, user_internal_regex_options: nil }
- true | 'mockRegexPattern' | { user_internal_regex_pattern: 'mockRegexPattern', user_internal_regex_options: 'gi' }
+ true | 'mockRegexPattern' | { user_internal_regex_pattern: 'mockRegexPattern', user_internal_regex_options: 'i' }
end
with_them do
@@ -100,4 +100,72 @@ describe UsersHelper do
end
end
end
+
+ describe '#user_badges_in_admin_section' do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'with a blocked user' do
+ it "returns the blocked badge" do
+ blocked_user = create(:user, state: 'blocked')
+
+ badges = helper.user_badges_in_admin_section(blocked_user)
+
+ expect(badges).to eq([text: "Blocked", variant: "danger"])
+ end
+ end
+
+ context 'with an admin user' do
+ it "returns the admin badge" do
+ admin_user = create(:admin)
+
+ badges = helper.user_badges_in_admin_section(admin_user)
+
+ expect(badges).to eq([text: "Admin", variant: "success"])
+ end
+ end
+
+ context 'with an external user' do
+ it 'returns the external badge' do
+ external_user = create(:user, external: true)
+
+ badges = helper.user_badges_in_admin_section(external_user)
+
+ expect(badges).to eq([text: "External", variant: "secondary"])
+ end
+ end
+
+ context 'with the current user' do
+ it 'returns the "It\'s You" badge' do
+ badges = helper.user_badges_in_admin_section(user)
+
+ expect(badges).to eq([text: "It's you!", variant: nil])
+ end
+ end
+
+ context 'with an external blocked admin' do
+ it 'returns the blocked, admin and external badges' do
+ user = create(:admin, state: 'blocked', external: true)
+
+ badges = helper.user_badges_in_admin_section(user)
+
+ expect(badges).to eq([
+ { text: "Blocked", variant: "danger" },
+ { text: "Admin", variant: "success" },
+ { text: "External", variant: "secondary" }
+ ])
+ end
+ end
+
+ context 'get badges for normal user' do
+ it 'returns no badges' do
+ user = create(:user)
+
+ badges = helper.user_badges_in_admin_section(user)
+
+ expect(badges).to be_empty
+ end
+ end
+ end
end
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js
index 9d55c615450..1e9470970ff 100644
--- a/spec/javascripts/api_spec.js
+++ b/spec/javascripts/api_spec.js
@@ -49,6 +49,22 @@ describe('Api', () => {
});
});
+ describe('groupMembers', () => {
+ it('fetches group members', done => {
+ const groupId = '54321';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`;
+ const expectedData = [{ id: 7 }];
+ mock.onGet(expectedUrl).reply(200, expectedData);
+
+ Api.groupMembers(groupId)
+ .then(({ data }) => {
+ expect(data).toEqual(expectedData);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
describe('groups', () => {
it('fetches groups', done => {
const query = 'dummy query';
diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js
index 6179a02ce16..ca849f75860 100644
--- a/spec/javascripts/behaviors/copy_as_gfm_spec.js
+++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js
@@ -1,4 +1,4 @@
-import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
+import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
describe('CopyAsGFM', () => {
describe('CopyAsGFM.pasteGFM', () => {
@@ -79,27 +79,46 @@ describe('CopyAsGFM', () => {
return clipboardData;
};
+ beforeAll(done => {
+ initCopyAsGFM();
+
+ // Fake call to nodeToGfm so the import of lazy bundle happened
+ CopyAsGFM.nodeToGFM(document.createElement('div'))
+ .then(() => {
+ done();
+ })
+ .catch(done.fail);
+ });
+
beforeEach(() => spyOn(clipboardData, 'setData'));
describe('list handling', () => {
- it('uses correct gfm for unordered lists', () => {
+ it('uses correct gfm for unordered lists', done => {
const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'UL');
+
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
- const expectedGFM = '* List Item1\n\n* List Item2';
+ setTimeout(() => {
+ const expectedGFM = '* List Item1\n\n* List Item2';
- expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ done();
+ });
});
- it('uses correct gfm for ordered lists', () => {
+ it('uses correct gfm for ordered lists', done => {
const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'OL');
+
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
- const expectedGFM = '1. List Item1\n\n1. List Item2';
+ setTimeout(() => {
+ const expectedGFM = '1. List Item1\n\n1. List Item2';
- expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js
index fe827bb1e18..4843a0386b5 100644
--- a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js
@@ -3,17 +3,26 @@
*/
import $ from 'jquery';
-import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm';
+import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
-initCopyAsGFM();
-
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
describe('ShortcutsIssuable', function() {
const fixtureName = 'snippets/show.html.raw';
preloadFixtures(fixtureName);
+ beforeAll(done => {
+ initCopyAsGFM();
+
+ // Fake call to nodeToGfm so the import of lazy bundle happened
+ CopyAsGFM.nodeToGFM(document.createElement('div'))
+ .then(() => {
+ done();
+ })
+ .catch(done.fail);
+ });
+
beforeEach(() => {
loadFixtures(fixtureName);
$('body').append(
@@ -63,17 +72,22 @@ describe('ShortcutsIssuable', function() {
stubSelection('<p>Selected text.</p>');
});
- it('leaves existing input intact', () => {
+ it('leaves existing input intact', done => {
$(FORM_SELECTOR).val('This text was already here.');
expect($(FORM_SELECTOR).val()).toBe('This text was already here.');
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('This text was already here.\n\n> Selected text.\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe(
+ 'This text was already here.\n\n> Selected text.\n\n',
+ );
+ done();
+ });
});
- it('triggers `input`', () => {
+ it('triggers `input`', done => {
let triggered = false;
$(FORM_SELECTOR).on('input', () => {
triggered = true;
@@ -81,36 +95,48 @@ describe('ShortcutsIssuable', function() {
ShortcutsIssuable.replyWithSelectedText(true);
- expect(triggered).toBe(true);
+ setTimeout(() => {
+ expect(triggered).toBe(true);
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
});
describe('with a one-line selection', () => {
- it('quotes the selection', () => {
+ it('quotes the selection', done => {
stubSelection('<p>This text has been selected.</p>');
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n');
+ done();
+ });
});
});
describe('with a multi-line selection', () => {
- it('quotes the selected lines as a group', () => {
+ it('quotes the selected lines as a group', done => {
stubSelection(
'<p>Selected line one.</p>\n<p>Selected line two.</p>\n<p>Selected line three.</p>',
);
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe(
- '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n',
- );
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe(
+ '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n',
+ );
+ done();
+ });
});
});
@@ -119,17 +145,23 @@ describe('ShortcutsIssuable', function() {
stubSelection('<p>Selected text.</p>', true);
});
- it('does not add anything to the input', () => {
+ it('does not add anything to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
});
@@ -138,20 +170,26 @@ describe('ShortcutsIssuable', function() {
stubSelection('<div class="md">Selected text.</div><p>Invalid selected text.</p>', true);
});
- it('only adds the valid part to the input', () => {
+ it('only adds the valid part to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
- it('triggers `input`', () => {
+ it('triggers `input`', done => {
let triggered = false;
$(FORM_SELECTOR).on('input', () => {
triggered = true;
@@ -159,7 +197,10 @@ describe('ShortcutsIssuable', function() {
ShortcutsIssuable.replyWithSelectedText(true);
- expect(triggered).toBe(true);
+ setTimeout(() => {
+ expect(triggered).toBe(true);
+ done();
+ });
});
});
@@ -183,20 +224,26 @@ describe('ShortcutsIssuable', function() {
});
});
- it('adds the quoted selection to the input', () => {
+ it('adds the quoted selection to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
- it('triggers `input`', () => {
+ it('triggers `input`', done => {
let triggered = false;
$(FORM_SELECTOR).on('input', () => {
triggered = true;
@@ -204,7 +251,10 @@ describe('ShortcutsIssuable', function() {
ShortcutsIssuable.replyWithSelectedText(true);
- expect(triggered).toBe(true);
+ setTimeout(() => {
+ expect(triggered).toBe(true);
+ done();
+ });
});
});
@@ -228,17 +278,23 @@ describe('ShortcutsIssuable', function() {
});
});
- it('does not add anything to the input', () => {
+ it('does not add anything to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js
index 880b469284b..7928feeadfa 100644
--- a/spec/javascripts/clusters/clusters_bundle_spec.js
+++ b/spec/javascripts/clusters/clusters_bundle_spec.js
@@ -1,10 +1,5 @@
import Clusters from '~/clusters/clusters_bundle';
-import {
- REQUEST_LOADING,
- REQUEST_SUCCESS,
- REQUEST_FAILURE,
- APPLICATION_STATUS,
-} from '~/clusters/constants';
+import { REQUEST_SUBMITTED, REQUEST_FAILURE, APPLICATION_STATUS } from '~/clusters/constants';
import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
describe('Clusters', () => {
@@ -196,67 +191,43 @@ describe('Clusters', () => {
});
describe('installApplication', () => {
- it('tries to install helm', done => {
+ it('tries to install helm', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
cluster.installApplication({ id: 'helm' });
- expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined);
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
- it('tries to install ingress', done => {
+ it('tries to install ingress', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null);
cluster.installApplication({ id: 'ingress' });
- expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined);
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
- it('tries to install runner', done => {
+ it('tries to install runner', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.runner.requestStatus).toEqual(null);
cluster.installApplication({ id: 'runner' });
- expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined);
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
- it('tries to install jupyter', done => {
+ it('tries to install jupyter', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null);
@@ -265,19 +236,11 @@ describe('Clusters', () => {
params: { hostname: cluster.store.state.applications.jupyter.hostname },
});
- expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', {
hostname: cluster.store.state.applications.jupyter.hostname,
});
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
it('sets error request status when the request fails', done => {
@@ -289,7 +252,7 @@ describe('Clusters', () => {
cluster.installApplication({ id: 'helm' });
- expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalled();
diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js
index 45d56514930..8cb9713964e 100644
--- a/spec/javascripts/clusters/components/application_row_spec.js
+++ b/spec/javascripts/clusters/components/application_row_spec.js
@@ -1,11 +1,6 @@
import Vue from 'vue';
import eventHub from '~/clusters/event_hub';
-import {
- APPLICATION_STATUS,
- REQUEST_LOADING,
- REQUEST_SUCCESS,
- REQUEST_FAILURE,
-} from '~/clusters/constants';
+import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
@@ -57,6 +52,12 @@ describe('Application Row', () => {
expect(vm.installButtonLabel).toBeUndefined();
});
+ it('has install button', () => {
+ const installationBtn = vm.$el.querySelector('.js-cluster-application-install-button');
+
+ expect(installationBtn).not.toBe(null);
+ });
+
it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
@@ -101,6 +102,18 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(true);
});
+ it('has loading "Installing" when REQUEST_SUBMITTED', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.INSTALLABLE,
+ requestStatus: REQUEST_SUBMITTED,
+ });
+
+ expect(vm.installButtonLabel).toEqual('Installing');
+ expect(vm.installButtonLoading).toEqual(true);
+ expect(vm.installButtonDisabled).toEqual(true);
+ });
+
it('has disabled "Installed" when APPLICATION_STATUS.INSTALLED', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
@@ -134,30 +147,6 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(false);
});
- it('has loading "Install" when REQUEST_LOADING', () => {
- vm = mountComponent(ApplicationRow, {
- ...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_STATUS.INSTALLABLE,
- requestStatus: REQUEST_LOADING,
- });
-
- expect(vm.installButtonLabel).toEqual('Install');
- expect(vm.installButtonLoading).toEqual(true);
- expect(vm.installButtonDisabled).toEqual(true);
- });
-
- it('has disabled "Install" when REQUEST_SUCCESS', () => {
- vm = mountComponent(ApplicationRow, {
- ...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_STATUS.INSTALLABLE,
- requestStatus: REQUEST_SUCCESS,
- });
-
- expect(vm.installButtonLabel).toEqual('Install');
- expect(vm.installButtonLoading).toEqual(false);
- expect(vm.installButtonDisabled).toEqual(true);
- });
-
it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
@@ -219,6 +208,144 @@ describe('Application Row', () => {
});
});
+ describe('Upgrade button', () => {
+ it('has indeterminate state on page load', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: null,
+ });
+ const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
+
+ expect(upgradeBtn).toBe(null);
+ });
+
+ it('has enabled "Upgrade" when "upgradeAvailable" is true', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ upgradeAvailable: true,
+ });
+ const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
+
+ expect(upgradeBtn).not.toBe(null);
+ expect(upgradeBtn.innerHTML).toContain('Upgrade');
+ });
+
+ it('has enabled "Retry upgrade" when APPLICATION_STATUS.UPDATE_ERRORED', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.UPDATE_ERRORED,
+ });
+ const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
+
+ expect(upgradeBtn).not.toBe(null);
+ expect(vm.upgradeFailed).toBe(true);
+ expect(upgradeBtn.innerHTML).toContain('Retry upgrade');
+ });
+
+ it('has disabled "Retry upgrade" when APPLICATION_STATUS.UPDATING', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.UPDATING,
+ });
+ const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
+
+ expect(upgradeBtn).not.toBe(null);
+ expect(vm.isUpgrading).toBe(true);
+ expect(upgradeBtn.innerHTML).toContain('Upgrading');
+ });
+
+ it('clicking upgrade button emits event', () => {
+ spyOn(eventHub, '$emit');
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.UPDATE_ERRORED,
+ });
+ const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
+
+ upgradeBtn.click();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('upgradeApplication', {
+ id: DEFAULT_APPLICATION_STATE.id,
+ params: {},
+ });
+ });
+
+ it('clicking disabled upgrade button emits nothing', () => {
+ spyOn(eventHub, '$emit');
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.UPDATING,
+ });
+ const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
+
+ upgradeBtn.click();
+
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ });
+
+ it('displays an error message if application upgrade failed', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ title: 'GitLab Runner',
+ status: APPLICATION_STATUS.UPDATE_ERRORED,
+ });
+ const failureMessage = vm.$el.querySelector(
+ '.js-cluster-application-upgrade-failure-message',
+ );
+
+ expect(failureMessage).not.toBe(null);
+ expect(failureMessage.innerHTML).toContain(
+ 'Something went wrong when upgrading GitLab Runner. Please check the logs and try again.',
+ );
+ });
+ });
+
+ describe('Version', () => {
+ it('displays a version number if application has been upgraded', () => {
+ const version = '0.1.45';
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.UPDATED,
+ version,
+ });
+ const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details');
+ const versionEl = vm.$el.querySelector('.js-cluster-application-upgrade-version');
+
+ expect(upgradeDetails.innerHTML).toContain('Upgraded');
+ expect(versionEl).not.toBe(null);
+ expect(versionEl.innerHTML).toContain(version);
+ });
+
+ it('contains a link to the chart repo if application has been upgraded', () => {
+ const version = '0.1.45';
+ const chartRepo = 'https://gitlab.com/charts/gitlab-runner';
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.UPDATED,
+ chartRepo,
+ version,
+ });
+ const versionEl = vm.$el.querySelector('.js-cluster-application-upgrade-version');
+
+ expect(versionEl.href).toEqual(chartRepo);
+ expect(versionEl.target).toEqual('_blank');
+ });
+
+ it('does not display a version number if application upgrade failed', () => {
+ const version = '0.1.45';
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.UPDATE_ERRORED,
+ version,
+ });
+ const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details');
+ const versionEl = vm.$el.querySelector('.js-cluster-application-upgrade-version');
+
+ expect(upgradeDetails.innerHTML).toContain('failed');
+ expect(versionEl).toBe(null);
+ });
+ });
+
describe('Error block', () => {
it('does not show error block when there is no error', () => {
vm = mountComponent(ApplicationRow, {
diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js
index dfce2656e4c..37a4d6614f6 100644
--- a/spec/javascripts/clusters/stores/clusters_store_spec.js
+++ b/spec/javascripts/clusters/stores/clusters_store_spec.js
@@ -85,6 +85,9 @@ describe('Clusters Store', () => {
statusReason: mockResponseData.applications[2].status_reason,
requestStatus: null,
requestReason: null,
+ version: mockResponseData.applications[2].version,
+ upgradeAvailable: mockResponseData.applications[2].update_available,
+ chartRepo: 'https://gitlab.com/charts/gitlab-runner',
},
prometheus: {
title: 'Prometheus',
diff --git a/spec/javascripts/diffs/components/compare_versions_spec.js b/spec/javascripts/diffs/components/compare_versions_spec.js
index 2f0385454d7..e886f962d2f 100644
--- a/spec/javascripts/diffs/components/compare_versions_spec.js
+++ b/spec/javascripts/diffs/components/compare_versions_spec.js
@@ -10,6 +10,10 @@ describe('CompareVersions', () => {
const targetBranch = { branchName: 'tmp-wine-dev', versionIndex: -1 };
beforeEach(() => {
+ store.state.diffs.addedLines = 10;
+ store.state.diffs.removedLines = 20;
+ store.state.diffs.diffFiles.push('test');
+
vm = createComponentWithStore(Vue.extend(CompareVersionsComponent), store, {
mergeRequestDiffs: diffsMockData,
mergeRequestDiff: diffsMockData[0],
diff --git a/spec/javascripts/diffs/components/diff_file_header_spec.js b/spec/javascripts/diffs/components/diff_file_header_spec.js
index b77907ff26f..787a81fd88f 100644
--- a/spec/javascripts/diffs/components/diff_file_header_spec.js
+++ b/spec/javascripts/diffs/components/diff_file_header_spec.js
@@ -24,6 +24,10 @@ describe('diff_file_header', () => {
beforeEach(() => {
const diffFile = diffDiscussionMock.diff_file;
+
+ diffFile.added_lines = 2;
+ diffFile.removed_lines = 1;
+
props = {
diffFile: { ...diffFile },
canCurrentUserFork: false,
diff --git a/spec/javascripts/diffs/components/diff_stats_spec.js b/spec/javascripts/diffs/components/diff_stats_spec.js
new file mode 100644
index 00000000000..984b3026209
--- /dev/null
+++ b/spec/javascripts/diffs/components/diff_stats_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import DiffStats from '~/diffs/components/diff_stats.vue';
+
+describe('diff_stats', () => {
+ it('does not render a group if diffFileLengths is not passed in', () => {
+ const wrapper = shallowMount(DiffStats, {
+ propsData: {
+ addedLines: 1,
+ removedLines: 2,
+ },
+ });
+ const groups = wrapper.findAll('.diff-stats-group');
+
+ expect(groups.length).toBe(2);
+ });
+
+ it('shows amount of files changed, lines added and lines removed when passed all props', () => {
+ const wrapper = shallowMount(DiffStats, {
+ propsData: {
+ addedLines: 100,
+ removedLines: 200,
+ diffFilesLength: 300,
+ },
+ });
+ const additions = wrapper.find('icon-stub[name="file-addition"]').element.parentNode;
+ const deletions = wrapper.find('icon-stub[name="file-deletion"]').element.parentNode;
+ const filesChanged = wrapper.find('icon-stub[name="doc-code"]').element.parentNode;
+
+ expect(additions.textContent).toContain('100');
+ expect(deletions.textContent).toContain('200');
+ expect(filesChanged.textContent).toContain('300');
+ });
+});
diff --git a/spec/javascripts/diffs/components/tree_list_spec.js b/spec/javascripts/diffs/components/tree_list_spec.js
index 08b0b4f9e45..9e556698f34 100644
--- a/spec/javascripts/diffs/components/tree_list_spec.js
+++ b/spec/javascripts/diffs/components/tree_list_spec.js
@@ -35,12 +35,6 @@ describe('Diffs tree list component', () => {
vm.$destroy();
});
- it('renders diff stats', () => {
- expect(vm.$el.textContent).toContain('1 changed file');
- expect(vm.$el.textContent).toContain('10 additions');
- expect(vm.$el.textContent).toContain('20 deletions');
- });
-
it('renders empty text', () => {
expect(vm.$el.textContent).toContain('No files found');
});
@@ -83,17 +77,6 @@ describe('Diffs tree list component', () => {
expect(vm.$el.querySelectorAll('.file-row')[1].textContent).toContain('app');
});
- it('filters tree list to blobs matching search', done => {
- vm.search = 'app/index';
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelectorAll('.file-row').length).toBe(1);
- expect(vm.$el.querySelectorAll('.file-row')[0].textContent).toContain('index.js');
-
- done();
- });
- });
-
it('calls toggleTreeOpen when clicking folder', () => {
spyOn(vm.$store, 'dispatch').and.stub();
@@ -130,14 +113,4 @@ describe('Diffs tree list component', () => {
});
});
});
-
- describe('clearSearch', () => {
- it('resets search', () => {
- vm.search = 'test';
-
- vm.$el.querySelector('.tree-list-clear-icon').click();
-
- expect(vm.search).toBe('');
- });
- });
});
diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js
index 190ca1230ca..4f69dc92ab8 100644
--- a/spec/javascripts/diffs/store/getters_spec.js
+++ b/spec/javascripts/diffs/store/getters_spec.js
@@ -242,7 +242,11 @@ describe('Diffs Module Getters', () => {
},
};
- expect(getters.allBlobs(localState)).toEqual([
+ expect(
+ getters.allBlobs(localState, {
+ flatBlobsList: getters.flatBlobsList(localState),
+ }),
+ ).toEqual([
{
isHeader: true,
path: '/',
diff --git a/spec/javascripts/environments/environment_item_spec.js b/spec/javascripts/environments/environment_item_spec.js
index 7618c2f50ce..a89e50045da 100644
--- a/spec/javascripts/environments/environment_item_spec.js
+++ b/spec/javascripts/environments/environment_item_spec.js
@@ -25,7 +25,6 @@ describe('Environment item', () => {
component = new EnvironmentItem({
propsData: {
model: mockItem,
- canCreateDeployment: false,
canReadEnvironment: true,
service: {},
},
@@ -117,7 +116,6 @@ describe('Environment item', () => {
component = new EnvironmentItem({
propsData: {
model: environment,
- canCreateDeployment: true,
canReadEnvironment: true,
service: {},
},
diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js
index 0e5e50a59a5..52895f35f3a 100644
--- a/spec/javascripts/environments/environment_table_spec.js
+++ b/spec/javascripts/environments/environment_table_spec.js
@@ -26,7 +26,6 @@ describe('Environment table', () => {
vm = mountComponent(Component, {
environments: [mockItem],
- canCreateDeployment: false,
canReadEnvironment: true,
});
diff --git a/spec/javascripts/environments/environments_app_spec.js b/spec/javascripts/environments/environments_app_spec.js
index e2d81eb454a..9220f7a264f 100644
--- a/spec/javascripts/environments/environments_app_spec.js
+++ b/spec/javascripts/environments/environments_app_spec.js
@@ -9,7 +9,6 @@ describe('Environment', () => {
const mockData = {
endpoint: 'environments.json',
canCreateEnvironment: true,
- canCreateDeployment: true,
canReadEnvironment: true,
cssContainerClass: 'container',
newEnvironmentPath: 'environments/new',
diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js b/spec/javascripts/environments/folder/environments_folder_view_spec.js
index 7f0a9475d5f..d9ee7e74e28 100644
--- a/spec/javascripts/environments/folder/environments_folder_view_spec.js
+++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js
@@ -13,7 +13,6 @@ describe('Environments Folder View', () => {
const mockData = {
endpoint: 'environments.json',
folderName: 'review',
- canCreateDeployment: true,
canReadEnvironment: true,
cssContainerClass: 'container',
};
diff --git a/spec/javascripts/helpers/vue_test_utils_helper.js b/spec/javascripts/helpers/vue_test_utils_helper.js
new file mode 100644
index 00000000000..19e27388eeb
--- /dev/null
+++ b/spec/javascripts/helpers/vue_test_utils_helper.js
@@ -0,0 +1,19 @@
+/* eslint-disable import/prefer-default-export */
+
+const vNodeContainsText = (vnode, text) =>
+ (vnode.text && vnode.text.includes(text)) ||
+ (vnode.children && vnode.children.filter(child => vNodeContainsText(child, text)).length);
+
+/**
+ * Determines whether a `shallowMount` Wrapper contains text
+ * within one of it's slots. This will also work on Wrappers
+ * acquired with `find()`, but only if it's parent Wrapper
+ * was shallowMounted.
+ * NOTE: Prefer checking the rendered output of a component
+ * wherever possible using something like `text()` instead.
+ * @param {Wrapper} shallowWrapper - Vue test utils wrapper (shallowMounted)
+ * @param {String} slotName
+ * @param {String} text
+ */
+export const shallowWrapperContainsSlotText = (shallowWrapper, slotName, text) =>
+ !!shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length;
diff --git a/spec/javascripts/helpers/vue_test_utils_helper_spec.js b/spec/javascripts/helpers/vue_test_utils_helper_spec.js
new file mode 100644
index 00000000000..41714066da5
--- /dev/null
+++ b/spec/javascripts/helpers/vue_test_utils_helper_spec.js
@@ -0,0 +1,48 @@
+import { shallowMount } from '@vue/test-utils';
+import { shallowWrapperContainsSlotText } from './vue_test_utils_helper';
+
+describe('Vue test utils helpers', () => {
+ describe('shallowWrapperContainsSlotText', () => {
+ const mockText = 'text';
+ const mockSlot = `<div>${mockText}</div>`;
+ let mockComponent;
+
+ beforeEach(() => {
+ mockComponent = shallowMount(
+ {
+ render(h) {
+ h(`<div>mockedComponent</div>`);
+ },
+ },
+ {
+ slots: {
+ default: mockText,
+ namedSlot: mockSlot,
+ },
+ },
+ );
+ });
+
+ it('finds text within shallowWrapper default slot', () => {
+ expect(shallowWrapperContainsSlotText(mockComponent, 'default', mockText)).toBe(true);
+ });
+
+ it('finds text within shallowWrapper named slot', () => {
+ expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', mockText)).toBe(true);
+ });
+
+ it('returns false when text is not present', () => {
+ const searchText = 'absent';
+
+ expect(shallowWrapperContainsSlotText(mockComponent, 'default', searchText)).toBe(false);
+ expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', searchText)).toBe(false);
+ });
+
+ it('searches with case-sensitivity', () => {
+ const searchText = mockText.toUpperCase();
+
+ expect(shallowWrapperContainsSlotText(mockComponent, 'default', searchText)).toBe(false);
+ expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', searchText)).toBe(false);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js
index 55f40be0e4e..dc5790f6562 100644
--- a/spec/javascripts/ide/components/ide_spec.js
+++ b/spec/javascripts/ide/components/ide_spec.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import Mousetrap from 'mousetrap';
import store from '~/ide/stores';
import ide from '~/ide/components/ide.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
@@ -72,73 +71,6 @@ describe('ide component', () => {
});
});
- describe('file finder', () => {
- beforeEach(done => {
- spyOn(vm, 'toggleFileFinder');
-
- vm.$store.state.fileFindVisible = true;
-
- vm.$nextTick(done);
- });
-
- it('calls toggleFileFinder on `t` key press', done => {
- Mousetrap.trigger('t');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('calls toggleFileFinder on `command+p` key press', done => {
- Mousetrap.trigger('command+p');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('calls toggleFileFinder on `ctrl+p` key press', done => {
- Mousetrap.trigger('ctrl+p');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('always allows `command+p` to trigger toggleFileFinder', () => {
- expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'),
- ).toBe(false);
- });
-
- it('always allows `ctrl+p` to trigger toggleFileFinder', () => {
- expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'),
- ).toBe(false);
- });
-
- it('onlys handles `t` when focused in input-field', () => {
- expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
- ).toBe(true);
- });
-
- it('stops callback in monaco editor', () => {
- setFixtures('<div class="inputarea"></div>');
-
- expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true);
- });
- });
-
it('shows error message when set', done => {
expect(vm.$el.querySelector('.flash-container')).toBe(null);
diff --git a/spec/javascripts/ide/components/ide_status_bar_spec.js b/spec/javascripts/ide/components/ide_status_bar_spec.js
index ab032b4cb98..bb8fb74c068 100644
--- a/spec/javascripts/ide/components/ide_status_bar_spec.js
+++ b/spec/javascripts/ide/components/ide_status_bar_spec.js
@@ -76,6 +76,9 @@ describe('ideStatusBar', () => {
icon: 'status_success',
},
},
+ commit: {
+ author_gravatar_url: 'www',
+ },
});
vm.$nextTick()
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 2bd1b3996dc..0ccf771c7ef 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -18,9 +18,13 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0;
let vm;
- document.body.innerHTML = '<span id="task_status"></span>';
-
beforeEach(done => {
+ setFixtures(`
+ <div>
+ <div class="flash-container"></div>
+ <span id="task_status"></span>
+ </div>
+ `);
spyOn(eventHub, '$emit');
const IssuableDescriptionComponent = Vue.extend(issuableApp);
@@ -43,6 +47,7 @@ describe('Issuable output', () => {
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
+ lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
@@ -78,6 +83,7 @@ describe('Issuable output', () => {
expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(1);
})
.then(() => {
vm.poll.makeRequest();
@@ -95,6 +101,7 @@ describe('Issuable output', () => {
expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(2);
})
.then(done)
.catch(done.fail);
@@ -137,21 +144,17 @@ describe('Issuable output', () => {
describe('updateIssuable', () => {
it('fetches new data after update', done => {
+ spyOn(vm, 'updateStoreState').and.callThrough();
spyOn(vm.service, 'getData').and.callThrough();
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- confidential: false,
- web_url: window.location.pathname,
- },
- });
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: { web_url: window.location.pathname },
+ }),
);
vm.updateIssuable()
.then(() => {
+ expect(vm.updateStoreState).toHaveBeenCalled();
expect(vm.service.getData).toHaveBeenCalled();
})
.then(done)
@@ -159,11 +162,10 @@ describe('Issuable output', () => {
});
it('correctly updates issuable data', done => {
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve();
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: { web_url: window.location.pathname },
+ }),
);
vm.updateIssuable()
@@ -177,16 +179,13 @@ describe('Issuable output', () => {
it('does not redirect if issue has not moved', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: window.location.pathname,
- confidential: vm.isConfidential,
- },
- });
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: window.location.pathname,
+ confidential: vm.isConfidential,
+ },
+ }),
);
vm.updateIssuable();
@@ -199,16 +198,13 @@ describe('Issuable output', () => {
it('redirects if returned web_url has changed', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: '/testing-issue-move',
- confidential: vm.isConfidential,
- },
- });
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: '/testing-issue-move',
+ confidential: vm.isConfidential,
+ },
+ }),
);
vm.updateIssuable();
@@ -227,6 +223,7 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => {
expect(e.returnValue).not.toBeNull();
+
done();
});
});
@@ -238,6 +235,7 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => {
expect(e.returnValue).not.toBeNull();
+
done();
});
});
@@ -247,49 +245,61 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => {
expect(e.returnValue).toBeNull();
+
done();
});
});
});
describe('error when updating', () => {
- beforeEach(() => {
- spyOn(window, 'Flash').and.callThrough();
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise((resolve, reject) => {
- reject();
- }),
- );
- });
-
it('closes form on error', done => {
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
vm.updateIssuable();
setTimeout(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
-
- expect(window.Flash).toHaveBeenCalledWith('Error updating issue');
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating issue`,
+ );
done();
});
});
it('returns the correct error message for issuableType', done => {
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
vm.issuableType = 'merge request';
Vue.nextTick(() => {
vm.updateIssuable();
setTimeout(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
-
- expect(window.Flash).toHaveBeenCalledWith('Error updating merge request');
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating merge request`,
+ );
done();
});
});
});
+
+ it('shows error mesage from backend if exists', done => {
+ const msg = 'Custom error message from backend';
+ spyOn(vm.service, 'updateIssuable').and.callFake(
+ // eslint-disable-next-line prefer-promise-reject-errors
+ () => Promise.reject({ response: { data: { errors: [msg] } } }),
+ );
+
+ vm.updateIssuable();
+ setTimeout(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `${vm.defaultErrorMessage}. ${msg}`,
+ );
+
+ done();
+ });
+ });
});
});
@@ -342,21 +352,19 @@ describe('Issuable output', () => {
describe('deleteIssuable', () => {
it('changes URL when deleted', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'deleteIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: '/test',
- },
- });
- }),
+ spyOn(vm.service, 'deleteIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: '/test',
+ },
+ }),
);
vm.deleteIssuable();
setTimeout(() => {
expect(visitUrl).toHaveBeenCalledWith('/test');
+
done();
});
});
@@ -364,40 +372,33 @@ describe('Issuable output', () => {
it('stops polling when deleting', done => {
spyOnDependency(issuableApp, 'visitUrl');
spyOn(vm.poll, 'stop').and.callThrough();
- spyOn(vm.service, 'deleteIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: '/test',
- },
- });
- }),
+ spyOn(vm.service, 'deleteIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: '/test',
+ },
+ }),
);
vm.deleteIssuable();
setTimeout(() => {
expect(vm.poll.stop).toHaveBeenCalledWith();
+
done();
});
});
it('closes form on error', done => {
- spyOn(window, 'Flash').and.callThrough();
- spyOn(vm.service, 'deleteIssuable').and.callFake(
- () =>
- new Promise((resolve, reject) => {
- reject();
- }),
- );
+ spyOn(vm.service, 'deleteIssuable').and.returnValue(Promise.reject());
vm.deleteIssuable();
setTimeout(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
-
- expect(window.Flash).toHaveBeenCalledWith('Error deleting issue');
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error deleting issue',
+ );
done();
});
@@ -420,6 +421,7 @@ describe('Issuable output', () => {
.then(vm.$nextTick)
.then(() => {
expect(vm.formState.lockedWarningVisible).toEqual(true);
+ expect(vm.formState.lock_version).toEqual(1);
expect(vm.$el.querySelector('.alert')).not.toBeNull();
})
.then(done)
@@ -438,4 +440,34 @@ describe('Issuable output', () => {
expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
});
});
+
+ describe('updateStoreState', () => {
+ it('should make a request and update the state of the store', done => {
+ const data = { foo: 1 };
+ spyOn(vm.store, 'updateState');
+ spyOn(vm.service, 'getData').and.returnValue(Promise.resolve({ data }));
+
+ vm.updateStoreState()
+ .then(() => {
+ expect(vm.service.getData).toHaveBeenCalled();
+ expect(vm.store.updateState).toHaveBeenCalledWith(data);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should show error message if store update fails', done => {
+ spyOn(vm.service, 'getData').and.returnValue(Promise.reject());
+ vm.issuableType = 'merge request';
+
+ vm.updateStoreState()
+ .then(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating ${vm.issuableType}`,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js
index 463f3c89926..2eeed6770be 100644
--- a/spec/javascripts/issue_show/components/description_spec.js
+++ b/spec/javascripts/issue_show/components/description_spec.js
@@ -21,7 +21,8 @@ describe('Description component', () => {
if (!document.querySelector('.issuable-meta')) {
const metaData = document.createElement('div');
metaData.classList.add('issuable-meta');
- metaData.innerHTML = '<span id="task_status"></span><span id="task_status_short"></span>';
+ metaData.innerHTML =
+ '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>';
document.body.appendChild(metaData);
}
@@ -33,6 +34,10 @@ describe('Description component', () => {
vm.$destroy();
});
+ afterAll(() => {
+ $('.issuable-meta .flash-container').remove();
+ });
+
it('animates description changes', done => {
vm.descriptionHtml = 'changed';
@@ -123,7 +128,10 @@ describe('Description component', () => {
fieldName: 'description',
selector: '.detail-page-description',
onSuccess: jasmine.any(Function),
+ onError: jasmine.any(Function),
+ lockVersion: 0,
});
+
done();
});
});
@@ -184,4 +192,17 @@ describe('Description component', () => {
it('sets data-update-url', () => {
expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST);
});
+
+ describe('taskListUpdateError', () => {
+ it('should create flash notification and emit an event to parent', () => {
+ const msg =
+ 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.';
+ spyOn(vm, '$emit');
+
+ vm.taskListUpdateError();
+
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
+ expect(vm.$emit).toHaveBeenCalledWith('taskListUpdateFailed');
+ });
+ });
});
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
index 74b3efb014b..f4475aadb8b 100644
--- a/spec/javascripts/issue_show/mock_data.js
+++ b/spec/javascripts/issue_show/mock_data.js
@@ -8,6 +8,7 @@ export default {
updated_at: '2015-05-15T12:31:04.428Z',
updated_by_name: 'Some User',
updated_by_path: '/some_user',
+ lock_version: 1,
},
secondRequest: {
title: '<p>2</p>',
@@ -18,5 +19,6 @@ export default {
updated_at: '2016-05-15T12:31:04.428Z',
updated_by_name: 'Other User',
updated_by_path: '/other_user',
+ lock_version: 2,
},
};
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index 121c4040212..3eff3f655ee 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -232,6 +232,21 @@ describe('common_utils', () => {
});
});
+ describe('debounceByAnimationFrame', () => {
+ it('debounces a function to allow a maximum of one call per animation frame', done => {
+ const spy = jasmine.createSpy('spy');
+ const debouncedSpy = commonUtils.debounceByAnimationFrame(spy);
+ window.requestAnimationFrame(() => {
+ debouncedSpy();
+ debouncedSpy();
+ window.requestAnimationFrame(() => {
+ expect(spy).toHaveBeenCalledTimes(1);
+ done();
+ });
+ });
+ });
+ });
+
describe('getParameterByName', () => {
beforeEach(() => {
window.history.pushState({}, null, '?scope=all&p=2');
@@ -680,51 +695,131 @@ describe('common_utils', () => {
});
});
- describe('deep: true', () => {
- it('converts object with child objects', () => {
- const obj = {
- snake_key: {
- child_snake_key: 'value',
- },
- };
+ describe('with options', () => {
+ const objWithoutChildren = {
+ project_name: 'GitLab CE',
+ group_name: 'GitLab.org',
+ license_type: 'MIT',
+ };
- expect(commonUtils.convertObjectPropsToCamelCase(obj, { deep: true })).toEqual({
- snakeKey: {
- childSnakeKey: 'value',
- },
- });
- });
+ const objWithChildren = {
+ project_name: 'GitLab CE',
+ group_name: 'GitLab.org',
+ license_type: 'MIT',
+ tech_stack: {
+ backend: 'Ruby',
+ frontend_framework: 'Vue',
+ database: 'PostgreSQL',
+ },
+ };
- it('converts array with child objects', () => {
- const arr = [
- {
- child_snake_key: 'value',
- },
- ];
-
- expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([
- {
- childSnakeKey: 'value',
- },
- ]);
- });
+ describe('when options.deep is true', () => {
+ it('converts object with child objects', () => {
+ const obj = {
+ snake_key: {
+ child_snake_key: 'value',
+ },
+ };
- it('converts array with child arrays', () => {
- const arr = [
- [
+ expect(commonUtils.convertObjectPropsToCamelCase(obj, { deep: true })).toEqual({
+ snakeKey: {
+ childSnakeKey: 'value',
+ },
+ });
+ });
+
+ it('converts array with child objects', () => {
+ const arr = [
{
child_snake_key: 'value',
},
- ],
- ];
+ ];
- expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([
- [
+ expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([
{
childSnakeKey: 'value',
},
- ],
- ]);
+ ]);
+ });
+
+ it('converts array with child arrays', () => {
+ const arr = [
+ [
+ {
+ child_snake_key: 'value',
+ },
+ ],
+ ];
+
+ expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([
+ [
+ {
+ childSnakeKey: 'value',
+ },
+ ],
+ ]);
+ });
+ });
+
+ describe('when options.dropKeys is provided', () => {
+ it('discards properties mentioned in `dropKeys` array', () => {
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(objWithoutChildren, {
+ dropKeys: ['group_name'],
+ }),
+ ).toEqual({
+ projectName: 'GitLab CE',
+ licenseType: 'MIT',
+ });
+ });
+
+ it('discards properties mentioned in `dropKeys` array when `deep` is true', () => {
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(objWithChildren, {
+ deep: true,
+ dropKeys: ['group_name', 'database'],
+ }),
+ ).toEqual({
+ projectName: 'GitLab CE',
+ licenseType: 'MIT',
+ techStack: {
+ backend: 'Ruby',
+ frontendFramework: 'Vue',
+ },
+ });
+ });
+ });
+
+ describe('when options.ignoreKeyNames is provided', () => {
+ it('leaves properties mentioned in `ignoreKeyNames` array intact', () => {
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(objWithoutChildren, {
+ ignoreKeyNames: ['group_name'],
+ }),
+ ).toEqual({
+ projectName: 'GitLab CE',
+ licenseType: 'MIT',
+ group_name: 'GitLab.org',
+ });
+ });
+
+ it('leaves properties mentioned in `ignoreKeyNames` array intact when `deep` is true', () => {
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(objWithChildren, {
+ deep: true,
+ ignoreKeyNames: ['group_name', 'frontend_framework'],
+ }),
+ ).toEqual({
+ projectName: 'GitLab CE',
+ group_name: 'GitLab.org',
+ licenseType: 'MIT',
+ techStack: {
+ backend: 'Ruby',
+ frontend_framework: 'Vue',
+ database: 'PostgreSQL',
+ },
+ });
+ });
});
});
});
diff --git a/spec/javascripts/lib/utils/file_upload_spec.js b/spec/javascripts/lib/utils/file_upload_spec.js
index 92c9cc70aaf..8f7092f63de 100644
--- a/spec/javascripts/lib/utils/file_upload_spec.js
+++ b/spec/javascripts/lib/utils/file_upload_spec.js
@@ -9,28 +9,56 @@ describe('File upload', () => {
<span class="js-filename"></span>
</form>
`);
+ });
+
+ describe('when there is a matching button and input', () => {
+ beforeEach(() => {
+ fileUpload('.js-button', '.js-input');
+ });
+
+ it('clicks file input after clicking button', () => {
+ const btn = document.querySelector('.js-button');
+ const input = document.querySelector('.js-input');
+
+ spyOn(input, 'click');
+
+ btn.click();
+
+ expect(input.click).toHaveBeenCalled();
+ });
+
+ it('updates file name text', () => {
+ const input = document.querySelector('.js-input');
- fileUpload('.js-button', '.js-input');
+ input.value = 'path/to/file/index.js';
+
+ input.dispatchEvent(new CustomEvent('change'));
+
+ expect(document.querySelector('.js-filename').textContent).toEqual('index.js');
+ });
});
- it('clicks file input after clicking button', () => {
- const btn = document.querySelector('.js-button');
+ it('fails gracefully when there is no matching button', () => {
const input = document.querySelector('.js-input');
+ const btn = document.querySelector('.js-button');
+ fileUpload('.js-not-button', '.js-input');
spyOn(input, 'click');
btn.click();
- expect(input.click).toHaveBeenCalled();
+ expect(input.click).not.toHaveBeenCalled();
});
- it('updates file name text', () => {
+ it('fails gracefully when there is no matching input', () => {
const input = document.querySelector('.js-input');
+ const btn = document.querySelector('.js-button');
+ fileUpload('.js-button', '.js-not-input');
- input.value = 'path/to/file/index.js';
+ spyOn(input, 'click');
- input.dispatchEvent(new CustomEvent('change'));
+ btn.click();
- expect(document.querySelector('.js-filename').textContent).toEqual('index.js');
+ expect(input.click).not.toHaveBeenCalled();
});
});
diff --git a/spec/javascripts/lib/utils/grammar_spec.js b/spec/javascripts/lib/utils/grammar_spec.js
new file mode 100644
index 00000000000..377b2ffb48c
--- /dev/null
+++ b/spec/javascripts/lib/utils/grammar_spec.js
@@ -0,0 +1,35 @@
+import * as grammar from '~/lib/utils/grammar';
+
+describe('utils/grammar', () => {
+ describe('toNounSeriesText', () => {
+ it('with empty items returns empty string', () => {
+ expect(grammar.toNounSeriesText([])).toBe('');
+ });
+
+ it('with single item returns item', () => {
+ const items = ['Lorem Ipsum'];
+
+ expect(grammar.toNounSeriesText(items)).toBe(items[0]);
+ });
+
+ it('with 2 items returns item1 and item2', () => {
+ const items = ['Dolar', 'Sit Amit'];
+
+ expect(grammar.toNounSeriesText(items)).toBe(`${items[0]} and ${items[1]}`);
+ });
+
+ it('with 3 items returns comma separated series', () => {
+ const items = ['Lorem', 'Ipsum', 'dolar'];
+ const expected = 'Lorem, Ipsum, and dolar';
+
+ expect(grammar.toNounSeriesText(items)).toBe(expected);
+ });
+
+ it('with 6 items returns comma separated series', () => {
+ const items = ['Lorem', 'ipsum', 'dolar', 'sit', 'amit', 'consectetur'];
+ const expected = 'Lorem, ipsum, dolar, sit, amit, and consectetur';
+
+ expect(grammar.toNounSeriesText(items)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/icon_utils_spec.js b/spec/javascripts/lib/utils/icon_utils_spec.js
new file mode 100644
index 00000000000..3fd3940efe8
--- /dev/null
+++ b/spec/javascripts/lib/utils/icon_utils_spec.js
@@ -0,0 +1,67 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import * as iconUtils from '~/lib/utils/icon_utils';
+
+describe('Icon utils', () => {
+ describe('getSvgIconPathContent', () => {
+ let spriteIcons;
+
+ beforeAll(() => {
+ spriteIcons = gon.sprite_icons;
+ gon.sprite_icons = 'mockSpriteIconsEndpoint';
+ });
+
+ afterAll(() => {
+ gon.sprite_icons = spriteIcons;
+ });
+
+ let axiosMock;
+ let mockEndpoint;
+ let getIcon;
+ const mockName = 'mockIconName';
+ const mockPath = 'mockPath';
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ mockEndpoint = axiosMock.onGet(gon.sprite_icons);
+ getIcon = iconUtils.getSvgIconPathContent(mockName);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
+ it('extracts svg icon path content from sprite icons', done => {
+ mockEndpoint.replyOnce(
+ 200,
+ `<svg><symbol id="${mockName}"><path d="${mockPath}"/></symbol></svg>`,
+ );
+ getIcon
+ .then(path => {
+ expect(path).toBe(mockPath);
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns null if icon path content does not exist', done => {
+ mockEndpoint.replyOnce(200, ``);
+ getIcon
+ .then(path => {
+ expect(path).toBe(null);
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns null if an http error occurs', done => {
+ mockEndpoint.replyOnce(500);
+ getIcon
+ .then(path => {
+ expect(path).toBe(null);
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js
index 1cb49b49ca7..ab809930804 100644
--- a/spec/javascripts/merge_request_spec.js
+++ b/spec/javascripts/merge_request_spec.js
@@ -40,17 +40,51 @@ describe('MergeRequest', function() {
expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
});
- it('submits an ajax request on tasklist:changed', done => {
- $('.js-task-list-field').trigger('tasklist:changed');
-
- setTimeout(() => {
- expect(axios.patch).toHaveBeenCalledWith(
- `${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`,
- {
- merge_request: { description: '- [ ] Task List Item' },
- },
- );
- done();
+ describe('tasklist', () => {
+ const lineNumber = 8;
+ const lineSource = '- [ ] item 8';
+ const index = 3;
+ const checked = true;
+
+ it('submits an ajax request on tasklist:changed', done => {
+ $('.js-task-list-field').trigger({
+ type: 'tasklist:changed',
+ detail: { lineNumber, lineSource, index, checked },
+ });
+
+ setTimeout(() => {
+ expect(axios.patch).toHaveBeenCalledWith(
+ `${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`,
+ {
+ merge_request: {
+ description: '- [ ] Task List Item',
+ lock_version: undefined,
+ update_task: { line_number: lineNumber, line_source: lineSource, index, checked },
+ },
+ },
+ );
+
+ done();
+ });
+ });
+
+ it('shows an error notification when tasklist update failed', done => {
+ mock
+ .onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`)
+ .reply(409, {});
+
+ $('.js-task-list-field').trigger({
+ type: 'tasklist:changed',
+ detail: { lineNumber, lineSource, index, checked },
+ });
+
+ setTimeout(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.',
+ );
+
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/monitoring/charts/area_spec.js b/spec/javascripts/monitoring/charts/area_spec.js
new file mode 100644
index 00000000000..0b36fc9f5f7
--- /dev/null
+++ b/spec/javascripts/monitoring/charts/area_spec.js
@@ -0,0 +1,220 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { shallowWrapperContainsSlotText } from 'spec/helpers/vue_test_utils_helper';
+import Area from '~/monitoring/components/charts/area.vue';
+import MonitoringStore from '~/monitoring/stores/monitoring_store';
+import MonitoringMock, { deploymentData } from '../mock_data';
+
+describe('Area component', () => {
+ const mockWidgets = 'mockWidgets';
+ let mockGraphData;
+ let areaChart;
+ let spriteSpy;
+
+ beforeEach(() => {
+ const store = new MonitoringStore();
+ store.storeMetrics(MonitoringMock.data);
+ store.storeDeploymentData(deploymentData);
+
+ [mockGraphData] = store.groups[0].metrics;
+
+ areaChart = shallowMount(Area, {
+ propsData: {
+ graphData: mockGraphData,
+ containerWidth: 0,
+ deploymentData: store.deploymentData,
+ },
+ slots: {
+ default: mockWidgets,
+ },
+ });
+
+ spriteSpy = spyOnDependency(Area, 'getSvgIconPathContent').and.callFake(
+ () => new Promise(resolve => resolve()),
+ );
+ });
+
+ afterEach(() => {
+ areaChart.destroy();
+ });
+
+ it('renders chart title', () => {
+ expect(areaChart.find({ ref: 'graphTitle' }).text()).toBe(mockGraphData.title);
+ });
+
+ it('contains graph widgets from slot', () => {
+ expect(areaChart.find({ ref: 'graphWidgets' }).text()).toBe(mockWidgets);
+ });
+
+ describe('wrapped components', () => {
+ describe('GitLab UI area chart', () => {
+ let glAreaChart;
+
+ beforeEach(() => {
+ glAreaChart = areaChart.find(GlAreaChart);
+ });
+
+ it('is a Vue instance', () => {
+ expect(glAreaChart.isVueInstance()).toBe(true);
+ });
+
+ it('receives data properties needed for proper chart render', () => {
+ const props = glAreaChart.props();
+
+ expect(props.data).toBe(areaChart.vm.chartData);
+ expect(props.option).toBe(areaChart.vm.chartOptions);
+ expect(props.formatTooltipText).toBe(areaChart.vm.formatTooltipText);
+ expect(props.thresholds).toBe(areaChart.props('alertData'));
+ });
+
+ it('recieves a tooltip title', () => {
+ const mockTitle = 'mockTitle';
+ areaChart.vm.tooltip.title = mockTitle;
+
+ expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipTitle', mockTitle)).toBe(true);
+ });
+
+ it('recieves tooltip content', () => {
+ const mockContent = 'mockContent';
+ areaChart.vm.tooltip.content = mockContent;
+
+ expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipContent', mockContent)).toBe(
+ true,
+ );
+ });
+
+ describe('when tooltip is showing deployment data', () => {
+ beforeEach(() => {
+ areaChart.vm.tooltip.isDeployment = true;
+ });
+
+ it('uses deployment title', () => {
+ expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipTitle', 'Deployed')).toBe(
+ true,
+ );
+ });
+
+ it('renders commit sha in tooltip content', () => {
+ const mockSha = 'mockSha';
+ areaChart.vm.tooltip.sha = mockSha;
+
+ expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipContent', mockSha)).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('formatTooltipText', () => {
+ const mockDate = deploymentData[0].created_at;
+ const generateSeriesData = type => ({
+ seriesData: [
+ {
+ componentSubType: type,
+ value: [mockDate, 5.55555],
+ },
+ ],
+ value: mockDate,
+ });
+
+ describe('series is of line type', () => {
+ beforeEach(() => {
+ areaChart.vm.formatTooltipText(generateSeriesData('line'));
+ });
+
+ it('formats tooltip title', () => {
+ expect(areaChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM');
+ });
+
+ it('formats tooltip content', () => {
+ expect(areaChart.vm.tooltip.content).toBe('CPU (Cores) 5.556');
+ });
+ });
+
+ describe('series is of scatter type', () => {
+ beforeEach(() => {
+ areaChart.vm.formatTooltipText(generateSeriesData('scatter'));
+ });
+
+ it('formats tooltip title', () => {
+ expect(areaChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM');
+ });
+
+ it('formats tooltip sha', () => {
+ expect(areaChart.vm.tooltip.sha).toBe('f5bcd1d9');
+ });
+ });
+ });
+
+ describe('getScatterSymbol', () => {
+ beforeEach(() => {
+ areaChart.vm.getScatterSymbol();
+ });
+
+ it('gets rocket svg path content for use as deployment data symbol', () => {
+ expect(spriteSpy).toHaveBeenCalledWith('rocket');
+ });
+ });
+
+ describe('onResize', () => {
+ const mockWidth = 233;
+ const mockHeight = 144;
+
+ beforeEach(() => {
+ spyOn(Element.prototype, 'getBoundingClientRect').and.callFake(() => ({
+ width: mockWidth,
+ height: mockHeight,
+ }));
+ areaChart.vm.onResize();
+ });
+
+ it('sets area chart width', () => {
+ expect(areaChart.vm.width).toBe(mockWidth);
+ });
+
+ it('sets area chart height', () => {
+ expect(areaChart.vm.height).toBe(mockHeight);
+ });
+ });
+ });
+
+ describe('computed', () => {
+ describe('chartData', () => {
+ it('utilizes all data points', () => {
+ expect(Object.keys(areaChart.vm.chartData)).toEqual(['Cores']);
+ expect(areaChart.vm.chartData.Cores.length).toBe(297);
+ });
+
+ it('creates valid data', () => {
+ const data = areaChart.vm.chartData.Cores;
+
+ expect(
+ data.filter(([time, value]) => new Date(time).getTime() > 0 && typeof value === 'number')
+ .length,
+ ).toBe(data.length);
+ });
+ });
+
+ describe('scatterSeries', () => {
+ it('utilizes deployment data', () => {
+ expect(areaChart.vm.scatterSeries.data).toEqual([
+ ['2017-05-31T21:23:37.881Z', 0],
+ ['2017-05-30T20:08:04.629Z', 0],
+ ['2017-05-30T17:42:38.409Z', 0],
+ ]);
+ });
+ });
+
+ describe('xAxisLabel', () => {
+ it('constructs a label for the chart x-axis', () => {
+ expect(areaChart.vm.xAxisLabel).toBe('Core Usage');
+ });
+ });
+
+ describe('yAxisLabel', () => {
+ it('constructs a label for the chart y-axis', () => {
+ expect(areaChart.vm.yAxisLabel).toBe('CPU (Cores)');
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js
index 565b87de248..b1778029a77 100644
--- a/spec/javascripts/monitoring/dashboard_spec.js
+++ b/spec/javascripts/monitoring/dashboard_spec.js
@@ -25,15 +25,22 @@ export default propsData;
describe('Dashboard', () => {
let DashboardComponent;
+ let mock;
beforeEach(() => {
setFixtures(`
<div class="prometheus-graphs"></div>
- <div class="nav-sidebar"></div>
+ <div class="layout-page"></div>
`);
+
+ mock = new MockAdapter(axios);
DashboardComponent = Vue.extend(Dashboard);
});
+ afterEach(() => {
+ mock.restore();
+ });
+
describe('no metrics are available yet', () => {
it('shows a getting started empty state when no metrics are present', () => {
const component = new DashboardComponent({
@@ -47,16 +54,10 @@ describe('Dashboard', () => {
});
describe('requests information to the server', () => {
- let mock;
beforeEach(() => {
- mock = new MockAdapter(axios);
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
});
- afterEach(() => {
- mock.restore();
- });
-
it('shows up a loading state', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
@@ -152,28 +153,25 @@ describe('Dashboard', () => {
});
describe('when the window resizes', () => {
- let mock;
beforeEach(() => {
- mock = new MockAdapter(axios);
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
jasmine.clock().install();
});
afterEach(() => {
- mock.restore();
jasmine.clock().uninstall();
});
- it('rerenders the dashboard when the sidebar is resized', done => {
+ it('sets elWidth to page width when the sidebar is resized', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
propsData: { ...propsData, hasMetrics: true, showPanels: false },
});
- expect(component.forceRedraw).toEqual(0);
+ expect(component.elWidth).toEqual(0);
- const navSidebarEl = document.querySelector('.nav-sidebar');
- navSidebarEl.classList.add('nav-sidebar-collapsed');
+ const pageLayoutEl = document.querySelector('.layout-page');
+ pageLayoutEl.classList.add('page-with-icon-sidebar');
Vue.nextTick()
.then(() => {
@@ -181,7 +179,7 @@ describe('Dashboard', () => {
return Vue.nextTick();
})
.then(() => {
- expect(component.forceRedraw).toEqual(component.elWidth);
+ expect(component.elWidth).toEqual(pageLayoutEl.clientWidth);
done();
})
.catch(done.fail);
diff --git a/spec/javascripts/monitoring/graph/axis_spec.js b/spec/javascripts/monitoring/graph/axis_spec.js
deleted file mode 100644
index c7adba00637..00000000000
--- a/spec/javascripts/monitoring/graph/axis_spec.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import Vue from 'vue';
-import GraphAxis from '~/monitoring/components/graph/axis.vue';
-import measurements from '~/monitoring/utils/measurements';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphAxis);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const defaultValuesComponent = {
- graphWidth: 500,
- graphHeight: 300,
- graphHeightOffset: 120,
- margin: measurements.large.margin,
- measurements: measurements.large,
- yAxisLabel: 'Values',
- unitOfDisplay: 'MB',
-};
-
-function getTextFromNode(component, selector) {
- return component.$el.querySelector(selector).firstChild.nodeValue.trim();
-}
-
-describe('Axis', () => {
- describe('Computed props', () => {
- it('textTransform', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.textTransform).toContain('translate(15, 120) rotate(-90)');
- });
-
- it('xPosition', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.xPosition).toEqual(180);
- });
-
- it('yPosition', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.yPosition).toEqual(240);
- });
-
- it('rectTransform', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)');
- });
- });
-
- it('has 2 rect-axis-text rect svg elements', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2);
- });
-
- it('contains text to signal the usage, title and time with multiple time series', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(getTextFromNode(component, '.y-label-text')).toEqual('Values (MB)');
- });
-});
diff --git a/spec/javascripts/monitoring/graph/deployment_spec.js b/spec/javascripts/monitoring/graph/deployment_spec.js
deleted file mode 100644
index 7d39c4345d2..00000000000
--- a/spec/javascripts/monitoring/graph/deployment_spec.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import Vue from 'vue';
-import GraphDeployment from '~/monitoring/components/graph/deployment.vue';
-import { deploymentData } from '../mock_data';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphDeployment);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-describe('MonitoringDeployment', () => {
- describe('Methods', () => {
- it('should contain a hidden gradient', () => {
- const component = createComponent({
- showDeployInfo: true,
- deploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull();
- });
-
- it('transformDeploymentGroup translates an available deployment', () => {
- const component = createComponent({
- showDeployInfo: false,
- deploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(component.transformDeploymentGroup({ xPos: 16 })).toContain('translate(11, 20)');
- });
-
- describe('Computed props', () => {
- it('calculatedHeight', () => {
- const component = createComponent({
- showDeployInfo: true,
- deploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(component.calculatedHeight).toEqual(180);
- });
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/flag_spec.js b/spec/javascripts/monitoring/graph/flag_spec.js
deleted file mode 100644
index 038bfffd44f..00000000000
--- a/spec/javascripts/monitoring/graph/flag_spec.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import Vue from 'vue';
-import GraphFlag from '~/monitoring/components/graph/flag.vue';
-import { deploymentData } from '../mock_data';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphFlag);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const defaultValuesComponent = {
- currentXCoordinate: 200,
- currentYCoordinate: 100,
- currentFlagPosition: 100,
- currentData: {
- time: new Date('2017-06-04T18:17:33.501Z'),
- value: '1.49609375',
- },
- graphHeight: 300,
- graphHeightOffset: 120,
- showFlagContent: true,
- realPixelRatio: 1,
- timeSeries: [
- {
- values: [
- {
- time: new Date('2017-06-04T18:17:33.501Z'),
- value: '1.49609375',
- },
- ],
- },
- ],
- unitOfDisplay: 'ms',
- currentDataIndex: 0,
- legendTitle: 'Average',
- currentCoordinates: {},
-};
-
-const deploymentFlagData = {
- ...deploymentData[0],
- ref: deploymentData[0].ref.name,
- xPos: 10,
- time: new Date(deploymentData[0].created_at),
-};
-
-describe('GraphFlag', () => {
- let component;
-
- it('has a line at the currentXCoordinate', () => {
- component = createComponent(defaultValuesComponent);
-
- expect(component.$el.style.left).toEqual(`${70 + component.currentXCoordinate}px`);
- });
-
- describe('Deployment flag', () => {
- it('shows a deployment flag when deployment data provided', () => {
- const deploymentFlagComponent = createComponent({
- ...defaultValuesComponent,
- deploymentFlagData,
- });
-
- expect(deploymentFlagComponent.$el.querySelector('.popover-title')).toContainText('Deployed');
- });
-
- it('contains the ref when a tag is available', () => {
- const deploymentFlagComponent = createComponent({
- ...defaultValuesComponent,
- deploymentFlagData: {
- ...deploymentFlagData,
- sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
- tag: true,
- ref: '1.0',
- },
- });
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
- 'f5bcd1d9',
- );
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
- '1.0',
- );
- });
-
- it('does not contain the ref when a tag is unavailable', () => {
- const deploymentFlagComponent = createComponent({
- ...defaultValuesComponent,
- deploymentFlagData: {
- ...deploymentFlagData,
- sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
- tag: false,
- ref: '1.0',
- },
- });
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
- 'f5bcd1d9',
- );
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).not.toContainText(
- '1.0',
- );
- });
- });
-
- describe('Computed props', () => {
- beforeEach(() => {
- component = createComponent(defaultValuesComponent);
- });
-
- it('formatTime', () => {
- expect(component.formatTime).toMatch(/\d:17PM/);
- });
-
- it('formatDate', () => {
- expect(component.formatDate).toEqual('04 Jun 2017, ');
- });
-
- it('cursorStyle', () => {
- expect(component.cursorStyle).toEqual({
- top: '20px',
- left: '270px',
- height: '180px',
- });
- });
-
- it('flagOrientation', () => {
- expect(component.flagOrientation).toEqual('left');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/legend_spec.js b/spec/javascripts/monitoring/graph/legend_spec.js
deleted file mode 100644
index 9209e77dcf4..00000000000
--- a/spec/javascripts/monitoring/graph/legend_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import Vue from 'vue';
-import GraphLegend from '~/monitoring/components/graph/legend.vue';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-
-const defaultValuesComponent = {};
-
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
-
-defaultValuesComponent.timeSeries = timeSeries;
-
-describe('Legend Component', () => {
- let vm;
- let Legend;
-
- beforeEach(() => {
- Legend = Vue.extend(GraphLegend);
- });
-
- describe('View', () => {
- beforeEach(() => {
- vm = mountComponent(Legend, {
- legendTitle: 'legend',
- timeSeries,
- currentDataIndex: 0,
- unitOfDisplay: 'Req/Sec',
- });
- });
-
- it('should render the usage, title and time with multiple time series', () => {
- const titles = vm.$el.querySelectorAll('.legend-metric-title');
-
- expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1);
- expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1);
- });
-
- it('should container the same number of rows in the table as time series', () => {
- expect(vm.$el.querySelectorAll('.prometheus-table tr').length).toEqual(vm.timeSeries.length);
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/track_info_spec.js b/spec/javascripts/monitoring/graph/track_info_spec.js
deleted file mode 100644
index ce93ae28842..00000000000
--- a/spec/javascripts/monitoring/graph/track_info_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import Vue from 'vue';
-import TrackInfo from '~/monitoring/components/graph/track_info.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
-
-describe('TrackInfo component', () => {
- let vm;
- let Component;
-
- beforeEach(() => {
- Component = Vue.extend(TrackInfo);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('Computed props', () => {
- beforeEach(() => {
- vm = mountComponent(Component, { track: timeSeries[0] });
- });
-
- it('summaryMetrics', () => {
- expect(vm.summaryMetrics).toEqual('Avg: 0.000 · Max: 0.000');
- });
- });
-
- describe('Rendered output', () => {
- beforeEach(() => {
- vm = mountComponent(Component, { track: timeSeries[0] });
- });
-
- it('contains metric tag and the summary metrics', () => {
- const metricTag = vm.$el.querySelector('strong');
-
- expect(metricTag.textContent.trim()).toEqual(vm.track.metricTag);
- expect(vm.$el.textContent).toContain('Avg: 0.000 · Max: 0.000');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/track_line_spec.js b/spec/javascripts/monitoring/graph/track_line_spec.js
deleted file mode 100644
index 2a4f89ddf6e..00000000000
--- a/spec/javascripts/monitoring/graph/track_line_spec.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import Vue from 'vue';
-import TrackLine from '~/monitoring/components/graph/track_line.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
-
-describe('TrackLine component', () => {
- let vm;
- let Component;
-
- beforeEach(() => {
- Component = Vue.extend(TrackLine);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('Computed props', () => {
- it('stylizedLine for dashed lineStyles', () => {
- vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dashed' } });
-
- expect(vm.stylizedLine).toEqual('6, 3');
- });
-
- it('stylizedLine for dotted lineStyles', () => {
- vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dotted' } });
-
- expect(vm.stylizedLine).toEqual('3, 3');
- });
- });
-
- describe('Rendered output', () => {
- it('has an svg with a line', () => {
- vm = mountComponent(Component, { track: { ...timeSeries[0] } });
- const svgEl = vm.$el.querySelector('svg');
- const lineEl = vm.$el.querySelector('svg line');
-
- expect(svgEl.getAttribute('width')).toEqual('16');
- expect(svgEl.getAttribute('height')).toEqual('8');
-
- expect(lineEl.getAttribute('stroke-width')).toEqual('4');
- expect(lineEl.getAttribute('x1')).toEqual('0');
- expect(lineEl.getAttribute('x2')).toEqual('16');
- expect(lineEl.getAttribute('y1')).toEqual('4');
- expect(lineEl.getAttribute('y2')).toEqual('4');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph_path_spec.js b/spec/javascripts/monitoring/graph_path_spec.js
deleted file mode 100644
index fd167b83d51..00000000000
--- a/spec/javascripts/monitoring/graph_path_spec.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import Vue from 'vue';
-import GraphPath from '~/monitoring/components/graph/path.vue';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from './mock_data';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphPath);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
-const firstTimeSeries = timeSeries[0];
-
-describe('Monitoring Paths', () => {
- it('renders two paths to represent a line and the area underneath it', () => {
- const component = createComponent({
- generatedLinePath: firstTimeSeries.linePath,
- generatedAreaPath: firstTimeSeries.areaPath,
- lineColor: firstTimeSeries.lineColor,
- areaColor: firstTimeSeries.areaColor,
- showDot: false,
- });
- const metricArea = component.$el.querySelector('.metric-area');
- const metricLine = component.$el.querySelector('.metric-line');
-
- expect(metricArea.getAttribute('fill')).toBe('#8fbce8');
- expect(metricArea.getAttribute('d')).toBe(firstTimeSeries.areaPath);
- expect(metricLine.getAttribute('stroke')).toBe('#1f78d1');
- expect(metricLine.getAttribute('d')).toBe(firstTimeSeries.linePath);
- });
-
- describe('Computed properties', () => {
- it('strokeDashArray', () => {
- const component = createComponent({
- generatedLinePath: firstTimeSeries.linePath,
- generatedAreaPath: firstTimeSeries.areaPath,
- lineColor: firstTimeSeries.lineColor,
- areaColor: firstTimeSeries.areaColor,
- showDot: false,
- });
-
- component.lineStyle = 'dashed';
-
- expect(component.strokeDashArray).toBe('3, 1');
-
- component.lineStyle = 'dotted';
-
- expect(component.strokeDashArray).toBe('1, 1');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js
deleted file mode 100644
index 59d6d4f3a7f..00000000000
--- a/spec/javascripts/monitoring/graph_spec.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import Vue from 'vue';
-import Graph from '~/monitoring/components/graph.vue';
-import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins';
-import {
- deploymentData,
- convertDatesMultipleSeries,
- singleRowMetricsMultipleSeries,
- queryWithoutData,
-} from './mock_data';
-
-const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags';
-const projectPath = 'http://test.host/frontend-fixtures/environments-project';
-const createComponent = propsData => {
- const Component = Vue.extend(Graph);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-
-describe('Graph', () => {
- beforeEach(() => {
- spyOn(MonitoringMixins.methods, 'formatDeployments').and.returnValue({});
- });
-
- it('has a title', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- expect(component.$el.querySelector('.prometheus-graph-title').innerText.trim()).toBe(
- component.graphData.title,
- );
- });
-
- describe('Computed props', () => {
- it('axisTransform translates an element Y position depending of its height', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- const transformedHeight = `${component.graphHeight - 100}`;
-
- expect(component.axisTransform.indexOf(transformedHeight)).not.toEqual(-1);
- });
-
- it('outerViewBox gets a width and height property based on the DOM size of the element', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- const viewBoxArray = component.outerViewBox.split(' ');
-
- expect(typeof component.outerViewBox).toEqual('string');
- expect(viewBoxArray[2]).toEqual(component.graphWidth.toString());
- expect(viewBoxArray[3]).toEqual((component.graphHeight - 50).toString());
- });
- });
-
- it('has a title for the y-axis and the chart legend that comes from the backend', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- expect(component.yAxisLabel).toEqual(component.graphData.y_label);
- expect(component.legendTitle).toEqual(component.graphData.queries[0].label);
- });
-
- it('sets the currentData object based on the hovered data index', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- graphIdentifier: 0,
- hoverData: {
- hoveredDate: new Date('Sun Aug 27 2017 06:11:51 GMT-0500 (CDT)'),
- currentDeployXPos: null,
- },
- tagsPath,
- projectPath,
- });
-
- // simulate moving mouse over data series
- component.seriesUnderMouse = component.timeSeries;
-
- component.positionFlag();
-
- expect(component.currentData).toBe(component.timeSeries[0].values[10]);
- });
-
- describe('Without data to display', () => {
- it('shows a "no data to display" empty state on a graph', done => {
- const component = createComponent({
- graphData: queryWithoutData,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- Vue.nextTick(() => {
- expect(
- component.$el.querySelector('.js-no-data-to-display text').textContent.trim(),
- ).toEqual('No data to display');
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js
index 18ad9843d22..ffc7148fde2 100644
--- a/spec/javascripts/monitoring/mock_data.js
+++ b/spec/javascripts/monitoring/mock_data.js
@@ -326,6 +326,7 @@ export const metricsGroupsAPIResponse = {
{
id: 6,
title: 'CPU usage',
+ y_label: 'CPU',
weight: 1,
queries: [
{
@@ -6597,58 +6598,46 @@ export function convertDatesMultipleSeries(multipleSeries) {
export const environmentData = [
{
+ id: 34,
name: 'production',
- size: 1,
- latest: {
- id: 34,
- name: 'production',
- state: 'available',
- external_url: 'http://root-autodevops-deploy.my-fake-domain.com',
- environment_type: null,
- stop_action: false,
- metrics_path: '/root/hello-prometheus/environments/34/metrics',
- environment_path: '/root/hello-prometheus/environments/34',
- stop_path: '/root/hello-prometheus/environments/34/stop',
- terminal_path: '/root/hello-prometheus/environments/34/terminal',
- folder_path: '/root/hello-prometheus/environments/folders/production',
- created_at: '2018-06-29T16:53:38.301Z',
- updated_at: '2018-06-29T16:57:09.825Z',
- last_deployment: {
- id: 127,
- },
+ state: 'available',
+ external_url: 'http://root-autodevops-deploy.my-fake-domain.com',
+ environment_type: null,
+ stop_action: false,
+ metrics_path: '/root/hello-prometheus/environments/34/metrics',
+ environment_path: '/root/hello-prometheus/environments/34',
+ stop_path: '/root/hello-prometheus/environments/34/stop',
+ terminal_path: '/root/hello-prometheus/environments/34/terminal',
+ folder_path: '/root/hello-prometheus/environments/folders/production',
+ created_at: '2018-06-29T16:53:38.301Z',
+ updated_at: '2018-06-29T16:57:09.825Z',
+ last_deployment: {
+ id: 127,
},
},
{
- name: 'review',
- size: 1,
- latest: {
- id: 35,
- name: 'review/noop-branch',
- state: 'available',
- external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com',
- environment_type: 'review',
- stop_action: true,
- metrics_path: '/root/hello-prometheus/environments/35/metrics',
- environment_path: '/root/hello-prometheus/environments/35',
- stop_path: '/root/hello-prometheus/environments/35/stop',
- terminal_path: '/root/hello-prometheus/environments/35/terminal',
- folder_path: '/root/hello-prometheus/environments/folders/review',
- created_at: '2018-07-03T18:39:41.702Z',
- updated_at: '2018-07-03T18:44:54.010Z',
- last_deployment: {
- id: 128,
- },
+ id: 35,
+ name: 'review/noop-branch',
+ state: 'available',
+ external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com',
+ environment_type: 'review',
+ stop_action: true,
+ metrics_path: '/root/hello-prometheus/environments/35/metrics',
+ environment_path: '/root/hello-prometheus/environments/35',
+ stop_path: '/root/hello-prometheus/environments/35/stop',
+ terminal_path: '/root/hello-prometheus/environments/35/terminal',
+ folder_path: '/root/hello-prometheus/environments/folders/review',
+ created_at: '2018-07-03T18:39:41.702Z',
+ updated_at: '2018-07-03T18:44:54.010Z',
+ last_deployment: {
+ id: 128,
},
},
{
- name: 'no-deployment',
- size: 1,
- latest: {
- id: 36,
- name: 'no-deployment/noop-branch',
- state: 'available',
- created_at: '2018-07-04T18:39:41.702Z',
- updated_at: '2018-07-04T18:44:54.010Z',
- },
+ id: 36,
+ name: 'no-deployment/noop-branch',
+ state: 'available',
+ created_at: '2018-07-04T18:39:41.702Z',
+ updated_at: '2018-07-04T18:44:54.010Z',
},
];
diff --git a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js b/spec/javascripts/monitoring/utils/multiple_time_series_spec.js
deleted file mode 100644
index 8937b7d9680..00000000000
--- a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
-const firstTimeSeries = timeSeries[0];
-
-describe('Multiple time series', () => {
- it('createTimeSeries returned array contains an object for each element', () => {
- expect(typeof firstTimeSeries.linePath).toEqual('string');
- expect(typeof firstTimeSeries.areaPath).toEqual('string');
- expect(typeof firstTimeSeries.timeSeriesScaleX).toEqual('function');
- expect(typeof firstTimeSeries.areaColor).toEqual('string');
- expect(typeof firstTimeSeries.lineColor).toEqual('string');
- expect(firstTimeSeries.values instanceof Array).toEqual(true);
- });
-
- it('createTimeSeries returns an array', () => {
- expect(timeSeries instanceof Array).toEqual(true);
- expect(timeSeries.length).toEqual(2);
- });
-});
diff --git a/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js b/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js
new file mode 100644
index 00000000000..07a366cf339
--- /dev/null
+++ b/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js
@@ -0,0 +1,34 @@
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+
+const localVue = createLocalVue();
+
+describe('ReplyPlaceholder', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(ReplyPlaceholder, {
+ localVue,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('emits onClick even on button click', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ button.trigger('click');
+
+ expect(wrapper.emitted()).toEqual({
+ onClick: [[]],
+ });
+ });
+
+ it('should render reply button', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ expect(button.text()).toEqual('Reply...');
+ });
+});
diff --git a/spec/javascripts/notes/components/note_actions/reply_button_spec.js b/spec/javascripts/notes/components/note_actions/reply_button_spec.js
new file mode 100644
index 00000000000..11e1664a3f4
--- /dev/null
+++ b/spec/javascripts/notes/components/note_actions/reply_button_spec.js
@@ -0,0 +1,46 @@
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
+import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
+
+describe('ReplyButton', () => {
+ const noteId = 'dummy-note-id';
+
+ let wrapper;
+ let convertToDiscussion;
+
+ beforeEach(() => {
+ const localVue = createLocalVue();
+ convertToDiscussion = jasmine.createSpy('convertToDiscussion');
+
+ localVue.use(Vuex);
+ const store = new Vuex.Store({
+ actions: {
+ convertToDiscussion,
+ },
+ });
+
+ wrapper = mount(ReplyButton, {
+ propsData: {
+ noteId,
+ },
+ store,
+ sync: false,
+ localVue,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('dispatches convertToDiscussion with note ID on click', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ button.trigger('click');
+
+ expect(convertToDiscussion).toHaveBeenCalledTimes(1);
+ const [, payload] = convertToDiscussion.calls.argsFor(0);
+
+ expect(payload).toBe(noteId);
+ });
+});
diff --git a/spec/javascripts/notes/components/note_actions_spec.js b/spec/javascripts/notes/components/note_actions_spec.js
index b102b7aecf7..0c1962912b4 100644
--- a/spec/javascripts/notes/components/note_actions_spec.js
+++ b/spec/javascripts/notes/components/note_actions_spec.js
@@ -2,14 +2,38 @@ import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import noteActions from '~/notes/components/note_actions.vue';
+import { TEST_HOST } from 'spec/test_constants';
import { userDataMock } from '../mock_data';
describe('noteActions', () => {
let wrapper;
let store;
+ let props;
+
+ const createWrapper = propsData => {
+ const localVue = createLocalVue();
+ return shallowMount(noteActions, {
+ store,
+ propsData,
+ localVue,
+ sync: false,
+ });
+ };
beforeEach(() => {
store = createStore();
+ props = {
+ accessLevel: 'Maintainer',
+ authorId: 26,
+ canDelete: true,
+ canEdit: true,
+ canAwardEmoji: true,
+ canReportAsAbuse: true,
+ noteId: '539',
+ noteUrl: `${TEST_HOST}/group/project/merge_requests/1#note_1`,
+ reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`,
+ showReply: false,
+ };
});
afterEach(() => {
@@ -17,31 +41,10 @@ describe('noteActions', () => {
});
describe('user is logged in', () => {
- let props;
-
beforeEach(() => {
- props = {
- accessLevel: 'Maintainer',
- authorId: 26,
- canDelete: true,
- canEdit: true,
- canAwardEmoji: true,
- canReportAsAbuse: true,
- noteId: '539',
- noteUrl: 'https://localhost:3000/group/project/merge_requests/1#note_1',
- reportAbusePath:
- '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
- };
-
store.dispatch('setUserData', userDataMock);
- const localVue = createLocalVue();
- wrapper = shallowMount(noteActions, {
- store,
- propsData: props,
- localVue,
- sync: false,
- });
+ wrapper = createWrapper(props);
});
it('should render access level badge', () => {
@@ -91,28 +94,14 @@ describe('noteActions', () => {
});
describe('user is not logged in', () => {
- let props;
-
beforeEach(() => {
store.dispatch('setUserData', {});
- props = {
- accessLevel: 'Maintainer',
- authorId: 26,
+ wrapper = createWrapper({
+ ...props,
canDelete: false,
canEdit: false,
canAwardEmoji: false,
canReportAsAbuse: false,
- noteId: '539',
- noteUrl: 'https://localhost:3000/group/project/merge_requests/1#note_1',
- reportAbusePath:
- '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
- };
- const localVue = createLocalVue();
- wrapper = shallowMount(noteActions, {
- store,
- propsData: props,
- localVue,
- sync: false,
});
});
@@ -124,4 +113,88 @@ describe('noteActions', () => {
expect(wrapper.find('.more-actions').exists()).toBe(false);
});
});
+
+ describe('with feature flag replyToIndividualNotes enabled', () => {
+ beforeEach(() => {
+ gon.features = {
+ replyToIndividualNotes: true,
+ };
+ });
+
+ afterEach(() => {
+ gon.features = {};
+ });
+
+ describe('for showReply = true', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: true,
+ });
+ });
+
+ it('shows a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(true);
+ });
+ });
+
+ describe('for showReply = false', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: false,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with feature flag replyToIndividualNotes disabled', () => {
+ beforeEach(() => {
+ gon.features = {
+ replyToIndividualNotes: false,
+ };
+ });
+
+ afterEach(() => {
+ gon.features = {};
+ });
+
+ describe('for showReply = true', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: true,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+
+ describe('for showReply = false', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: false,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js
index 22bee049f9c..d5c0bf6b25d 100644
--- a/spec/javascripts/notes/components/note_app_spec.js
+++ b/spec/javascripts/notes/components/note_app_spec.js
@@ -1,57 +1,49 @@
import $ from 'jquery';
import _ from 'underscore';
import Vue from 'vue';
-import notesApp from '~/notes/components/notes_app.vue';
+import { mount, createLocalVue } from '@vue/test-utils';
+import NotesApp from '~/notes/components/notes_app.vue';
import service from '~/notes/services/notes_service';
import createStore from '~/notes/stores';
import '~/behaviors/markdown/render_gfm';
-import { mountComponentWithStore } from 'spec/helpers';
import * as mockData from '../mock_data';
-const vueMatchers = {
- toIncludeElement() {
- return {
- compare(vm, selector) {
- const result = {
- pass: vm.$el.querySelector(selector) !== null,
- };
- return result;
- },
- };
- },
-};
-
describe('note_app', () => {
let mountComponent;
- let vm;
+ let wrapper;
let store;
beforeEach(() => {
- jasmine.addMatchers(vueMatchers);
$('body').attr('data-page', 'projects:merge_requests:show');
- setFixtures('<div class="js-vue-notes-event"><div id="app"></div></div>');
-
- const IssueNotesApp = Vue.extend(notesApp);
-
store = createStore();
mountComponent = data => {
- const props = data || {
+ const propsData = data || {
noteableData: mockData.noteableDataMock,
notesData: mockData.notesDataMock,
userData: mockData.userDataMock,
};
-
- return mountComponentWithStore(IssueNotesApp, {
- props,
- store,
- el: document.getElementById('app'),
- });
+ const localVue = createLocalVue();
+
+ return mount(
+ {
+ components: {
+ NotesApp,
+ },
+ template: '<div class="js-vue-notes-event"><notes-app v-bind="$attrs" /></div>',
+ },
+ {
+ propsData,
+ store,
+ localVue,
+ sync: false,
+ },
+ );
};
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('set data', () => {
@@ -65,7 +57,7 @@ describe('note_app', () => {
beforeEach(() => {
Vue.http.interceptors.push(responseInterceptor);
- vm = mountComponent();
+ wrapper = mountComponent();
});
afterEach(() => {
@@ -73,26 +65,26 @@ describe('note_app', () => {
});
it('should set notes data', () => {
- expect(vm.$store.state.notesData).toEqual(mockData.notesDataMock);
+ expect(store.state.notesData).toEqual(mockData.notesDataMock);
});
it('should set issue data', () => {
- expect(vm.$store.state.noteableData).toEqual(mockData.noteableDataMock);
+ expect(store.state.noteableData).toEqual(mockData.noteableDataMock);
});
it('should set user data', () => {
- expect(vm.$store.state.userData).toEqual(mockData.userDataMock);
+ expect(store.state.userData).toEqual(mockData.userDataMock);
});
it('should fetch discussions', () => {
- expect(vm.$store.state.discussions).toEqual([]);
+ expect(store.state.discussions).toEqual([]);
});
});
describe('render', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
- vm = mountComponent();
+ wrapper = mountComponent();
});
afterEach(() => {
@@ -107,51 +99,50 @@ describe('note_app', () => {
setTimeout(() => {
expect(
- vm.$el.querySelector('.main-notes-list .note-header-author-name').textContent.trim(),
+ wrapper
+ .find('.main-notes-list .note-header-author-name')
+ .text()
+ .trim(),
).toEqual(note.author.name);
- expect(vm.$el.querySelector('.main-notes-list .note-text').innerHTML).toEqual(
- note.note_html,
- );
+ expect(wrapper.find('.main-notes-list .note-text').html()).toContain(note.note_html);
done();
}, 0);
});
it('should render form', () => {
- expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
- expect(
- vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
- ).toEqual('Write a comment or drag your files here…');
+ expect(wrapper.find('.js-main-target-form').name()).toEqual('form');
+ expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual(
+ 'Write a comment or drag your files here…',
+ );
});
it('should not render form when commenting is disabled', () => {
store.state.commentsDisabled = true;
- vm = mountComponent();
+ wrapper = mountComponent();
- expect(vm.$el.querySelector('.js-main-target-form')).toEqual(null);
+ expect(wrapper.find('.js-main-target-form').exists()).toBe(false);
});
it('should render form comment button as disabled', () => {
- expect(vm.$el.querySelector('.js-note-new-discussion').getAttribute('disabled')).toEqual(
- 'disabled',
- );
+ expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled');
});
});
describe('while fetching data', () => {
beforeEach(() => {
- vm = mountComponent();
+ wrapper = mountComponent();
});
it('renders skeleton notes', () => {
- expect(vm).toIncludeElement('.animation-container');
+ expect(wrapper.find('.animation-container').exists()).toBe(true);
});
it('should render form', () => {
- expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
- expect(
- vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
- ).toEqual('Write a comment or drag your files here…');
+ expect(wrapper.find('.js-main-target-form').name()).toEqual('form');
+ expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual(
+ 'Write a comment or drag your files here…',
+ );
});
});
@@ -160,9 +151,9 @@ describe('note_app', () => {
beforeEach(done => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
spyOn(service, 'updateNote').and.callThrough();
- vm = mountComponent();
+ wrapper = mountComponent();
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
Vue.nextTick(done);
}, 0);
});
@@ -175,12 +166,12 @@ describe('note_app', () => {
});
it('renders edit form', () => {
- expect(vm).toIncludeElement('.js-vue-issue-note-form');
+ expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true);
});
it('calls the service to update the note', done => {
- vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
- vm.$el.querySelector('.js-vue-issue-save').click();
+ wrapper.find('.js-vue-issue-note-form').value = 'this is a note';
+ wrapper.find('.js-vue-issue-save').trigger('click');
expect(service.updateNote).toHaveBeenCalled();
// Wait for the requests to finish before destroying
@@ -194,10 +185,10 @@ describe('note_app', () => {
beforeEach(done => {
Vue.http.interceptors.push(mockData.discussionNoteInterceptor);
spyOn(service, 'updateNote').and.callThrough();
- vm = mountComponent();
+ wrapper = mountComponent();
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
Vue.nextTick(done);
}, 0);
});
@@ -210,12 +201,12 @@ describe('note_app', () => {
});
it('renders edit form', () => {
- expect(vm).toIncludeElement('.js-vue-issue-note-form');
+ expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true);
});
it('updates the note and resets the edit form', done => {
- vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
- vm.$el.querySelector('.js-vue-issue-save').click();
+ wrapper.find('.js-vue-issue-note-form').value = 'this is a note';
+ wrapper.find('.js-vue-issue-save').trigger('click');
expect(service.updateNote).toHaveBeenCalled();
// Wait for the requests to finish before destroying
@@ -228,30 +219,36 @@ describe('note_app', () => {
describe('new note form', () => {
beforeEach(() => {
- vm = mountComponent();
+ wrapper = mountComponent();
});
it('should render markdown docs url', () => {
const { markdownDocsPath } = mockData.notesDataMock;
- expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual(
- 'Markdown',
- );
+ expect(
+ wrapper
+ .find(`a[href="${markdownDocsPath}"]`)
+ .text()
+ .trim(),
+ ).toEqual('Markdown');
});
it('should render quick action docs url', () => {
const { quickActionsDocsPath } = mockData.notesDataMock;
- expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual(
- 'quick actions',
- );
+ expect(
+ wrapper
+ .find(`a[href="${quickActionsDocsPath}"]`)
+ .text()
+ .trim(),
+ ).toEqual('quick actions');
});
});
describe('edit form', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
- vm = mountComponent();
+ wrapper = mountComponent();
});
afterEach(() => {
@@ -260,12 +257,15 @@ describe('note_app', () => {
it('should render markdown docs url', done => {
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
const { markdownDocsPath } = mockData.notesDataMock;
Vue.nextTick(() => {
expect(
- vm.$el.querySelector(`.edit-note a[href="${markdownDocsPath}"]`).textContent.trim(),
+ wrapper
+ .find(`.edit-note a[href="${markdownDocsPath}"]`)
+ .text()
+ .trim(),
).toEqual('Markdown is supported');
done();
});
@@ -274,13 +274,11 @@ describe('note_app', () => {
it('should not render quick actions docs url', done => {
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
const { quickActionsDocsPath } = mockData.notesDataMock;
Vue.nextTick(() => {
- expect(vm.$el.querySelector(`.edit-note a[href="${quickActionsDocsPath}"]`)).toEqual(
- null,
- );
+ expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false);
done();
});
}, 0);
@@ -295,12 +293,19 @@ describe('note_app', () => {
noteId: 1,
},
});
+ const toggleAwardAction = jasmine.createSpy('toggleAward');
+ wrapper.vm.$store.hotUpdate({
+ actions: {
+ toggleAward: toggleAwardAction,
+ },
+ });
- spyOn(vm.$store, 'dispatch');
+ wrapper.vm.$parent.$el.dispatchEvent(toggleAwardEvent);
- vm.$el.parentElement.dispatchEvent(toggleAwardEvent);
+ expect(toggleAwardAction).toHaveBeenCalledTimes(1);
+ const [, payload] = toggleAwardAction.calls.argsFor(0);
- expect(vm.$store.dispatch).toHaveBeenCalledWith('toggleAward', {
+ expect(payload).toEqual({
awardName: 'test',
noteId: 1,
});
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index c4b7eb17393..2eae22e095f 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -1,6 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import '~/behaviors/markdown/render_gfm';
import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data';
import mockDiffFile from '../../diffs/mock_data/diff_file';
@@ -57,27 +58,23 @@ describe('noteable_discussion component', () => {
});
describe('actions', () => {
- it('should render reply button', () => {
- expect(
- wrapper
- .find('.js-vue-discussion-reply')
- .text()
- .trim(),
- ).toEqual('Reply...');
- });
-
it('should toggle reply form', done => {
- wrapper.find('.js-vue-discussion-reply').trigger('click');
+ const replyPlaceholder = wrapper.find(ReplyPlaceholder);
- wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.isReplying).toEqual(true);
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.vm.isReplying).toEqual(false);
- // There is a watcher for `isReplying` which will init autosave in the next tick
- wrapper.vm.$nextTick(() => {
+ replyPlaceholder.vm.$emit('onClick');
+ })
+ .then(() => wrapper.vm.$nextTick())
+ .then(() => {
+ expect(wrapper.vm.isReplying).toEqual(true);
expect(wrapper.vm.$refs.noteForm).not.toBeNull();
- done();
- });
- });
+ })
+ .then(done)
+ .catch(done.fail);
});
it('does not render jump to discussion button', () => {
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index 7ae45c40c28..348743081eb 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -165,7 +165,6 @@ export const note = {
report_abuse_path:
'/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1',
path: '/gitlab-org/gitlab-ce/notes/546',
- cached_markdown_version: 11,
};
export const discussionMock = {
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
index 2e3cd5e8f36..73f960dd21e 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -585,4 +585,18 @@ describe('Actions Notes Store', () => {
);
});
});
+
+ describe('convertToDiscussion', () => {
+ it('commits CONVERT_TO_DISCUSSION with noteId', done => {
+ const noteId = 'dummy-note-id';
+ testAction(
+ actions.convertToDiscussion,
+ noteId,
+ {},
+ [{ type: 'CONVERT_TO_DISCUSSION', payload: noteId }],
+ [],
+ done,
+ );
+ });
+ });
});
diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js
index b6b2c7d60a5..4f8d3069bb5 100644
--- a/spec/javascripts/notes/stores/mutation_spec.js
+++ b/spec/javascripts/notes/stores/mutation_spec.js
@@ -517,4 +517,27 @@ describe('Notes Store mutations', () => {
);
});
});
+
+ describe('CONVERT_TO_DISCUSSION', () => {
+ let discussion;
+ let state;
+
+ beforeEach(() => {
+ discussion = {
+ id: 42,
+ individual_note: true,
+ };
+ state = { discussions: [discussion] };
+ });
+
+ it('toggles individual_note', () => {
+ mutations.CONVERT_TO_DISCUSSION(state, discussion.id);
+
+ expect(discussion.individual_note).toBe(false);
+ });
+
+ it('throws if discussion was not found', () => {
+ expect(() => mutations.CONVERT_TO_DISCUSSION(state, 99)).toThrow();
+ });
+ });
});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 694f581150f..7c869d4c326 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -89,10 +89,25 @@ describe('Notes', function() {
});
it('submits an ajax request on tasklist:changed', function(done) {
- $('.js-task-list-container').trigger('tasklist:changed');
+ const lineNumber = 8;
+ const lineSource = '- [ ] item 8';
+ const index = 3;
+ const checked = true;
+
+ $('.js-task-list-container').trigger({
+ type: 'tasklist:changed',
+ detail: { lineNumber, lineSource, index, checked },
+ });
setTimeout(() => {
- expect(axios.patch).toHaveBeenCalled();
+ expect(axios.patch).toHaveBeenCalledWith(undefined, {
+ note: {
+ note: '',
+ lock_version: undefined,
+ update_task: { index, checked, line_number: lineNumber, line_source: lineSource },
+ },
+ });
+
done();
});
});
diff --git a/spec/javascripts/serverless/components/environment_row_spec.js b/spec/javascripts/serverless/components/environment_row_spec.js
new file mode 100644
index 00000000000..bdf7a714910
--- /dev/null
+++ b/spec/javascripts/serverless/components/environment_row_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+
+import environmentRowComponent from '~/serverless/components/environment_row.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import ServerlessStore from '~/serverless/stores/serverless_store';
+
+import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+
+const createComponent = (env, envName) =>
+ mountComponent(Vue.extend(environmentRowComponent), { env, envName });
+
+describe('environment row component', () => {
+ describe('default global cluster case', () => {
+ let vm;
+
+ beforeEach(() => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctions);
+ vm = createComponent(store.state.functions['*'], '*');
+ });
+
+ it('has the correct envId', () => {
+ expect(vm.envId).toEqual('env-global');
+ vm.$destroy();
+ });
+
+ it('is open by default', () => {
+ expect(vm.isOpenClass).toEqual({ 'is-open': true });
+ vm.$destroy();
+ });
+
+ it('generates correct output', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(2);
+ expect(vm.$el.id).toEqual('env-global');
+ expect(vm.$el.classList.contains('is-open')).toBe(true);
+ expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
+
+ vm.$destroy();
+ });
+
+ it('opens and closes correctly', () => {
+ expect(vm.isOpen).toBe(true);
+
+ vm.toggleOpen();
+ Vue.nextTick(() => {
+ expect(vm.isOpen).toBe(false);
+ });
+
+ vm.$destroy();
+ });
+ });
+
+ describe('default named cluster case', () => {
+ let vm;
+
+ beforeEach(() => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv);
+ vm = createComponent(store.state.functions.test, 'test');
+ });
+
+ it('has the correct envId', () => {
+ expect(vm.envId).toEqual('env-test');
+ vm.$destroy();
+ });
+
+ it('is open by default', () => {
+ expect(vm.isOpenClass).toEqual({ 'is-open': true });
+ vm.$destroy();
+ });
+
+ it('generates correct output', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(1);
+ expect(vm.$el.id).toEqual('env-test');
+ expect(vm.$el.classList.contains('is-open')).toBe(true);
+ expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/serverless/components/function_row_spec.js b/spec/javascripts/serverless/components/function_row_spec.js
new file mode 100644
index 00000000000..6933a8f6c87
--- /dev/null
+++ b/spec/javascripts/serverless/components/function_row_spec.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+
+import functionRowComponent from '~/serverless/components/function_row.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockServerlessFunction } from '../mock_data';
+
+const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func });
+
+describe('functionRowComponent', () => {
+ it('Parses the function details correctly', () => {
+ const vm = createComponent(mockServerlessFunction);
+
+ expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
+ expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image);
+ expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null);
+ expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
+ mockServerlessFunction.url,
+ );
+
+ vm.$destroy();
+ });
+
+ it('handles clicks correctly', () => {
+ const vm = createComponent(mockServerlessFunction);
+
+ expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
+ expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image
+ expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar
+
+ vm.$destroy();
+ });
+});
diff --git a/spec/javascripts/serverless/components/functions_spec.js b/spec/javascripts/serverless/components/functions_spec.js
new file mode 100644
index 00000000000..85cfe71281f
--- /dev/null
+++ b/spec/javascripts/serverless/components/functions_spec.js
@@ -0,0 +1,68 @@
+import Vue from 'vue';
+
+import functionsComponent from '~/serverless/components/functions.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import ServerlessStore from '~/serverless/stores/serverless_store';
+
+import { mockServerlessFunctions } from '../mock_data';
+
+const createComponent = (
+ functions,
+ installed = true,
+ loadingData = true,
+ hasFunctionData = true,
+) => {
+ const component = Vue.extend(functionsComponent);
+
+ return mountComponent(component, {
+ functions,
+ installed,
+ clustersPath: '/testClusterPath',
+ helpPath: '/helpPath',
+ loadingData,
+ hasFunctionData,
+ });
+};
+
+describe('functionsComponent', () => {
+ it('should render empty state when Knative is not installed', () => {
+ const vm = createComponent({}, false);
+
+ expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true);
+ expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
+ 'Getting started with serverless',
+ );
+
+ vm.$destroy();
+ });
+
+ it('should render a loading component', () => {
+ const vm = createComponent({});
+
+ expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null);
+ expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
+ });
+
+ it('should render empty state when there is no function data', () => {
+ const vm = createComponent({}, true, false, false);
+
+ expect(
+ vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'),
+ ).toBe(true);
+
+ expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
+ 'No functions available',
+ );
+
+ vm.$destroy();
+ });
+
+ it('should render the functions list', () => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctions);
+ const vm = createComponent(store.state.functions, true, false);
+
+ expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null);
+ expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true);
+ });
+});
diff --git a/spec/javascripts/serverless/components/url_spec.js b/spec/javascripts/serverless/components/url_spec.js
new file mode 100644
index 00000000000..21a879a49bb
--- /dev/null
+++ b/spec/javascripts/serverless/components/url_spec.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+
+import urlComponent from '~/serverless/components/url.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = uri => {
+ const component = Vue.extend(urlComponent);
+
+ return mountComponent(component, {
+ uri,
+ });
+};
+
+describe('urlComponent', () => {
+ it('should render correctly', () => {
+ const uri = 'http://testfunc.apps.example.com';
+ const vm = createComponent(uri);
+
+ expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
+ expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual(
+ uri,
+ );
+
+ expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
+
+ vm.$destroy();
+ });
+});
diff --git a/spec/javascripts/serverless/mock_data.js b/spec/javascripts/serverless/mock_data.js
new file mode 100644
index 00000000000..ecd393b174c
--- /dev/null
+++ b/spec/javascripts/serverless/mock_data.js
@@ -0,0 +1,79 @@
+export const mockServerlessFunctions = [
+ {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+ },
+ {
+ name: 'testfunc2',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc2.tm-example.apps.example.com',
+ description: 'A second test service\nThis one with additional descriptions',
+ image: 'knative-test-echo-buildtemplate',
+ },
+];
+
+export const mockServerlessFunctionsDiffEnv = [
+ {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+ },
+ {
+ name: 'testfunc2',
+ namespace: 'tm-example',
+ environment_scope: 'test',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc2.tm-example.apps.example.com',
+ description: 'A second test service\nThis one with additional descriptions',
+ image: 'knative-test-echo-buildtemplate',
+ },
+];
+
+export const mockServerlessFunction = {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: '3',
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+};
+
+export const mockMultilineServerlessFunction = {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: '3',
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'testfunc1\nA test service line\\nWith additional services',
+ image: 'knative-test-container-buildtemplate',
+};
diff --git a/spec/javascripts/serverless/stores/serverless_store_spec.js b/spec/javascripts/serverless/stores/serverless_store_spec.js
new file mode 100644
index 00000000000..72fd903d7d1
--- /dev/null
+++ b/spec/javascripts/serverless/stores/serverless_store_spec.js
@@ -0,0 +1,36 @@
+import ServerlessStore from '~/serverless/stores/serverless_store';
+import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+
+describe('Serverless Functions Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ });
+
+ describe('#updateFunctionsFromServer', () => {
+ it('should pass an empty hash object', () => {
+ store.updateFunctionsFromServer();
+
+ expect(store.state.functions).toEqual({});
+ });
+
+ it('should group functions to one global environment', () => {
+ const mockServerlessData = mockServerlessFunctions;
+ store.updateFunctionsFromServer(mockServerlessData);
+
+ expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
+ expect(store.state.functions['*'].length).toEqual(2);
+ });
+
+ it('should group functions to multiple environments', () => {
+ const mockServerlessData = mockServerlessFunctionsDiffEnv;
+ store.updateFunctionsFromServer(mockServerlessData);
+
+ expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
+ expect(store.state.functions['*'].length).toEqual(1);
+ expect(store.state.functions.test.length).toEqual(1);
+ expect(store.state.functions.test[0].name).toEqual('testfunc2');
+ });
+ });
+});
diff --git a/spec/javascripts/task_list_spec.js b/spec/javascripts/task_list_spec.js
new file mode 100644
index 00000000000..563f402de58
--- /dev/null
+++ b/spec/javascripts/task_list_spec.js
@@ -0,0 +1,156 @@
+import $ from 'jquery';
+import TaskList from '~/task_list';
+import axios from '~/lib/utils/axios_utils';
+
+describe('TaskList', () => {
+ let taskList;
+ let currentTarget;
+ const taskListOptions = {
+ selector: '.task-list',
+ dataType: 'issue',
+ fieldName: 'description',
+ lockVersion: 2,
+ };
+ const createTaskList = () => new TaskList(taskListOptions);
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="task-list">
+ <div class="js-task-list-container"></div>
+ </div>
+ `);
+
+ currentTarget = $('<div></div>');
+ taskList = createTaskList();
+ });
+
+ it('should call init when the class constructed', () => {
+ spyOn(TaskList.prototype, 'init').and.callThrough();
+ spyOn(TaskList.prototype, 'disable');
+ spyOn($.prototype, 'taskList');
+ spyOn($.prototype, 'on');
+
+ taskList = createTaskList();
+ const $taskListEl = $(taskList.taskListContainerSelector);
+
+ expect(taskList.init).toHaveBeenCalled();
+ expect(taskList.disable).toHaveBeenCalled();
+ expect($taskListEl.taskList).toHaveBeenCalledWith('enable');
+ expect($(document).on).toHaveBeenCalledWith(
+ 'tasklist:changed',
+ taskList.taskListContainerSelector,
+ taskList.updateHandler,
+ );
+ });
+
+ describe('getTaskListTarget', () => {
+ it('should return currentTarget from event object if exists', () => {
+ const $target = taskList.getTaskListTarget({ currentTarget });
+
+ expect($target).toEqual(currentTarget);
+ });
+
+ it('should return element of the taskListContainerSelector', () => {
+ const $target = taskList.getTaskListTarget();
+
+ expect($target).toEqual($(taskList.taskListContainerSelector));
+ });
+ });
+
+ describe('disableTaskListItems', () => {
+ it('should call taskList method with disable param', () => {
+ spyOn($.prototype, 'taskList');
+
+ taskList.disableTaskListItems({ currentTarget });
+
+ expect(currentTarget.taskList).toHaveBeenCalledWith('disable');
+ });
+ });
+
+ describe('enableTaskListItems', () => {
+ it('should call taskList method with enable param', () => {
+ spyOn($.prototype, 'taskList');
+
+ taskList.enableTaskListItems({ currentTarget });
+
+ expect(currentTarget.taskList).toHaveBeenCalledWith('enable');
+ });
+ });
+
+ describe('disable', () => {
+ it('should disable task list items and off document event', () => {
+ spyOn(taskList, 'disableTaskListItems');
+ spyOn($.prototype, 'off');
+
+ taskList.disable();
+
+ expect(taskList.disableTaskListItems).toHaveBeenCalled();
+ expect($(document).off).toHaveBeenCalledWith(
+ 'tasklist:changed',
+ taskList.taskListContainerSelector,
+ );
+ });
+ });
+
+ describe('update', () => {
+ it('should disable task list items and make a patch request then enable them again', done => {
+ const response = { data: { lock_version: 3 } };
+ spyOn(taskList, 'enableTaskListItems');
+ spyOn(taskList, 'disableTaskListItems');
+ spyOn(taskList, 'onSuccess');
+ spyOn(axios, 'patch').and.returnValue(Promise.resolve(response));
+
+ const value = 'hello world';
+ const endpoint = '/foo';
+ const target = $(`<input data-update-url="${endpoint}" value="${value}" />`);
+ const detail = {
+ index: 2,
+ checked: true,
+ lineNumber: 8,
+ lineSource: '- [ ] check item',
+ };
+ const event = { target, detail };
+ const patchData = {
+ [taskListOptions.dataType]: {
+ [taskListOptions.fieldName]: value,
+ lock_version: taskListOptions.lockVersion,
+ update_task: {
+ index: detail.index,
+ checked: detail.checked,
+ line_number: detail.lineNumber,
+ line_source: detail.lineSource,
+ },
+ },
+ };
+
+ taskList
+ .update(event)
+ .then(() => {
+ expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event);
+ expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData);
+ expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
+ expect(taskList.onSuccess).toHaveBeenCalledWith(response.data);
+ expect(taskList.lockVersion).toEqual(response.data.lock_version);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ it('should handle request error and enable task list items', done => {
+ const response = { data: { error: 1 } };
+ spyOn(taskList, 'enableTaskListItems');
+ spyOn(taskList, 'onError');
+ spyOn(axios, 'patch').and.returnValue(Promise.reject({ response })); // eslint-disable-line prefer-promise-reject-errors
+
+ const event = { detail: {} };
+ taskList
+ .update(event)
+ .then(() => {
+ expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
+ expect(taskList.onError).toHaveBeenCalledWith(response.data);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/commit_edit_spec.js b/spec/javascripts/vue_mr_widget/components/states/commit_edit_spec.js
new file mode 100644
index 00000000000..994d6255324
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/commit_edit_spec.js
@@ -0,0 +1,85 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue';
+
+const localVue = createLocalVue();
+const testCommitMessage = 'Test commit message';
+const testLabel = 'Test label';
+const testInputId = 'test-input-id';
+
+describe('Commits edit component', () => {
+ let wrapper;
+
+ const createComponent = (slots = {}) => {
+ wrapper = shallowMount(localVue.extend(CommitEdit), {
+ localVue,
+ sync: false,
+ propsData: {
+ value: testCommitMessage,
+ label: testLabel,
+ inputId: testInputId,
+ },
+ slots: {
+ ...slots,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findTextarea = () => wrapper.find('.form-control');
+
+ it('has a correct label', () => {
+ const labelElement = wrapper.find('.col-form-label');
+
+ expect(labelElement.text()).toBe(testLabel);
+ });
+
+ describe('textarea', () => {
+ it('has a correct ID', () => {
+ expect(findTextarea().attributes('id')).toBe(testInputId);
+ });
+
+ it('has a correct value', () => {
+ expect(findTextarea().element.value).toBe(testCommitMessage);
+ });
+
+ it('emits an input event and receives changed value', () => {
+ const changedCommitMessage = 'Changed commit message';
+
+ findTextarea().element.value = changedCommitMessage;
+ findTextarea().trigger('input');
+
+ expect(wrapper.emitted().input[0]).toEqual([changedCommitMessage]);
+ expect(findTextarea().element.value).toBe(changedCommitMessage);
+ });
+ });
+
+ describe('when slots are present', () => {
+ beforeEach(() => {
+ createComponent({
+ header: `<div class="test-header">${testCommitMessage}</div>`,
+ checkbox: `<label slot="checkbox" class="test-checkbox">${testLabel}</label >`,
+ });
+ });
+
+ it('renders header slot correctly', () => {
+ const headerSlotElement = wrapper.find('.test-header');
+
+ expect(headerSlotElement.exists()).toBe(true);
+ expect(headerSlotElement.text()).toBe(testCommitMessage);
+ });
+
+ it('renders checkbox slot correctly', () => {
+ const checkboxSlotElement = wrapper.find('.test-checkbox');
+
+ expect(checkboxSlotElement.exists()).toBe(true);
+ expect(checkboxSlotElement.text()).toBe(testLabel);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js
new file mode 100644
index 00000000000..daf1cc2d98b
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js
@@ -0,0 +1,61 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlDropdownItem } from '@gitlab/ui';
+import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue';
+
+const localVue = createLocalVue();
+const commits = [
+ {
+ title: 'Commit 1',
+ short_id: '78d5b7',
+ message: 'Update test.txt',
+ },
+ {
+ title: 'Commit 2',
+ short_id: '34cbe28b',
+ message: 'Fixed test',
+ },
+ {
+ title: 'Commit 3',
+ short_id: 'fa42932a',
+ message: 'Added changelog',
+ },
+];
+
+describe('Commits message dropdown component', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(localVue.extend(CommitMessageDropdown), {
+ localVue,
+ sync: false,
+ propsData: {
+ commits,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownElements = () => wrapper.findAll(GlDropdownItem);
+ const findFirstDropdownElement = () => findDropdownElements().at(0);
+
+ it('should have 3 elements in dropdown list', () => {
+ expect(findDropdownElements().length).toBe(3);
+ });
+
+ it('should have correct message for the first dropdown list element', () => {
+ expect(findFirstDropdownElement().text()).toBe('78d5b7 Commit 1');
+ });
+
+ it('should emit a commit title on selecting commit', () => {
+ findFirstDropdownElement().vm.$emit('click');
+
+ expect(wrapper.emitted().input[0]).toEqual(['Update test.txt']);
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commits_header_spec.js
new file mode 100644
index 00000000000..5cf6408cf34
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commits_header_spec.js
@@ -0,0 +1,110 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+const localVue = createLocalVue();
+
+describe('Commits header component', () => {
+ let wrapper;
+
+ const createComponent = props => {
+ wrapper = shallowMount(localVue.extend(CommitsHeader), {
+ localVue,
+ sync: false,
+ propsData: {
+ isSquashEnabled: false,
+ targetBranch: 'master',
+ commitsCount: 5,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findHeaderWrapper = () => wrapper.find('.js-mr-widget-commits-count');
+ const findCommitToggle = () => wrapper.find('.commit-edit-toggle');
+ const findIcon = () => wrapper.find(Icon);
+ const findCommitsCountMessage = () => wrapper.find('.commits-count-message');
+ const findTargetBranchMessage = () => wrapper.find('.label-branch');
+ const findModifyButton = () => wrapper.find('.modify-message-button');
+
+ describe('when collapsed', () => {
+ it('toggle has aria-label equal to Expand', () => {
+ createComponent();
+
+ expect(findCommitToggle().attributes('aria-label')).toBe('Expand');
+ });
+
+ it('has a chevron-right icon', () => {
+ createComponent();
+ wrapper.setData({ expanded: false });
+
+ expect(findIcon().props('name')).toBe('chevron-right');
+ });
+
+ describe('when squash is disabled', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has commits count message showing correct amount of commits', () => {
+ expect(findCommitsCountMessage().text()).toBe('5 commits');
+ });
+
+ it('has button with modify merge commit message', () => {
+ expect(findModifyButton().text()).toBe('Modify merge commit');
+ });
+ });
+
+ describe('when squash is enabled', () => {
+ beforeEach(() => {
+ createComponent({ isSquashEnabled: true });
+ });
+
+ it('has commits count message showing one commit when squash is enabled', () => {
+ expect(findCommitsCountMessage().text()).toBe('1 commit');
+ });
+
+ it('has button with modify commit messages text', () => {
+ expect(findModifyButton().text()).toBe('Modify commit messages');
+ });
+ });
+
+ it('has correct target branch displayed', () => {
+ createComponent();
+
+ expect(findTargetBranchMessage().text()).toBe('master');
+ });
+ });
+
+ describe('when expanded', () => {
+ beforeEach(() => {
+ createComponent();
+ wrapper.setData({ expanded: true });
+ });
+
+ it('toggle has aria-label equal to collapse', done => {
+ wrapper.vm.$nextTick(() => {
+ expect(findCommitToggle().attributes('aria-label')).toBe('Collapse');
+ done();
+ });
+ });
+
+ it('has a chevron-down icon', done => {
+ wrapper.vm.$nextTick(() => {
+ expect(findIcon().props('name')).toBe('chevron-down');
+ done();
+ });
+ });
+
+ it('has a collapse text', done => {
+ wrapper.vm.$nextTick(() => {
+ expect(findHeaderWrapper().text()).toBe('Collapse');
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index e387367d1a2..631da202d1d 100644
--- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -1,10 +1,14 @@
import Vue from 'vue';
import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue';
import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue';
+import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue';
+import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue';
+import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
import { createLocalVue, shallowMount } from '@vue/test-utils';
const commitMessage = 'This is the commit message';
+const squashCommitMessage = 'This is the squash commit message';
const commitMessageWithDescription = 'This is the commit message description';
const createTestMr = customConfig => {
const mr = {
@@ -19,9 +23,11 @@ const createTestMr = customConfig => {
sha: '12345678',
squash: false,
commitMessage,
+ squashCommitMessage,
commitMessageWithDescription,
shouldRemoveSourceBranch: true,
canRemoveSourceBranch: false,
+ targetBranch: 'master',
};
Object.assign(mr, customConfig.mr);
@@ -98,21 +104,6 @@ describe('ReadyToMerge', () => {
});
});
- describe('commitMessageLinkTitle', () => {
- const withDesc = 'Include description in commit message';
- const withoutDesc = "Don't include description in commit message";
-
- it('should return message with description', () => {
- expect(vm.commitMessageLinkTitle).toEqual(withDesc);
- });
-
- it('should return message without description', () => {
- vm.useCommitMessageWithDescription = true;
-
- expect(vm.commitMessageLinkTitle).toEqual(withoutDesc);
- });
- });
-
describe('status', () => {
it('defaults to success', () => {
vm.mr.pipeline = true;
@@ -279,55 +270,43 @@ describe('ReadyToMerge', () => {
vm.mr.isMergeAllowed = false;
vm.mr.isPipelineActive = false;
- expect(vm.shouldShowMergeControls()).toBeFalsy();
+ expect(vm.shouldShowMergeControls).toBeFalsy();
});
it('should return true when the build succeeded or build not required to succeed', () => {
vm.mr.isMergeAllowed = true;
vm.mr.isPipelineActive = false;
- expect(vm.shouldShowMergeControls()).toBeTruthy();
+ expect(vm.shouldShowMergeControls).toBeTruthy();
});
it('should return true when showing the MWPS button and a pipeline is running that needs to be successful', () => {
vm.mr.isMergeAllowed = false;
vm.mr.isPipelineActive = true;
- expect(vm.shouldShowMergeControls()).toBeTruthy();
+ expect(vm.shouldShowMergeControls).toBeTruthy();
});
it('should return true when showing the MWPS button but not required for the pipeline to succeed', () => {
vm.mr.isMergeAllowed = true;
vm.mr.isPipelineActive = true;
- expect(vm.shouldShowMergeControls()).toBeTruthy();
+ expect(vm.shouldShowMergeControls).toBeTruthy();
});
});
- describe('updateCommitMessage', () => {
+ describe('updateMergeCommitMessage', () => {
it('should revert flag and change commitMessage', () => {
- expect(vm.useCommitMessageWithDescription).toBeFalsy();
expect(vm.commitMessage).toEqual(commitMessage);
- vm.updateCommitMessage();
+ vm.updateMergeCommitMessage(true);
- expect(vm.useCommitMessageWithDescription).toBeTruthy();
expect(vm.commitMessage).toEqual(commitMessageWithDescription);
- vm.updateCommitMessage();
+ vm.updateMergeCommitMessage(false);
- expect(vm.useCommitMessageWithDescription).toBeFalsy();
expect(vm.commitMessage).toEqual(commitMessage);
});
});
- describe('toggleCommitMessageEditor', () => {
- it('should toggle showCommitMessageEditor flag', () => {
- expect(vm.showCommitMessageEditor).toBeFalsy();
- vm.toggleCommitMessageEditor();
-
- expect(vm.showCommitMessageEditor).toBeTruthy();
- });
- });
-
describe('handleMergeButtonClick', () => {
const returnPromise = status =>
new Promise(resolve => {
@@ -623,7 +602,7 @@ describe('ReadyToMerge', () => {
});
});
- describe('Squash checkbox component', () => {
+ describe('render children components', () => {
let wrapper;
const localVue = createLocalVue();
@@ -642,25 +621,101 @@ describe('ReadyToMerge', () => {
});
const findCheckboxElement = () => wrapper.find(SquashBeforeMerge);
+ const findCommitsHeaderElement = () => wrapper.find(CommitsHeader);
+ const findCommitEditElements = () => wrapper.findAll(CommitEdit);
+ const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown);
+
+ describe('squash checkbox', () => {
+ it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => {
+ createLocalComponent({
+ mr: { commitsCount: 2, enableSquashBeforeMerge: true },
+ });
- it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => {
- createLocalComponent({
- mr: { commitsCount: 2, enableSquashBeforeMerge: true },
+ expect(findCheckboxElement().exists()).toBeTruthy();
});
- expect(findCheckboxElement().exists()).toBeTruthy();
+ it('should not be rendered when squash before merge is disabled', () => {
+ createLocalComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: false } });
+
+ expect(findCheckboxElement().exists()).toBeFalsy();
+ });
+
+ it('should not be rendered when there is only 1 commit', () => {
+ createLocalComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } });
+
+ expect(findCheckboxElement().exists()).toBeFalsy();
+ });
});
- it('should not be rendered when squash before merge is disabled', () => {
- createLocalComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: false } });
+ describe('commits count collapsible header', () => {
+ it('should be rendered if fast-forward is disabled', () => {
+ createLocalComponent();
- expect(findCheckboxElement().exists()).toBeFalsy();
+ expect(findCommitsHeaderElement().exists()).toBeTruthy();
+ });
+
+ it('should not be rendered if fast-forward is enabled', () => {
+ createLocalComponent({ mr: { ffOnlyEnabled: true } });
+
+ expect(findCommitsHeaderElement().exists()).toBeFalsy();
+ });
});
- it('should not be rendered when there is only 1 commit', () => {
- createLocalComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } });
+ describe('commits edit components', () => {
+ it('should have one edit component when squash is disabled', () => {
+ createLocalComponent();
+
+ expect(findCommitEditElements().length).toBe(1);
+ });
- expect(findCheckboxElement().exists()).toBeFalsy();
+ const findFirstCommitEditLabel = () =>
+ findCommitEditElements()
+ .at(0)
+ .props('label');
+
+ it('should have two edit components when squash is enabled', () => {
+ createLocalComponent({
+ mr: {
+ commitsCount: 2,
+ squash: true,
+ enableSquashBeforeMerge: true,
+ },
+ });
+
+ expect(findCommitEditElements().length).toBe(2);
+ });
+
+ it('should have correct edit merge commit label', () => {
+ createLocalComponent();
+
+ expect(findFirstCommitEditLabel()).toBe('Merge commit message');
+ });
+
+ it('should have correct edit squash commit label', () => {
+ createLocalComponent({
+ mr: {
+ commitsCount: 2,
+ squash: true,
+ enableSquashBeforeMerge: true,
+ },
+ });
+
+ expect(findFirstCommitEditLabel()).toBe('Squash commit message');
+ });
+ });
+
+ describe('commits dropdown', () => {
+ it('should not be rendered if squash is disabled', () => {
+ createLocalComponent();
+
+ expect(findCommitDropdownElement().exists()).toBeFalsy();
+ });
+
+ it('should be rendered if squash is enabled', () => {
+ createLocalComponent({ mr: { squash: true } });
+
+ expect(findCommitDropdownElement().exists()).toBeTruthy();
+ });
});
});
@@ -696,10 +751,6 @@ describe('ReadyToMerge', () => {
expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).toBeNull();
});
- it('does not show modify commit message button', () => {
- expect(vm.$el.querySelector('.js-modify-commit-message-button')).toBeNull();
- });
-
it('shows message to resolve all items before being allowed to merge', () => {
expect(vm.$el.querySelector('.js-resolve-mr-widget-items-message')).toBeDefined();
});
@@ -712,7 +763,7 @@ describe('ReadyToMerge', () => {
mr: { ffOnlyEnabled: false },
});
- expect(customVm.$el.querySelector('.js-fast-forward-message')).toBeNull();
+ expect(customVm.$el.querySelector('.mr-fast-forward-message')).toBeNull();
expect(customVm.$el.querySelector('.js-modify-commit-message-button')).toBeDefined();
});
@@ -721,7 +772,7 @@ describe('ReadyToMerge', () => {
mr: { ffOnlyEnabled: true },
});
- expect(customVm.$el.querySelector('.js-fast-forward-message')).toBeDefined();
+ expect(customVm.$el.querySelector('.mr-fast-forward-message')).toBeDefined();
expect(customVm.$el.querySelector('.js-modify-commit-message-button')).toBeNull();
});
});
diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js
index 072e98fc0e8..6ef07f81705 100644
--- a/spec/javascripts/vue_mr_widget/mock_data.js
+++ b/spec/javascripts/vue_mr_widget/mock_data.js
@@ -58,7 +58,7 @@ export default {
merge_user: null,
diff_head_sha: '104096c51715e12e7ae41f9333e9fa35b73f385d',
diff_head_commit_short_id: '104096c5',
- merge_commit_message:
+ default_merge_commit_message:
"Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
pipeline: {
id: 172,
@@ -213,14 +213,16 @@ export default {
merge_check_path: '/root/acets-app/merge_requests/22/merge_check',
ci_environments_status_url: '/root/acets-app/merge_requests/22/ci_environments_status',
project_archived: false,
- merge_commit_message_with_description:
+ default_merge_commit_message_with_description:
"Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
+ default_squash_commit_message: 'Test squash commit message',
diverged_commits_count: 0,
only_allow_merge_if_pipeline_succeeds: false,
commit_change_content_path: '/root/acets-app/merge_requests/22/commit_change_content',
merge_commit_path:
'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775',
troubleshooting_docs_path: 'help',
+ squash: true,
};
export const mockStore = {
diff --git a/spec/javascripts/ide/components/file_finder/index_spec.js b/spec/javascripts/vue_shared/components/file_finder/index_spec.js
index 15ef8c31f91..bae4741f652 100644
--- a/spec/javascripts/ide/components/file_finder/index_spec.js
+++ b/spec/javascripts/vue_shared/components/file_finder/index_spec.js
@@ -1,54 +1,51 @@
import Vue from 'vue';
-import store from '~/ide/stores';
-import FindFileComponent from '~/ide/components/file_finder/index.vue';
+import Mousetrap from 'mousetrap';
+import FindFileComponent from '~/vue_shared/components/file_finder/index.vue';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
-import router from '~/ide/ide_router';
-import { file, resetStore } from '../../helpers';
-import { mountComponentWithStore } from '../../../helpers/vue_mount_component_helper';
+import { file } from 'spec/ide/helpers';
+import timeoutPromise from 'spec/helpers/set_timeout_promise_helper';
-describe('IDE File finder item spec', () => {
+describe('File finder item spec', () => {
const Component = Vue.extend(FindFileComponent);
let vm;
- beforeEach(done => {
- setFixtures('<div id="app"></div>');
-
- vm = mountComponentWithStore(Component, {
- store,
- el: '#app',
- props: {
- index: 0,
+ function createComponent(props) {
+ vm = new Component({
+ propsData: {
+ files: [],
+ visible: true,
+ loading: false,
+ ...props,
},
});
- setTimeout(done);
+ vm.$mount('#app');
+ }
+
+ beforeEach(() => {
+ setFixtures('<div id="app"></div>');
});
afterEach(() => {
vm.$destroy();
-
- resetStore(vm.$store);
});
describe('with entries', () => {
beforeEach(done => {
- Vue.set(vm.$store.state.entries, 'folder', {
- ...file('folder'),
- path: 'folder',
- type: 'folder',
- });
-
- Vue.set(vm.$store.state.entries, 'index.js', {
- ...file('index.js'),
- path: 'index.js',
- type: 'blob',
- url: '/index.jsurl',
- });
-
- Vue.set(vm.$store.state.entries, 'component.js', {
- ...file('component.js'),
- path: 'component.js',
- type: 'blob',
+ createComponent({
+ files: [
+ {
+ ...file('index.js'),
+ path: 'index.js',
+ type: 'blob',
+ url: '/index.jsurl',
+ },
+ {
+ ...file('component.js'),
+ path: 'component.js',
+ type: 'blob',
+ },
+ ],
});
setTimeout(done);
@@ -56,13 +53,14 @@ describe('IDE File finder item spec', () => {
it('renders list of blobs', () => {
expect(vm.$el.textContent).toContain('index.js');
+ expect(vm.$el.textContent).toContain('component.js');
expect(vm.$el.textContent).not.toContain('folder');
});
it('filters entries', done => {
vm.searchText = 'index';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.$el.textContent).toContain('index.js');
expect(vm.$el.textContent).not.toContain('component.js');
@@ -73,8 +71,8 @@ describe('IDE File finder item spec', () => {
it('shows clear button when searchText is not empty', done => {
vm.searchText = 'index';
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.dropdown-input-clear').classList).toContain('show');
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.dropdown-input').classList).toContain('has-value');
expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden');
done();
@@ -84,11 +82,11 @@ describe('IDE File finder item spec', () => {
it('clear button resets searchText', done => {
vm.searchText = 'index';
- vm.$nextTick()
+ timeoutPromise()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
- .then(vm.$nextTick)
+ .then(timeoutPromise)
.then(() => {
expect(vm.searchText).toBe('');
})
@@ -100,11 +98,11 @@ describe('IDE File finder item spec', () => {
spyOn(vm.$refs.searchInput, 'focus');
vm.searchText = 'index';
- vm.$nextTick()
+ timeoutPromise()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
- .then(vm.$nextTick)
+ .then(timeoutPromise)
.then(() => {
expect(vm.$refs.searchInput.focus).toHaveBeenCalled();
})
@@ -116,7 +114,7 @@ describe('IDE File finder item spec', () => {
it('returns 1 when no filtered entries exist', done => {
vm.searchText = 'testing 123';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.listShowCount).toBe(1);
done();
@@ -136,7 +134,7 @@ describe('IDE File finder item spec', () => {
it('returns 33 when entries dont exist', done => {
vm.searchText = 'testing 123';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.listHeight).toBe(33);
done();
@@ -148,7 +146,7 @@ describe('IDE File finder item spec', () => {
it('returns length of filtered blobs', done => {
vm.searchText = 'index';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.filteredBlobsLength).toBe(1);
done();
@@ -162,7 +160,7 @@ describe('IDE File finder item spec', () => {
vm.focusedIndex = 1;
vm.searchText = 'test';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.focusedIndex).toBe(0);
done();
@@ -170,16 +168,16 @@ describe('IDE File finder item spec', () => {
});
});
- describe('fileFindVisible', () => {
+ describe('visible', () => {
it('returns searchText when false', done => {
vm.searchText = 'test';
- vm.$store.state.fileFindVisible = true;
+ vm.visible = true;
- vm.$nextTick()
+ timeoutPromise()
.then(() => {
- vm.$store.state.fileFindVisible = false;
+ vm.visible = false;
})
- .then(vm.$nextTick)
+ .then(timeoutPromise)
.then(() => {
expect(vm.searchText).toBe('');
})
@@ -191,20 +189,19 @@ describe('IDE File finder item spec', () => {
describe('openFile', () => {
beforeEach(() => {
- spyOn(router, 'push');
- spyOn(vm, 'toggleFileFinder');
+ spyOn(vm, '$emit');
});
it('closes file finder', () => {
- vm.openFile(vm.$store.state.entries['index.js']);
+ vm.openFile(vm.files[0]);
- expect(vm.toggleFileFinder).toHaveBeenCalled();
+ expect(vm.$emit).toHaveBeenCalledWith('toggle', false);
});
it('pushes to router', () => {
- vm.openFile(vm.$store.state.entries['index.js']);
+ vm.openFile(vm.files[0]);
- expect(router.push).toHaveBeenCalledWith('/project/index.jsurl');
+ expect(vm.$emit).toHaveBeenCalledWith('click', vm.files[0]);
});
});
@@ -217,8 +214,8 @@ describe('IDE File finder item spec', () => {
vm.$refs.searchInput.dispatchEvent(event);
- vm.$nextTick(() => {
- expect(vm.openFile).toHaveBeenCalledWith(vm.$store.state.entries['index.js']);
+ setTimeout(() => {
+ expect(vm.openFile).toHaveBeenCalledWith(vm.files[0]);
done();
});
@@ -228,12 +225,12 @@ describe('IDE File finder item spec', () => {
const event = new CustomEvent('keyup');
event.keyCode = ESC_KEY_CODE;
- spyOn(vm, 'toggleFileFinder');
+ spyOn(vm, '$emit');
vm.$refs.searchInput.dispatchEvent(event);
- vm.$nextTick(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(vm.$emit).toHaveBeenCalledWith('toggle', false);
done();
});
@@ -287,18 +284,85 @@ describe('IDE File finder item spec', () => {
});
describe('without entries', () => {
- it('renders loading text when loading', done => {
- store.state.loading = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.textContent).toContain('Loading...');
-
- done();
+ it('renders loading text when loading', () => {
+ createComponent({
+ loading: true,
});
+
+ expect(vm.$el.textContent).toContain('Loading...');
});
it('renders no files text', () => {
+ createComponent();
+
expect(vm.$el.textContent).toContain('No files found.');
});
});
+
+ describe('keyboard shortcuts', () => {
+ beforeEach(done => {
+ createComponent();
+
+ spyOn(vm, 'toggle');
+
+ vm.$nextTick(done);
+ });
+
+ it('calls toggle on `t` key press', done => {
+ Mousetrap.trigger('t');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.toggle).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('calls toggle on `command+p` key press', done => {
+ Mousetrap.trigger('command+p');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.toggle).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('calls toggle on `ctrl+p` key press', done => {
+ Mousetrap.trigger('ctrl+p');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.toggle).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('always allows `command+p` to trigger toggle', () => {
+ expect(
+ vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'),
+ ).toBe(false);
+ });
+
+ it('always allows `ctrl+p` to trigger toggle', () => {
+ expect(
+ vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'),
+ ).toBe(false);
+ });
+
+ it('onlys handles `t` when focused in input-field', () => {
+ expect(
+ vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
+ ).toBe(true);
+ });
+
+ it('stops callback in monaco editor', () => {
+ setFixtures('<div class="inputarea"></div>');
+
+ expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true);
+ });
+ });
});
diff --git a/spec/javascripts/ide/components/file_finder/item_spec.js b/spec/javascripts/vue_shared/components/file_finder/item_spec.js
index 0f1116c6912..c1511643a9d 100644
--- a/spec/javascripts/ide/components/file_finder/item_spec.js
+++ b/spec/javascripts/vue_shared/components/file_finder/item_spec.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
-import ItemComponent from '~/ide/components/file_finder/item.vue';
-import { file } from '../../helpers';
+import ItemComponent from '~/vue_shared/components/file_finder/item.vue';
+import { file } from 'spec/ide/helpers';
import createComponent from '../../../helpers/vue_mount_component_helper';
-describe('IDE File finder item spec', () => {
+describe('File finder item spec', () => {
const Component = Vue.extend(ItemComponent);
let vm;
let localFile;
diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
index de3e0c149de..e8b41e8eeff 100644
--- a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
@@ -122,7 +122,7 @@ describe('User Popover Component', () => {
describe('status data', () => {
it('should show only message', () => {
const testProps = Object.assign({}, DEFAULT_PROPS);
- testProps.user.status = { message: 'Hello World' };
+ testProps.user.status = { message_html: 'Hello World' };
vm = mountComponent(UserPopover, {
...DEFAULT_PROPS,
@@ -134,12 +134,12 @@ describe('User Popover Component', () => {
it('should show message and emoji', () => {
const testProps = Object.assign({}, DEFAULT_PROPS);
- testProps.user.status = { emoji: 'basketball_player', message: 'Hello World' };
+ testProps.user.status = { emoji: 'basketball_player', message_html: 'Hello World' };
vm = mountComponent(UserPopover, {
...DEFAULT_PROPS,
target: document.querySelector('.js-user-link'),
- status: { emoji: 'basketball_player', message: 'Hello World' },
+ status: { emoji: 'basketball_player', message_html: 'Hello World' },
});
expect(vm.$el.textContent).toContain('Hello World');
diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb
index e1aea82653d..08165f147bb 100644
--- a/spec/lib/api/helpers_spec.rb
+++ b/spec/lib/api/helpers_spec.rb
@@ -179,7 +179,7 @@ describe API::Helpers do
context 'when blob name is not null' do
it 'returns disposition with the blob name' do
- expect(send_git_blob['Content-Disposition']).to eq 'inline; filename="foobar"'
+ expect(send_git_blob['Content-Disposition']).to eq %q(inline; filename="foobar"; filename*=UTF-8''foobar)
end
end
end
diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb
index 7a457403b51..4972c4b4bd2 100644
--- a/spec/lib/banzai/filter/autolink_filter_spec.rb
+++ b/spec/lib/banzai/filter/autolink_filter_spec.rb
@@ -121,6 +121,13 @@ describe Banzai::Filter::AutolinkFilter do
expect(doc.to_s).to eq("See #{link}")
end
+ it 'does not autolink bad URLs after we remove trailing punctuation' do
+ link = 'http://]'
+ doc = filter("See #{link}")
+
+ expect(doc.to_s).to eq("See #{link}")
+ end
+
it 'does not include trailing punctuation' do
['.', ', ok?', '...', '?', '!', ': is that ok?'].each do |trailing_punctuation|
doc = filter("See #{link}#{trailing_punctuation}")
@@ -188,6 +195,22 @@ describe Banzai::Filter::AutolinkFilter do
expect(doc.at_css('a')['class']).to eq 'custom'
end
+ it 'escapes RTLO and other characters' do
+ # rendered text looks like "http://example.com/evilexe.mp3"
+ evil_link = "#{link}evil\u202E3pm.exe"
+ doc = filter("#{evil_link}")
+
+ expect(doc.at_css('a')['href']).to eq "http://about.gitlab.com/evil%E2%80%AE3pm.exe"
+ end
+
+ it 'encodes international domains' do
+ link = "http://one😄two.com"
+ expected = "http://one%F0%9F%98%84two.com"
+ doc = filter(link)
+
+ expect(doc.at_css('a')['href']).to eq expected
+ end
+
described_class::IGNORE_PARENTS.each do |elem|
it "ignores valid links contained inside '#{elem}' element" do
exp = act = "<#{elem}>See #{link}</#{elem}>"
diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb
index e6dae8d5382..2acbe05f082 100644
--- a/spec/lib/banzai/filter/external_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/external_link_filter_spec.rb
@@ -62,6 +62,13 @@ describe Banzai::Filter::ExternalLinkFilter do
expect(doc.to_html).to eq(expected)
end
+
+ it 'adds rel and target to improperly formatted autolinks' do
+ doc = filter %q(<p><a href="mailto://jblogs@example.com">mailto://jblogs@example.com</a></p>)
+ expected = %q(<p><a href="mailto://jblogs@example.com" rel="nofollow noreferrer noopener" target="_blank">mailto://jblogs@example.com</a></p>)
+
+ expect(doc.to_html).to eq(expected)
+ end
end
context 'for links with a username' do
@@ -112,4 +119,62 @@ describe Banzai::Filter::ExternalLinkFilter do
it_behaves_like 'an external link with rel attribute'
end
+
+ context 'links with RTLO character' do
+ # In rendered text this looks like "http://example.com/evilexe.mp3"
+ let(:doc) { filter %Q(<a href="http://example.com/evil%E2%80%AE3pm.exe">http://example.com/evil\u202E3pm.exe</a>) }
+
+ it_behaves_like 'an external link with rel attribute'
+
+ it 'escapes RTLO in link text' do
+ expected = %q(http://example.com/evil%E2%80%AE3pm.exe</a>)
+
+ expect(doc.to_html).to include(expected)
+ end
+
+ it 'does not mangle the link text' do
+ doc = filter %Q(<a href="http://example.com">One<span>and</span>\u202Eexe.mp3</a>)
+
+ expect(doc.to_html).to include('One<span>and</span>%E2%80%AEexe.mp3</a>')
+ end
+ end
+
+ context 'for generated autolinks' do
+ context 'with an IDN character' do
+ let(:doc) { filter(%q(<a href="http://exa%F0%9F%98%84mple.com">http://exa😄mple.com</a>)) }
+ let(:doc_email) { filter(%q(<a href="http://exa%F0%9F%98%84mple.com">http://exa😄mple.com</a>), emailable_links: true) }
+
+ it_behaves_like 'an external link with rel attribute'
+
+ it 'does not change the link text' do
+ expect(doc.to_html).to include('http://exa😄mple.com</a>')
+ end
+
+ it 'uses punycode for emails' do
+ expect(doc_email.to_html).to include('http://xn--example-6p25f.com/</a>')
+ end
+ end
+ end
+
+ context 'for links that look malicious' do
+ context 'with an IDN character' do
+ let(:doc) { filter %q(<a href="http://exa%F0%9F%98%84mple.com">http://exa😄mple.com</a>) }
+
+ it 'adds a toolip with punycode' do
+ expect(doc.to_html).to include('http://exa😄mple.com</a>')
+ expect(doc.to_html).to include('class="has-tooltip"')
+ expect(doc.to_html).to include('title="http://xn--example-6p25f.com/"')
+ end
+ end
+
+ context 'with RTLO character' do
+ let(:doc) { filter %q(<a href="http://example.com/evil%E2%80%AE3pm.exe">Evil Test</a>) }
+
+ it 'adds a toolip with punycode' do
+ expect(doc.to_html).to include('Evil Test</a>')
+ expect(doc.to_html).to include('class="has-tooltip"')
+ expect(doc.to_html).to include('title="http://example.com/evil%E2%80%AE3pm.exe"')
+ end
+ end
+ end
end
diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb
index 4c4e821deab..83fcda29680 100644
--- a/spec/lib/banzai/filter/markdown_filter_spec.rb
+++ b/spec/lib/banzai/filter/markdown_filter_spec.rb
@@ -10,12 +10,6 @@ describe Banzai::Filter::MarkdownFilter do
filter('test')
end
- it 'uses Redcarpet' do
- expect_any_instance_of(Banzai::Filter::MarkdownEngines::Redcarpet).to receive(:render).and_return('test')
-
- filter('test', { markdown_engine: :redcarpet })
- end
-
it 'uses CommonMark' do
expect_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark).to receive(:render).and_return('test')
@@ -47,24 +41,6 @@ describe Banzai::Filter::MarkdownFilter do
expect(result).to start_with('<pre><code lang="æ—¥">')
end
end
-
- context 'using Redcarpet' do
- before do
- stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :redcarpet)
- end
-
- it 'adds language to lang attribute when specified' do
- result = filter("```html\nsome code\n```")
-
- expect(result).to start_with("\n<pre><code lang=\"html\">")
- end
-
- it 'does not add language to lang attribute when not specified' do
- result = filter("```\nsome code\n```")
-
- expect(result).to start_with("\n<pre><code>")
- end
- end
end
describe 'source line position' do
@@ -85,18 +61,6 @@ describe Banzai::Filter::MarkdownFilter do
expect(result).to eq '<p>test</p>'
end
end
-
- context 'using Redcarpet' do
- before do
- stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :redcarpet)
- end
-
- it 'does not support data-sourcepos' do
- result = filter('test')
-
- expect(result).to eq '<p>test</p>'
- end
- end
end
describe 'footnotes in tables' do
diff --git a/spec/lib/banzai/filter/project_reference_filter_spec.rb b/spec/lib/banzai/filter/project_reference_filter_spec.rb
index c68d49f9366..69f9c1ae829 100644
--- a/spec/lib/banzai/filter/project_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/project_reference_filter_spec.rb
@@ -26,6 +26,12 @@ describe Banzai::Filter::ProjectReferenceFilter do
expect(reference_filter(act).to_html).to eq(CGI.escapeHTML(exp))
end
+ it 'fails fast for long invalid string' do
+ expect do
+ Timeout.timeout(5.seconds) { reference_filter("A" * 50000).to_html }
+ end.not_to raise_error
+ end
+
it 'allows references with text after the > character' do
doc = reference_filter("Hey #{reference}foo")
expect(doc.css('a').first.attr('href')).to eq urls.project_url(subject)
diff --git a/spec/lib/banzai/filter/spaced_link_filter_spec.rb b/spec/lib/banzai/filter/spaced_link_filter_spec.rb
index 1ad7f3ff567..76d7644d76c 100644
--- a/spec/lib/banzai/filter/spaced_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/spaced_link_filter_spec.rb
@@ -26,11 +26,6 @@ describe Banzai::Filter::SpacedLinkFilter do
expect(doc.at_css('p')).to be_nil
end
- it 'does nothing when markdown_engine is redcarpet' do
- exp = act = link
- expect(filter(act, markdown_engine: :redcarpet).to_html).to eq exp
- end
-
it 'does nothing with empty text' do
link = '[](page slug)'
doc = filter("See #{link}")
diff --git a/spec/lib/banzai/object_renderer_spec.rb b/spec/lib/banzai/object_renderer_spec.rb
index 209a547c3b3..3b52f6666d0 100644
--- a/spec/lib/banzai/object_renderer_spec.rb
+++ b/spec/lib/banzai/object_renderer_spec.rb
@@ -11,7 +11,7 @@ describe Banzai::ObjectRenderer do
)
end
- let(:object) { Note.new(note: 'hello', note_html: '<p dir="auto">hello</p>', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) }
+ let(:object) { Note.new(note: 'hello', note_html: '<p dir="auto">hello</p>', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) }
describe '#render' do
context 'with cache' do
diff --git a/spec/lib/banzai/pipeline/email_pipeline_spec.rb b/spec/lib/banzai/pipeline/email_pipeline_spec.rb
index 6a11ca2f9d5..b99161109eb 100644
--- a/spec/lib/banzai/pipeline/email_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/email_pipeline_spec.rb
@@ -10,5 +10,19 @@ describe Banzai::Pipeline::EmailPipeline do
expect(described_class.filters).not_to be_empty
expect(described_class.filters).not_to include(Banzai::Filter::ImageLazyLoadFilter)
end
+
+ it 'shows punycode for autolinks' do
+ examples = %W[
+ http://one😄two.com
+ http://\u0261itlab.com
+ ]
+
+ examples.each do |markdown|
+ result = described_class.call(markdown, project: nil)[:output]
+ link = result.css('a').first
+
+ expect(link.content).to include('http://xn--')
+ end
+ end
end
end
diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb
index aa503b6e1d5..3d3aa64d630 100644
--- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb
@@ -59,4 +59,42 @@ describe Banzai::Pipeline::FullPipeline do
expect(html.lines.map(&:strip).join("\n")).to eq filtered_footnote
end
end
+
+ describe 'links are detected as malicious' do
+ it 'has tooltips for malicious links' do
+ examples = %W[
+ http://example.com/evil\u202E3pm.exe
+ [evilexe.mp3](http://example.com/evil\u202E3pm.exe)
+ rdar://localhost.com/\u202E3pm.exe
+ http://one😄two.com
+ [Evil-Test](http://one😄two.com)
+ http://\u0261itlab.com
+ [Evil-GitLab-link](http://\u0261itlab.com)
+ ![Evil-GitLab-link](http://\u0261itlab.com.png)
+ ]
+
+ examples.each do |markdown|
+ result = described_class.call(markdown, project: nil)[:output]
+ link = result.css('a').first
+
+ expect(link[:class]).to include('has-tooltip')
+ end
+ end
+
+ it 'has no tooltips for safe links' do
+ examples = %w[
+ http://example.com
+ [Safe-Test](http://example.com)
+ https://commons.wikimedia.org/wiki/File:اسكرام_2_-_تمنراست.jpg
+ [Wikipedia-link](https://commons.wikimedia.org/wiki/File:اسكرام_2_-_تمنراست.jpg)
+ ]
+
+ examples.each do |markdown|
+ result = described_class.call(markdown, project: nil)[:output]
+ link = result.css('a').first
+
+ expect(link[:class]).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index 236808c0b69..a4a6338961e 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -19,7 +19,7 @@ describe Gitlab::Auth do
it 'optional_scopes contains all non-default scopes' do
stub_container_registry_config(enabled: true)
- expect(subject.optional_scopes).to eq %i[read_user sudo read_repository read_registry openid]
+ expect(subject.optional_scopes).to eq %i[read_user sudo read_repository read_registry openid profile email]
end
context 'registry_scopes' do
diff --git a/spec/lib/gitlab/background_migration_spec.rb b/spec/lib/gitlab/background_migration_spec.rb
index 8a83b76fd94..7d3d8a949ef 100644
--- a/spec/lib/gitlab/background_migration_spec.rb
+++ b/spec/lib/gitlab/background_migration_spec.rb
@@ -104,6 +104,38 @@ describe Gitlab::BackgroundMigration do
end
end
end
+
+ context 'when retry_dead_jobs is true', :sidekiq, :redis do
+ let(:retry_queue) do
+ [double(args: ['Object', [3]], queue: described_class.queue, delete: true)]
+ end
+ let(:dead_queue) do
+ [double(args: ['Object', [4]], queue: described_class.queue, delete: true)]
+ end
+
+ before do
+ allow(Sidekiq::RetrySet).to receive(:new).and_return(retry_queue)
+ allow(Sidekiq::DeadSet).to receive(:new).and_return(dead_queue)
+ end
+
+ it 'steals from the dead and retry queue' do
+ Sidekiq::Testing.disable! do
+ expect(described_class).to receive(:perform)
+ .with('Object', [1]).ordered
+ expect(described_class).to receive(:perform)
+ .with('Object', [2]).ordered
+ expect(described_class).to receive(:perform)
+ .with('Object', [3]).ordered
+ expect(described_class).to receive(:perform)
+ .with('Object', [4]).ordered
+
+ BackgroundMigrationWorker.perform_async('Object', [2])
+ BackgroundMigrationWorker.perform_in(10.minutes, 'Object', [1])
+
+ described_class.steal('Object', retry_dead_jobs: true)
+ end
+ end
+ end
end
describe '.perform' do
diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb
index afd8f5da39f..a07c5371134 100644
--- a/spec/lib/gitlab/bare_repository_import/repository_spec.rb
+++ b/spec/lib/gitlab/bare_repository_import/repository_spec.rb
@@ -61,7 +61,7 @@ describe ::Gitlab::BareRepositoryImport::Repository do
let(:wiki_path) { File.join(root_path, "#{hashed_path}.wiki.git") }
before do
- gitlab_shell.create_repository(repository_storage, hashed_path)
+ gitlab_shell.create_repository(repository_storage, hashed_path, 'group/project')
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
repository = Rugged::Repository.new(repo_path)
repository.config['gitlab.fullpath'] = 'to/repo'
diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
index 0def685f177..c432cc223b9 100644
--- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
@@ -218,7 +218,7 @@ describe Gitlab::BitbucketImport::Importer do
describe 'wiki import' do
it 'is skipped when the wiki exists' do
expect(project.wiki).to receive(:repository_exists?) { true }
- expect(importer.gitlab_shell).not_to receive(:import_repository)
+ expect(importer.gitlab_shell).not_to receive(:import_wiki_repository)
importer.execute
@@ -227,11 +227,7 @@ describe Gitlab::BitbucketImport::Importer do
it 'imports to the project disk_path' do
expect(project.wiki).to receive(:repository_exists?) { false }
- expect(importer.gitlab_shell).to receive(:import_repository).with(
- project.repository_storage,
- project.wiki.disk_path,
- project.import_url + '/wiki'
- )
+ expect(importer.gitlab_shell).to receive(:import_wiki_repository)
importer.execute
diff --git a/spec/lib/gitlab/bitbucket_import/wiki_formatter_spec.rb b/spec/lib/gitlab/bitbucket_import/wiki_formatter_spec.rb
new file mode 100644
index 00000000000..795fd069ab2
--- /dev/null
+++ b/spec/lib/gitlab/bitbucket_import/wiki_formatter_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Gitlab::BitbucketImport::WikiFormatter do
+ let(:project) do
+ create(:project,
+ namespace: create(:namespace, path: 'gitlabhq'),
+ import_url: 'https://xxx@bitbucket.org/gitlabhq/sample.gitlabhq.git')
+ end
+
+ subject(:wiki) { described_class.new(project) }
+
+ describe '#disk_path' do
+ it 'appends .wiki to disk path' do
+ expect(wiki.disk_path).to eq project.wiki.disk_path
+ end
+ end
+
+ describe '#full_path' do
+ it 'appends .wiki to project path' do
+ expect(wiki.full_path).to eq project.wiki.full_path
+ end
+ end
+
+ describe '#import_url' do
+ it 'returns URL of the wiki repository' do
+ expect(wiki.import_url).to eq 'https://xxx@bitbucket.org/gitlabhq/sample.gitlabhq.git/wiki'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb
index befdc18d1aa..0c4decc6518 100644
--- a/spec/lib/gitlab/data_builder/push_spec.rb
+++ b/spec/lib/gitlab/data_builder/push_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::DataBuilder::Push do
let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ let(:user) { build(:user, public_email: 'public-email@example.com') }
describe '.build_sample' do
let(:data) { described_class.build_sample(project, user) }
@@ -36,7 +36,7 @@ describe Gitlab::DataBuilder::Push do
it { expect(data[:user_id]).to eq(user.id) }
it { expect(data[:user_name]).to eq(user.name) }
it { expect(data[:user_username]).to eq(user.username) }
- it { expect(data[:user_email]).to eq(user.email) }
+ it { expect(data[:user_email]).to eq(user.public_email) }
it { expect(data[:user_avatar]).to eq(user.avatar_url) }
it { expect(data[:project_id]).to eq(project.id) }
it { expect(data[:project]).to be_a(Hash) }
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index b1f48c15c21..50e473c459e 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -118,11 +118,44 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
end
end
- context "when everything is fine" do
+ shared_examples "checks permissions on noteable" do
+ context "when user has access" do
+ before do
+ project.add_reporter(user)
+ end
+
+ it "creates a comment" do
+ expect { receiver.execute }.to change { noteable.notes.count }.by(1)
+ end
+ end
+
+ context "when user does not have access" do
+ it "raises UserNotAuthorizedError" do
+ expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotAuthorizedError)
+ end
+ end
+ end
+
+ context "when discussion is locked" do
before do
- setup_attachment
+ noteable.update_attribute(:discussion_locked, true)
+ end
+
+ it_behaves_like "checks permissions on noteable"
+ end
+
+ context "when issue is confidential" do
+ let(:issue) { create(:issue, project: project) }
+ let(:note) { create(:note, noteable: issue, project: project) }
+
+ before do
+ issue.update_attribute(:confidential, true)
end
+ it_behaves_like "checks permissions on noteable"
+ end
+
+ shared_examples 'a reply to existing comment' do
it "creates a comment" do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
new_note = noteable.notes.last
@@ -131,8 +164,22 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
expect(new_note.position).to eq(note.position)
expect(new_note.note).to include("I could not disagree more.")
expect(new_note.in_reply_to?(note)).to be_truthy
+
+ if note.part_of_discussion?
+ expect(new_note.discussion_id).to eq(note.discussion_id)
+ else
+ expect(new_note.discussion_id).not_to eq(note.discussion_id)
+ end
+ end
+ end
+
+ context "when everything is fine" do
+ before do
+ setup_attachment
end
+ it_behaves_like 'a reply to existing comment'
+
it "adds all attachments" do
receiver.execute
@@ -170,4 +217,10 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
end
end
end
+
+ context "when note is not a discussion" do
+ let(:note) { create(:note_on_merge_request, project: project) }
+
+ it_behaves_like 'a reply to existing comment'
+ end
end
diff --git a/spec/lib/gitlab/git/blame_spec.rb b/spec/lib/gitlab/git/blame_spec.rb
index e704d1c673c..0010c0304eb 100644
--- a/spec/lib/gitlab/git/blame_spec.rb
+++ b/spec/lib/gitlab/git/blame_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
describe Gitlab::Git::Blame, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:blame) do
Gitlab::Git::Blame.new(repository, SeedRepo::Commit::ID, "CONTRIBUTING.md")
end
diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb
index 1bcec04d28f..a1b5cea88c0 100644
--- a/spec/lib/gitlab/git/blob_spec.rb
+++ b/spec/lib/gitlab/git/blob_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
describe Gitlab::Git::Blob, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:rugged) do
Rugged::Repository.new(File.join(TestEnv.repos_path, TEST_REPO_PATH))
end
diff --git a/spec/lib/gitlab/git/branch_spec.rb b/spec/lib/gitlab/git/branch_spec.rb
index 0df282d0ae3..0764e525ede 100644
--- a/spec/lib/gitlab/git/branch_spec.rb
+++ b/spec/lib/gitlab/git/branch_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
describe Gitlab::Git::Branch, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:rugged) do
Rugged::Repository.new(File.join(TestEnv.repos_path, repository.relative_path))
end
@@ -64,7 +64,7 @@ describe Gitlab::Git::Branch, :seed_helper do
context 'with active, stale and future branches' do
let(:repository) do
- Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
+ Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '', 'group/project')
end
let(:user) { create(:user) }
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index db68062e433..2611ebed25b 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -3,7 +3,7 @@ require "spec_helper"
describe Gitlab::Git::Commit, :seed_helper do
include GitHelpers
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:rugged_repo) do
Rugged::Repository.new(File.join(TestEnv.repos_path, TEST_REPO_PATH))
end
@@ -146,7 +146,7 @@ describe Gitlab::Git::Commit, :seed_helper do
end
context 'with broken repo' do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_BROKEN_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_BROKEN_REPO_PATH, '', 'group/project') }
it 'returns nil' do
expect(described_class.find(repository, SeedRepo::Commit::ID)).to be_nil
diff --git a/spec/lib/gitlab/git/compare_spec.rb b/spec/lib/gitlab/git/compare_spec.rb
index 771c71a16a9..65dfb93d0db 100644
--- a/spec/lib/gitlab/git/compare_spec.rb
+++ b/spec/lib/gitlab/git/compare_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
describe Gitlab::Git::Compare, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: false) }
let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: true) }
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 8a4415506c4..1d22329b670 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
describe Gitlab::Git::Diff, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:gitaly_diff) do
Gitlab::GitalyClient::Diff.new(
from_path: '.gitmodules',
diff --git a/spec/lib/gitlab/git/remote_repository_spec.rb b/spec/lib/gitlab/git/remote_repository_spec.rb
index 53ed7c5a13a..e166628d4ca 100644
--- a/spec/lib/gitlab/git/remote_repository_spec.rb
+++ b/spec/lib/gitlab/git/remote_repository_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
describe Gitlab::Git::RemoteRepository, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
subject { described_class.new(repository) }
describe '#empty?' do
using RSpec::Parameterized::TableSyntax
where(:repository, :result) do
- Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') | false
- Gitlab::Git::Repository.new('default', 'does-not-exist.git', '') | true
+ Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') | false
+ Gitlab::Git::Repository.new('default', 'does-not-exist.git', '', 'group/project') | true
end
with_them do
@@ -44,11 +44,11 @@ describe Gitlab::Git::RemoteRepository, :seed_helper do
using RSpec::Parameterized::TableSyntax
where(:other_repository, :result) do
- repository | true
- Gitlab::Git::Repository.new(repository.storage, repository.relative_path, '') | true
- Gitlab::Git::Repository.new('broken', TEST_REPO_PATH, '') | false
- Gitlab::Git::Repository.new(repository.storage, 'wrong/relative-path.git', '') | false
- Gitlab::Git::Repository.new('broken', 'wrong/relative-path.git', '') | false
+ repository | true
+ Gitlab::Git::Repository.new(repository.storage, repository.relative_path, '', 'group/project') | true
+ Gitlab::Git::Repository.new('broken', TEST_REPO_PATH, '', 'group/project') | false
+ Gitlab::Git::Repository.new(repository.storage, 'wrong/relative-path.git', '', 'group/project') | false
+ Gitlab::Git::Repository.new('broken', 'wrong/relative-path.git', '', 'group/project') | false
end
with_them do
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index a19e3e84f83..cf9e0cccc71 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -19,8 +19,10 @@ describe Gitlab::Git::Repository, :seed_helper do
end
end
- let(:mutable_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') }
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:mutable_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '', 'group/project') }
+ let(:mutable_repository_path) { File.join(TestEnv.repos_path, mutable_repository.relative_path) }
+ let(:mutable_repository_rugged) { Rugged::Repository.new(mutable_repository_path) }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:repository_path) { File.join(TestEnv.repos_path, repository.relative_path) }
let(:repository_rugged) { Rugged::Repository.new(repository_path) }
let(:storage_path) { TestEnv.repos_path }
@@ -434,13 +436,13 @@ describe Gitlab::Git::Repository, :seed_helper do
describe '#fetch_repository_as_mirror' do
let(:new_repository) do
- Gitlab::Git::Repository.new('default', 'my_project.git', '')
+ Gitlab::Git::Repository.new('default', 'my_project.git', '', 'group/project')
end
subject { new_repository.fetch_repository_as_mirror(repository) }
before do
- Gitlab::Shell.new.create_repository('default', 'my_project')
+ Gitlab::Shell.new.create_repository('default', 'my_project', 'group/project')
end
after do
@@ -497,6 +499,48 @@ describe Gitlab::Git::Repository, :seed_helper do
end
end
+ describe '#search_files_by_content' do
+ let(:repository) { mutable_repository }
+ let(:repository_rugged) { mutable_repository_rugged }
+
+ before do
+ repository.create_branch('search-files-by-content-branch', 'master')
+ new_commit_edit_new_file_on_branch(repository_rugged, 'encoding/CHANGELOG', 'search-files-by-content-branch', 'committing something', 'search-files-by-content change')
+ new_commit_edit_new_file_on_branch(repository_rugged, 'anotherfile', 'search-files-by-content-branch', 'committing something', 'search-files-by-content change')
+ end
+
+ after do
+ ensure_seeds
+ end
+
+ shared_examples 'search files by content' do
+ it 'should have 2 items' do
+ expect(search_results.size).to eq(2)
+ end
+
+ it 'should have the correct matching line' do
+ expect(search_results).to contain_exactly("search-files-by-content-branch:encoding/CHANGELOG\u00001\u0000search-files-by-content change\n",
+ "search-files-by-content-branch:anotherfile\u00001\u0000search-files-by-content change\n")
+ end
+ end
+
+ it_should_behave_like 'search files by content' do
+ let(:search_results) do
+ repository.search_files_by_content('search-files-by-content', 'search-files-by-content-branch')
+ end
+ end
+
+ it_should_behave_like 'search files by content' do
+ let(:search_results) do
+ repository.gitaly_repository_client.search_files_by_content(
+ 'search-files-by-content-branch',
+ 'search-files-by-content',
+ chunked_response: false
+ )
+ end
+ end
+ end
+
describe '#find_remote_root_ref' do
it 'gets the remote root ref from GitalyClient' do
expect_any_instance_of(Gitlab::GitalyClient::RemoteService)
@@ -544,7 +588,7 @@ describe Gitlab::Git::Repository, :seed_helper do
# Add new commits so that there's a renamed file in the commit history
@commit_with_old_name_id = new_commit_edit_old_file(repository_rugged).oid
@rename_commit_id = new_commit_move_file(repository_rugged).oid
- @commit_with_new_name_id = new_commit_edit_new_file(repository_rugged).oid
+ @commit_with_new_name_id = new_commit_edit_new_file(repository_rugged, "encoding/CHANGELOG", "Edit encoding/CHANGELOG", "I'm a new changelog with different text").oid
end
after do
@@ -1230,7 +1274,7 @@ describe Gitlab::Git::Repository, :seed_helper do
end
describe '#gitattribute' do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_GITATTRIBUTES_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_GITATTRIBUTES_REPO_PATH, '', 'group/project') }
after do
ensure_seeds
@@ -1249,7 +1293,7 @@ describe Gitlab::Git::Repository, :seed_helper do
end
context 'without gitattributes file' do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
it 'returns nil' do
expect(repository.gitattribute("README.md", 'gitlab-language')).to eq(nil)
@@ -1513,7 +1557,7 @@ describe Gitlab::Git::Repository, :seed_helper do
context 'repository does not exist' do
it 'raises NoRepository and does not call Gitaly WriteConfig' do
- repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '')
+ repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project')
expect(repository.gitaly_repository_client).not_to receive(:write_config)
@@ -1803,7 +1847,7 @@ describe Gitlab::Git::Repository, :seed_helper do
out: '/dev/null',
err: '/dev/null')
- empty_repo = described_class.new('default', 'empty-repo.git', '')
+ empty_repo = described_class.new('default', 'empty-repo.git', '', 'group/empty-repo')
expect(empty_repo.checksum).to eq '0000000000000000000000000000000000000000'
end
@@ -1818,13 +1862,13 @@ describe Gitlab::Git::Repository, :seed_helper do
File.truncate(File.join(storage_path, 'non-valid.git/HEAD'), 0)
- non_valid = described_class.new('default', 'non-valid.git', '')
+ non_valid = described_class.new('default', 'non-valid.git', '', 'a/non-valid')
expect { non_valid.checksum }.to raise_error(Gitlab::Git::Repository::InvalidRepository)
end
it 'raises Gitlab::Git::Repository::NoRepository error when there is no repo' do
- broken_repo = described_class.new('default', 'a/path.git', '')
+ broken_repo = described_class.new('default', 'a/path.git', '', 'a/path')
expect { broken_repo.checksum }.to raise_error(Gitlab::Git::Repository::NoRepository)
end
@@ -1964,7 +2008,7 @@ describe Gitlab::Git::Repository, :seed_helper do
end
# Build the options hash that's passed to Rugged::Commit#create
- def commit_options(repo, index, message)
+ def commit_options(repo, index, target, ref, message)
options = {}
options[:tree] = index.write_tree(repo)
options[:author] = {
@@ -1978,8 +2022,8 @@ describe Gitlab::Git::Repository, :seed_helper do
time: Time.gm(2014, "mar", 3, 20, 15, 1)
}
options[:message] ||= message
- options[:parents] = repo.empty? ? [] : [repo.head.target].compact
- options[:update_ref] = "HEAD"
+ options[:parents] = repo.empty? ? [] : [target].compact
+ options[:update_ref] = ref
options
end
@@ -1995,6 +2039,8 @@ describe Gitlab::Git::Repository, :seed_helper do
options = commit_options(
repo,
index,
+ repo.head.target,
+ "HEAD",
"Edit CHANGELOG in its original location"
)
@@ -2003,19 +2049,24 @@ describe Gitlab::Git::Repository, :seed_helper do
end
# Writes a new commit to the repo and returns a Rugged::Commit. Replaces the
- # contents of encoding/CHANGELOG with new text.
- def new_commit_edit_new_file(repo)
- oid = repo.write("I'm a new changelog with different text", :blob)
+ # contents of the specified file_path with new text.
+ def new_commit_edit_new_file(repo, file_path, commit_message, text, branch = repo.head)
+ oid = repo.write(text, :blob)
index = repo.index
- index.read_tree(repo.head.target.tree)
- index.add(path: "encoding/CHANGELOG", oid: oid, mode: 0100644)
-
- options = commit_options(repo, index, "Edit encoding/CHANGELOG")
-
+ index.read_tree(branch.target.tree)
+ index.add(path: file_path, oid: oid, mode: 0100644)
+ options = commit_options(repo, index, branch.target, branch.canonical_name, commit_message)
sha = Rugged::Commit.create(repo, options)
repo.lookup(sha)
end
+ # Writes a new commit to the repo and returns a Rugged::Commit. Replaces the
+ # contents of encoding/CHANGELOG with new text.
+ def new_commit_edit_new_file_on_branch(repo, file_path, branch_name, commit_message, text)
+ branch = repo.branches[branch_name]
+ new_commit_edit_new_file(repo, file_path, commit_message, text, branch)
+ end
+
# Writes a new commit to the repo and returns a Rugged::Commit. Moves the
# CHANGELOG file to the encoding/ directory.
def new_commit_move_file(repo)
@@ -2027,7 +2078,7 @@ describe Gitlab::Git::Repository, :seed_helper do
index.add(path: "encoding/CHANGELOG", oid: oid, mode: 0100644)
index.remove("CHANGELOG")
- options = commit_options(repo, index, "Move CHANGELOG to encoding/")
+ options = commit_options(repo, index, repo.head.target, "HEAD", "Move CHANGELOG to encoding/")
sha = Rugged::Commit.create(repo, options)
repo.lookup(sha)
diff --git a/spec/lib/gitlab/git/tag_spec.rb b/spec/lib/gitlab/git/tag_spec.rb
index b51e3879f49..4c0291f64f0 100644
--- a/spec/lib/gitlab/git/tag_spec.rb
+++ b/spec/lib/gitlab/git/tag_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
describe Gitlab::Git::Tag, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
describe '#tags' do
describe 'first tag' do
diff --git a/spec/lib/gitlab/git/tree_spec.rb b/spec/lib/gitlab/git/tree_spec.rb
index bec875fb03d..4a4d69490a3 100644
--- a/spec/lib/gitlab/git/tree_spec.rb
+++ b/spec/lib/gitlab/git/tree_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
describe Gitlab::Git::Tree, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
context :repo do
let(:tree) { Gitlab::Git::Tree.where(repository, SeedRepo::Commit::ID) }
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index 3e34dd592f2..634c370d211 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -776,10 +776,13 @@ describe Gitlab::GitAccess do
it "has the correct permissions for #{role}s" do
if role == :admin
user.update_attribute(:admin, true)
+ project.add_guest(user)
else
project.add_role(user, role)
end
+ protected_branch.save
+
aggregate_failures do
matrix.each do |action, allowed|
check = -> { push_changes(changes[action]) }
@@ -861,25 +864,19 @@ describe Gitlab::GitAccess do
[%w(feature exact), ['feat*', 'wildcard']].each do |protected_branch_name, protected_branch_type|
context do
- before do
- create(:protected_branch, name: protected_branch_name, project: project)
- end
+ let(:protected_branch) { create(:protected_branch, :maintainers_can_push, name: protected_branch_name, project: project) }
run_permission_checks(permissions_matrix)
end
context "when developers are allowed to push into the #{protected_branch_type} protected branch" do
- before do
- create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project)
- end
+ let(:protected_branch) { create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project) }
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
end
context "developers are allowed to merge into the #{protected_branch_type} protected branch" do
- before do
- create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project)
- end
+ let(:protected_branch) { create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project) }
context "when a merge request exists for the given source/target branch" do
context "when the merge request is in progress" do
@@ -906,17 +903,13 @@ describe Gitlab::GitAccess do
end
context "when developers are allowed to push and merge into the #{protected_branch_type} protected branch" do
- before do
- create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project)
- end
+ let(:protected_branch) { create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project) }
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
end
context "when no one is allowed to push to the #{protected_branch_name} protected branch" do
- before do
- create(:protected_branch, :no_one_can_push, name: protected_branch_name, project: project)
- end
+ let(:protected_branch) { build(:protected_branch, :no_one_can_push, name: protected_branch_name, project: project) }
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false },
maintainer: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false },
diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
index aff47599ad6..d5508dbff5d 100644
--- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
@@ -33,7 +33,7 @@ describe Gitlab::GitalyClient::RemoteService do
end
describe '#fetch_internal_remote' do
- let(:remote_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') }
+ let(:remote_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '', 'group/project') }
it 'sends an fetch_internal_remote message and returns the result value' do
expect_any_instance_of(Gitaly::RemoteService::Stub)
diff --git a/spec/lib/gitlab/gitaly_client/util_spec.rb b/spec/lib/gitlab/gitaly_client/util_spec.rb
index 550db6db6d9..78a5e195ad1 100644
--- a/spec/lib/gitlab/gitaly_client/util_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/util_spec.rb
@@ -7,6 +7,7 @@ describe Gitlab::GitalyClient::Util do
let(:gl_repository) { 'project-1' }
let(:git_object_directory) { '.git/objects' }
let(:git_alternate_object_directory) { ['/dir/one', '/dir/two'] }
+ let(:gl_project_path) { 'namespace/myproject' }
let(:git_env) do
{
'GIT_OBJECT_DIRECTORY_RELATIVE' => git_object_directory,
@@ -15,7 +16,7 @@ describe Gitlab::GitalyClient::Util do
end
subject do
- described_class.repository(repository_storage, relative_path, gl_repository)
+ described_class.repository(repository_storage, relative_path, gl_repository, gl_project_path)
end
it 'creates a Gitaly::Repository with the given data' do
@@ -27,6 +28,7 @@ describe Gitlab::GitalyClient::Util do
expect(subject.gl_repository).to eq(gl_repository)
expect(subject.git_object_directory).to eq(git_object_directory)
expect(subject.git_alternate_object_directories).to eq(git_alternate_object_directory)
+ expect(subject.gl_project_path).to eq(gl_project_path)
end
end
end
diff --git a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb
index 4857f2afbe2..8fd328d9c1e 100644
--- a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb
@@ -2,20 +2,26 @@ require 'spec_helper'
describe Gitlab::GithubImport::Importer::LfsObjectImporter do
let(:project) { create(:project) }
- let(:download_link) { "http://www.gitlab.com/lfs_objects/oid" }
-
- let(:github_lfs_object) do
- Gitlab::GithubImport::Representation::LfsObject.new(
- oid: 'oid', download_link: download_link
- )
+ let(:lfs_attributes) do
+ {
+ oid: 'oid',
+ size: 1,
+ link: 'http://www.gitlab.com/lfs_objects/oid'
+ }
end
+ let(:lfs_download_object) { LfsDownloadObject.new(lfs_attributes) }
+ let(:github_lfs_object) { Gitlab::GithubImport::Representation::LfsObject.new(lfs_attributes) }
+
let(:importer) { described_class.new(github_lfs_object, project, nil) }
describe '#execute' do
it 'calls the LfsDownloadService with the lfs object attributes' do
- expect_any_instance_of(Projects::LfsPointers::LfsDownloadService)
- .to receive(:execute).with('oid', download_link)
+ allow(importer).to receive(:lfs_download_object).and_return(lfs_download_object)
+
+ service = double
+ expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).with(project, lfs_download_object).and_return(service)
+ expect(service).to receive(:execute)
importer.execute
end
diff --git a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
index 5f5c6b803c0..50442552eee 100644
--- a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
@@ -5,7 +5,15 @@ describe Gitlab::GithubImport::Importer::LfsObjectsImporter do
let(:client) { double(:client) }
let(:download_link) { "http://www.gitlab.com/lfs_objects/oid" }
- let(:github_lfs_object) { ['oid', download_link] }
+ let(:lfs_attributes) do
+ {
+ oid: 'oid',
+ size: 1,
+ link: 'http://www.gitlab.com/lfs_objects/oid'
+ }
+ end
+
+ let(:lfs_download_object) { LfsDownloadObject.new(lfs_attributes) }
describe '#parallel?' do
it 'returns true when running in parallel mode' do
@@ -48,7 +56,7 @@ describe Gitlab::GithubImport::Importer::LfsObjectsImporter do
allow(importer)
.to receive(:each_object_to_import)
- .and_yield(['oid', download_link])
+ .and_yield(lfs_download_object)
expect(Gitlab::GithubImport::Importer::LfsObjectImporter)
.to receive(:new)
@@ -71,7 +79,7 @@ describe Gitlab::GithubImport::Importer::LfsObjectsImporter do
allow(importer)
.to receive(:each_object_to_import)
- .and_yield(github_lfs_object)
+ .and_yield(lfs_download_object)
expect(Gitlab::GithubImport::ImportLfsObjectWorker)
.to receive(:perform_async)
diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
index 77f5b2ffa37..47233ea6ee2 100644
--- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
@@ -5,6 +5,14 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
let(:import_state) { double(:import_state) }
let(:client) { double(:client) }
+ let(:wiki) do
+ double(
+ :wiki,
+ disk_path: 'foo.wiki',
+ full_path: 'group/foo.wiki'
+ )
+ end
+
let(:project) do
double(
:project,
@@ -15,7 +23,9 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
repository: repository,
create_wiki: true,
import_state: import_state,
- lfs_enabled?: true
+ full_path: 'group/foo',
+ lfs_enabled?: true,
+ wiki: wiki
)
end
@@ -195,7 +205,7 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
it 'imports the wiki repository' do
expect(importer.gitlab_shell)
.to receive(:import_repository)
- .with('foo', 'foo.wiki', 'foo.wiki.git')
+ .with('foo', 'foo.wiki', 'foo.wiki.git', 'group/foo.wiki')
expect(importer.import_wiki_repository).to eq(true)
end
diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
index 242c16c4bdc..6084dc96410 100644
--- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
@@ -12,7 +12,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
]
RSpec::Mocks.with_temporary_scope do
- @project = create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project')
+ @project = create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project')
@shared = @project.import_export_shared
allow(@shared).to receive(:export_path).and_return('spec/lib/gitlab/import_export/')
@@ -40,7 +40,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
project = Project.find_by_path('project')
expect(project.project_feature.issues_access_level).to eq(ProjectFeature::DISABLED)
- expect(project.project_feature.builds_access_level).to eq(ProjectFeature::DISABLED)
+ expect(project.project_feature.builds_access_level).to eq(ProjectFeature::ENABLED)
expect(project.project_feature.snippets_access_level).to eq(ProjectFeature::ENABLED)
expect(project.project_feature.wiki_access_level).to eq(ProjectFeature::ENABLED)
expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED)
@@ -273,6 +273,11 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
it 'has group milestone' do
expect(project.group.milestones.size).to eq(results.fetch(:milestones, 0))
end
+
+ it 'has the correct visibility level' do
+ # INTERNAL in the `project.json`, group's is PRIVATE
+ expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
end
context 'Light JSON' do
@@ -347,7 +352,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
:issues_disabled,
name: 'project',
path: 'project',
- group: create(:group))
+ group: create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE))
end
before do
@@ -434,4 +439,58 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
end
end
end
+
+ describe '#restored_project' do
+ let(:project) { create(:project) }
+ let(:shared) { project.import_export_shared }
+ let(:tree_hash) { { 'visibility_level' => visibility } }
+ let(:restorer) { described_class.new(user: nil, shared: shared, project: project) }
+
+ before do
+ restorer.instance_variable_set(:@tree_hash, tree_hash)
+ end
+
+ context 'no group visibility' do
+ let(:visibility) { Gitlab::VisibilityLevel::PRIVATE }
+
+ it 'uses the project visibility' do
+ expect(restorer.restored_project.visibility_level).to eq(visibility)
+ end
+ end
+
+ context 'with group visibility' do
+ before do
+ group = create(:group, visibility_level: group_visibility)
+
+ project.update(group: group)
+ end
+
+ context 'private group visibility' do
+ let(:group_visibility) { Gitlab::VisibilityLevel::PRIVATE }
+ let(:visibility) { Gitlab::VisibilityLevel::PUBLIC }
+
+ it 'uses the group visibility' do
+ expect(restorer.restored_project.visibility_level).to eq(group_visibility)
+ end
+ end
+
+ context 'public group visibility' do
+ let(:group_visibility) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:visibility) { Gitlab::VisibilityLevel::PRIVATE }
+
+ it 'uses the project visibility' do
+ expect(restorer.restored_project.visibility_level).to eq(visibility)
+ end
+ end
+
+ context 'internal group visibility' do
+ let(:group_visibility) { Gitlab::VisibilityLevel::INTERNAL }
+ let(:visibility) { Gitlab::VisibilityLevel::PUBLIC }
+
+ it 'uses the group visibility' do
+ expect(restorer.restored_project.visibility_level).to eq(group_visibility)
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index fe2087e8fc3..baca8f6d542 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -603,5 +603,6 @@ ResourceLabelEvent:
ErrorTracking::ProjectErrorTrackingSetting:
- id
- api_url
-- enabled
- project_id
+- project_name
+- organization_name
diff --git a/spec/lib/gitlab/import_export/shared_spec.rb b/spec/lib/gitlab/import_export/shared_spec.rb
new file mode 100644
index 00000000000..f2d750c6595
--- /dev/null
+++ b/spec/lib/gitlab/import_export/shared_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+require 'fileutils'
+
+describe Gitlab::ImportExport::Shared do
+ let(:project) { build(:project) }
+ subject { project.import_export_shared }
+
+ describe '#error' do
+ let(:error) { StandardError.new('Error importing into /my/folder Permission denied @ unlink_internal - /var/opt/gitlab/gitlab-rails/shared/a/b/c/uploads/file') }
+
+ it 'filters any full paths' do
+ subject.error(error)
+
+ expect(subject.errors).to eq(['Error importing into [FILTERED] Permission denied @ unlink_internal - [FILTERED]'])
+ end
+
+ it 'calls the error logger with the full message' do
+ expect(subject).to receive(:log_error).with(hash_including(message: error.message))
+
+ subject.error(error)
+ end
+
+ it 'calls the debug logger with a backtrace' do
+ error.set_backtrace('backtrace')
+
+ expect(subject).to receive(:log_debug).with(hash_including(backtrace: 'backtrace'))
+
+ subject.error(error)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb
index 49d857d9483..76f8253ec9b 100644
--- a/spec/lib/gitlab/import_export/version_checker_spec.rb
+++ b/spec/lib/gitlab/import_export/version_checker_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
include ImportExport::CommonUtil
describe Gitlab::ImportExport::VersionChecker do
- let(:shared) { Gitlab::ImportExport::Shared.new(nil) }
+ let!(:shared) { Gitlab::ImportExport::Shared.new(nil) }
describe 'bundle a project Git repo' do
let(:version) { Gitlab::ImportExport.version }
diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
index c7f92cbb143..8433d40b2ea 100644
--- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
@@ -171,51 +171,6 @@ describe Gitlab::Kubernetes::Helm::Api do
end
end
- describe '#update' do
- let(:rbac) { false }
-
- let(:command) do
- Gitlab::Kubernetes::Helm::UpgradeCommand.new(
- application_name,
- chart: 'chart-name',
- files: files,
- rbac: rbac
- )
- end
-
- before do
- allow(namespace).to receive(:ensure_exists!).once
-
- allow(client).to receive(:update_config_map).and_return(nil)
- allow(client).to receive(:create_pod).and_return(nil)
- allow(client).to receive(:delete_pod).and_return(nil)
- end
-
- it 'ensures the namespace exists before creating the pod' do
- expect(namespace).to receive(:ensure_exists!).once.ordered
- expect(client).to receive(:create_pod).once.ordered
-
- subject.update(command)
- end
-
- it 'removes an existing pod before updating' do
- expect(client).to receive(:delete_pod).with('upgrade-app-name', 'gitlab-managed-apps').once.ordered
- expect(client).to receive(:create_pod).once.ordered
-
- subject.update(command)
- end
-
- it 'updates the config map on kubeclient when one exists' do
- resource = Gitlab::Kubernetes::ConfigMap.new(
- application_name, files
- ).generate
-
- expect(client).to receive(:update_config_map).with(resource).once
-
- subject.update(command)
- end
- end
-
describe '#status' do
let(:phase) { Gitlab::Kubernetes::Pod::RUNNING }
let(:pod) { Kubeclient::Resource.new(status: { phase: phase }) } # partial representation
diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
index 82ed4d47857..db76d5d207e 100644
--- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
@@ -21,6 +21,15 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
)
end
+ let(:tls_flags) do
+ <<~EOS.squish
+ --tls
+ --tls-ca-cert /data/helm/app-name/config/ca.pem
+ --tls-cert /data/helm/app-name/config/cert.pem
+ --tls-key /data/helm/app-name/config/key.pem
+ EOS
+ end
+
subject { install_command }
it_behaves_like 'helm commands' do
@@ -36,12 +45,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
let(:helm_install_comand) do
<<~EOS.squish
- helm install chart-name
- --name app-name
- --tls
- --tls-ca-cert /data/helm/app-name/config/ca.pem
- --tls-cert /data/helm/app-name/config/cert.pem
- --tls-key /data/helm/app-name/config/key.pem
+ helm upgrade app-name chart-name
+ --install
+ --reset-values
+ #{tls_flags}
--version 1.2.3
--set rbac.create\\=false,rbac.enabled\\=false
--namespace gitlab-managed-apps
@@ -66,12 +73,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
let(:helm_install_command) do
<<~EOS.squish
- helm install chart-name
- --name app-name
- --tls
- --tls-ca-cert /data/helm/app-name/config/ca.pem
- --tls-cert /data/helm/app-name/config/cert.pem
- --tls-key /data/helm/app-name/config/key.pem
+ helm upgrade app-name chart-name
+ --install
+ --reset-values
+ #{tls_flags}
--version 1.2.3
--set rbac.create\\=true,rbac.enabled\\=true
--namespace gitlab-managed-apps
@@ -95,12 +100,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
let(:helm_install_command) do
<<~EOS.squish
- helm install chart-name
- --name app-name
- --tls
- --tls-ca-cert /data/helm/app-name/config/ca.pem
- --tls-cert /data/helm/app-name/config/cert.pem
- --tls-key /data/helm/app-name/config/key.pem
+ helm upgrade app-name chart-name
+ --install
+ --reset-values
+ #{tls_flags}
--version 1.2.3
--set rbac.create\\=false,rbac.enabled\\=false
--namespace gitlab-managed-apps
@@ -120,15 +123,22 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done
helm repo add app-name https://repository.example.com
helm repo update
+ /bin/date
+ /bin/true
#{helm_install_command}
EOS
end
let(:helm_install_command) do
- <<~EOS.strip
- /bin/date
- /bin/true
- helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --install
+ --reset-values
+ #{tls_flags}
+ --version 1.2.3
+ --set rbac.create\\=false,rbac.enabled\\=false
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
EOS
end
end
@@ -145,14 +155,21 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
helm repo add app-name https://repository.example.com
helm repo update
#{helm_install_command}
+ /bin/date
+ /bin/false
EOS
end
let(:helm_install_command) do
- <<~EOS.strip
- helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml
- /bin/date
- /bin/false
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --install
+ --reset-values
+ #{tls_flags}
+ --version 1.2.3
+ --set rbac.create\\=false,rbac.enabled\\=false
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
EOS
end
end
@@ -174,8 +191,9 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
let(:helm_install_command) do
<<~EOS.squish
- helm install chart-name
- --name app-name
+ helm upgrade app-name chart-name
+ --install
+ --reset-values
--version 1.2.3
--set rbac.create\\=false,rbac.enabled\\=false
--namespace gitlab-managed-apps
@@ -201,12 +219,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
let(:helm_install_command) do
<<~EOS.squish
- helm install chart-name
- --name app-name
- --tls
- --tls-ca-cert /data/helm/app-name/config/ca.pem
- --tls-cert /data/helm/app-name/config/cert.pem
- --tls-key /data/helm/app-name/config/key.pem
+ helm upgrade app-name chart-name
+ --install
+ --reset-values
+ #{tls_flags}
--set rbac.create\\=false,rbac.enabled\\=false
--namespace gitlab-managed-apps
-f /data/helm/app-name/config/values.yaml
diff --git a/spec/lib/gitlab/kubernetes/helm/upgrade_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/upgrade_command_spec.rb
deleted file mode 100644
index 9b201dae417..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/upgrade_command_spec.rb
+++ /dev/null
@@ -1,140 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Gitlab::Kubernetes::Helm::UpgradeCommand do
- let(:application) { build(:clusters_applications_prometheus) }
- let(:files) { { 'ca.pem': 'some file content' } }
- let(:namespace) { ::Gitlab::Kubernetes::Helm::NAMESPACE }
- let(:rbac) { false }
- let(:upgrade_command) do
- described_class.new(
- application.name,
- chart: application.chart,
- files: files,
- rbac: rbac
- )
- end
-
- subject { upgrade_command }
-
- it_behaves_like 'helm commands' do
- let(:commands) do
- <<~EOS
- helm init --upgrade
- for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done
- helm upgrade #{application.name} #{application.chart} --tls --tls-ca-cert /data/helm/#{application.name}/config/ca.pem --tls-cert /data/helm/#{application.name}/config/cert.pem --tls-key /data/helm/#{application.name}/config/key.pem --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml
- EOS
- end
- end
-
- context 'rbac is true' do
- let(:rbac) { true }
-
- it_behaves_like 'helm commands' do
- let(:commands) do
- <<~EOS
- helm init --upgrade
- for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done
- helm upgrade #{application.name} #{application.chart} --tls --tls-ca-cert /data/helm/#{application.name}/config/ca.pem --tls-cert /data/helm/#{application.name}/config/cert.pem --tls-key /data/helm/#{application.name}/config/key.pem --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml
- EOS
- end
- end
- end
-
- context 'with an application with a repository' do
- let(:ci_runner) { create(:ci_runner) }
- let(:application) { build(:clusters_applications_runner, runner: ci_runner) }
- let(:upgrade_command) do
- described_class.new(
- application.name,
- chart: application.chart,
- files: files,
- rbac: rbac,
- repository: application.repository
- )
- end
-
- it_behaves_like 'helm commands' do
- let(:commands) do
- <<~EOS
- helm init --upgrade
- for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done
- helm repo add #{application.name} #{application.repository}
- helm upgrade #{application.name} #{application.chart} --tls --tls-ca-cert /data/helm/#{application.name}/config/ca.pem --tls-cert /data/helm/#{application.name}/config/cert.pem --tls-key /data/helm/#{application.name}/config/key.pem --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is no ca.pem file' do
- let(:files) { { 'file.txt': 'some content' } }
-
- it_behaves_like 'helm commands' do
- let(:commands) do
- <<~EOS
- helm init --upgrade
- for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done
- helm upgrade #{application.name} #{application.chart} --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml
- EOS
- end
- end
- end
-
- describe '#pod_resource' do
- subject { upgrade_command.pod_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a pod that uses the tiller serviceAccountName' do
- expect(subject.spec.serviceAccountName).to eq('tiller')
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it 'generates a pod that uses the default serviceAccountName' do
- expect(subject.spec.serviceAcccountName).to be_nil
- end
- end
- end
-
- describe '#config_map_resource' do
- let(:metadata) do
- {
- name: "values-content-configuration-#{application.name}",
- namespace: namespace,
- labels: { name: "values-content-configuration-#{application.name}" }
- }
- end
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) }
-
- it 'returns a KubeClient resource with config map content for the application' do
- expect(subject.config_map_resource).to eq(resource)
- end
- end
-
- describe '#rbac?' do
- subject { upgrade_command.rbac? }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it { is_expected.to be_truthy }
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it { is_expected.to be_falsey }
- end
- end
-
- describe '#pod_name' do
- it 'returns the pod name' do
- expect(subject.pod_name).to eq("upgrade-#{application.name}")
- end
- end
-end
diff --git a/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb b/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb
index 7723533aee2..7519707293c 100644
--- a/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb
@@ -10,11 +10,17 @@ describe Gitlab::LegacyGithubImport::WikiFormatter do
subject(:wiki) { described_class.new(project) }
describe '#disk_path' do
- it 'appends .wiki to project path' do
+ it 'appends .wiki to disk path' do
expect(wiki.disk_path).to eq project.wiki.disk_path
end
end
+ describe '#full_path' do
+ it 'appends .wiki to project path' do
+ expect(wiki.full_path).to eq project.wiki.full_path
+ end
+ end
+
describe '#import_url' do
it 'returns URL of the wiki repository' do
expect(wiki.import_url).to eq 'https://xxx@github.com/gitlabhq/sample.gitlabhq.wiki.git'
diff --git a/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb
index 771b633a2b9..4b03f3c2532 100644
--- a/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb
+++ b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb
@@ -37,7 +37,7 @@ describe Gitlab::Metrics::Samplers::UnicornSampler do
end
it 'updates metrics type unix and with addr' do
- labels = { type: 'unix', address: socket_address }
+ labels = { socket_type: 'unix', socket_address: socket_address }
expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active')
expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued')
@@ -69,7 +69,7 @@ describe Gitlab::Metrics::Samplers::UnicornSampler do
end
it 'updates metrics type unix and with addr' do
- labels = { type: 'tcp', address: tcp_socket_address }
+ labels = { socket_type: 'tcp', socket_address: tcp_socket_address }
expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active')
expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued')
diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb
index 57b0ef8d1ad..1cc2bde50e9 100644
--- a/spec/lib/gitlab/project_template_spec.rb
+++ b/spec/lib/gitlab/project_template_spec.rb
@@ -6,7 +6,12 @@ describe Gitlab::ProjectTemplate do
expected = [
described_class.new('rails', 'Ruby on Rails', 'Includes an MVC structure, .gitignore, Gemfile, and more great stuff', 'https://gitlab.com/gitlab-org/project-templates/rails'),
described_class.new('spring', 'Spring', 'Includes an MVC structure, .gitignore, Gemfile, and more great stuff', 'https://gitlab.com/gitlab-org/project-templates/spring'),
- described_class.new('express', 'NodeJS Express', 'Includes an MVC structure, .gitignore, Gemfile, and more great stuff', 'https://gitlab.com/gitlab-org/project-templates/express')
+ described_class.new('express', 'NodeJS Express', 'Includes an MVC structure, .gitignore, Gemfile, and more great stuff', 'https://gitlab.com/gitlab-org/project-templates/express'),
+ described_class.new('hugo', 'Pages/Hugo', 'Everything you need to get started using a Hugo Pages site.', 'https://gitlab.com/pages/hugo'),
+ described_class.new('jekyll', 'Pages/Jekyll', 'Everything you need to get started using a Jekyll Pages site.', 'https://gitlab.com/pages/jekyll'),
+ described_class.new('plainhtml', 'Pages/Plain HTML', 'Everything you need to get started using a plain HTML Pages site.', 'https://gitlab.com/pages/plain-html'),
+ described_class.new('gitbook', 'Pages/GitBook', 'Everything you need to get started using a GitBook Pages site.', 'https://gitlab.com/pages/gitbook'),
+ described_class.new('hexo', 'Pages/Hexo', 'Everything you need to get started using a plan Hexo Pages site.', 'https://gitlab.com/pages/hexo')
]
expect(described_class.all).to be_an(Array)
diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb
index 6ce9d515a0f..033e1bf81a1 100644
--- a/spec/lib/gitlab/shell_spec.rb
+++ b/spec/lib/gitlab/shell_spec.rb
@@ -412,7 +412,7 @@ describe Gitlab::Shell do
end
it 'creates a repository' do
- expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_truthy
+ expect(gitlab_shell.create_repository(repository_storage, repo_name, repo_name)).to be_truthy
expect(File.stat(created_path).mode & 0o777).to eq(0o770)
@@ -427,7 +427,7 @@ describe Gitlab::Shell do
# should cause #create_repository to fail.
FileUtils.touch(created_path)
- expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_falsy
+ expect(gitlab_shell.create_repository(repository_storage, repo_name, repo_name)).to be_falsy
end
end
@@ -474,13 +474,10 @@ describe Gitlab::Shell do
end
describe '#fork_repository' do
+ let(:target_project) { create(:project) }
+
subject do
- gitlab_shell.fork_repository(
- project.repository_storage,
- project.disk_path,
- 'nfs-file05',
- 'fork/path'
- )
+ gitlab_shell.fork_repository(project, target_project)
end
it 'returns true when the command succeeds' do
@@ -505,7 +502,7 @@ describe Gitlab::Shell do
it 'returns true when the command succeeds' do
expect_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:import_repository).with(import_url)
- result = gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url)
+ result = gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url, project.full_path)
expect(result).to be_truthy
end
@@ -516,7 +513,7 @@ describe Gitlab::Shell do
expect_any_instance_of(Gitlab::Shell::GitalyGitlabProjects).to receive(:output) { 'error'}
expect do
- gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url)
+ gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url, project.full_path)
end.to raise_error(Gitlab::Shell::Error, "error")
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 2a09f581f68..4f5993ba226 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -26,6 +26,8 @@ describe Gitlab::UsageData do
create(:clusters_applications_prometheus, :installed, cluster: gcp_cluster)
create(:clusters_applications_runner, :installed, cluster: gcp_cluster)
create(:clusters_applications_knative, :installed, cluster: gcp_cluster)
+
+ ProjectFeature.first.update_attribute('repository_access_level', 0)
end
subject { described_class.data }
@@ -112,6 +114,7 @@ describe Gitlab::UsageData do
projects_slack_notifications_active
projects_slack_slash_active
projects_prometheus_active
+ projects_with_repositories_enabled
pages_domains
protected_branches
releases
@@ -134,6 +137,7 @@ describe Gitlab::UsageData do
expect(count_data[:projects_jira_cloud_active]).to eq(1)
expect(count_data[:projects_slack_notifications_active]).to eq(2)
expect(count_data[:projects_slack_slash_active]).to eq(1)
+ expect(count_data[:projects_with_repositories_enabled]).to eq(2)
expect(count_data[:clusters_enabled]).to eq(7)
expect(count_data[:project_clusters_enabled]).to eq(6)
diff --git a/spec/lib/safe_zip/entry_spec.rb b/spec/lib/safe_zip/entry_spec.rb
new file mode 100644
index 00000000000..115e28c5994
--- /dev/null
+++ b/spec/lib/safe_zip/entry_spec.rb
@@ -0,0 +1,196 @@
+require 'spec_helper'
+
+describe SafeZip::Entry do
+ let(:target_path) { Dir.mktmpdir('safe-zip') }
+ let(:directories) { %w(public folder/with/subfolder) }
+ let(:params) { SafeZip::ExtractParams.new(directories: directories, to: target_path) }
+
+ let(:entry) { described_class.new(zip_archive, zip_entry, params) }
+ let(:entry_name) { 'public/folder/index.html' }
+ let(:entry_path_dir) { File.join(target_path, File.dirname(entry_name)) }
+ let(:entry_path) { File.join(target_path, entry_name) }
+ let(:zip_archive) { double }
+
+ let(:zip_entry) do
+ double(
+ name: entry_name,
+ file?: false,
+ directory?: false,
+ symlink?: false)
+ end
+
+ after do
+ FileUtils.remove_entry_secure(target_path)
+ end
+
+ context '#path_dir' do
+ subject { entry.path_dir }
+
+ it { is_expected.to eq(target_path + '/public/folder') }
+ end
+
+ context '#exist?' do
+ subject { entry.exist? }
+
+ context 'when entry does not exist' do
+ it { is_expected.not_to be_truthy }
+ end
+
+ context 'when entry does exist' do
+ before do
+ create_entry
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#extract' do
+ subject { entry.extract }
+
+ context 'when entry does not match the filtered directories' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:entry_name) do
+ [
+ 'assets/folder/index.html',
+ 'public/../folder/index.html',
+ 'public/../../../../../index.html',
+ '../../../../../public/index.html',
+ '/etc/passwd'
+ ]
+ end
+
+ with_them do
+ it 'does not extract file' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+
+ context 'when entry does exist' do
+ before do
+ create_entry
+ end
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(SafeZip::Extract::AlreadyExistsError)
+ end
+ end
+
+ context 'when entry type is unknown' do
+ it 'raises an exception' do
+ expect { subject }.to raise_error(SafeZip::Extract::UnsupportedEntryError)
+ end
+ end
+
+ context 'when entry is valid' do
+ shared_examples 'secured symlinks' do
+ context 'when we try to extract entry into symlinked folder' do
+ before do
+ FileUtils.mkdir_p(File.join(target_path, "source"))
+ File.symlink("source", File.join(target_path, "public"))
+ end
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(SafeZip::Extract::PermissionDeniedError)
+ end
+ end
+ end
+
+ context 'and is file' do
+ before do
+ allow(zip_entry).to receive(:file?) { true }
+ end
+
+ it 'does extract file' do
+ expect(zip_archive).to receive(:extract)
+ .with(zip_entry, entry_path)
+ .and_return(true)
+
+ is_expected.to be_truthy
+ end
+
+ it_behaves_like 'secured symlinks'
+ end
+
+ context 'and is directory' do
+ let(:entry_name) { 'public/folder/assets' }
+
+ before do
+ allow(zip_entry).to receive(:directory?) { true }
+ end
+
+ it 'does create directory' do
+ is_expected.to be_truthy
+
+ expect(File.exist?(entry_path)).to eq(true)
+ end
+
+ it_behaves_like 'secured symlinks'
+ end
+
+ context 'and is symlink' do
+ let(:entry_name) { 'public/folder/assets' }
+
+ before do
+ allow(zip_entry).to receive(:symlink?) { true }
+ allow(zip_archive).to receive(:read).with(zip_entry) { entry_symlink }
+ end
+
+ shared_examples 'a valid symlink' do
+ it 'does create symlink' do
+ is_expected.to be_truthy
+
+ expect(File.exist?(entry_path)).to eq(true)
+ end
+ end
+
+ context 'when source is within target' do
+ let(:entry_symlink) { '../images' }
+
+ context 'but does not exist' do
+ it 'raises an exception' do
+ expect { subject }.to raise_error(SafeZip::Extract::SymlinkSourceDoesNotExistError)
+ end
+ end
+
+ context 'and does exist' do
+ before do
+ FileUtils.mkdir_p(File.join(target_path, 'public', 'images'))
+ end
+
+ it_behaves_like 'a valid symlink'
+ end
+ end
+
+ context 'when source points outside of target' do
+ let(:entry_symlink) { '../../images' }
+
+ before do
+ FileUtils.mkdir(File.join(target_path, 'images'))
+ end
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(SafeZip::Extract::PermissionDeniedError)
+ end
+ end
+
+ context 'when source points to /etc/passwd' do
+ let(:entry_symlink) { '/etc/passwd' }
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(SafeZip::Extract::PermissionDeniedError)
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def create_entry
+ FileUtils.mkdir_p(entry_path_dir)
+ FileUtils.touch(entry_path)
+ end
+end
diff --git a/spec/lib/safe_zip/extract_params_spec.rb b/spec/lib/safe_zip/extract_params_spec.rb
new file mode 100644
index 00000000000..85e22cfa495
--- /dev/null
+++ b/spec/lib/safe_zip/extract_params_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe SafeZip::ExtractParams do
+ let(:target_path) { Dir.mktmpdir("safe-zip") }
+ let(:params) { described_class.new(directories: directories, to: target_path) }
+ let(:directories) { %w(public folder/with/subfolder) }
+
+ after do
+ FileUtils.remove_entry_secure(target_path)
+ end
+
+ describe '#extract_path' do
+ subject { params.extract_path }
+
+ it { is_expected.to eq(target_path) }
+ end
+
+ describe '#matching_target_directory' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { params.matching_target_directory(target_path + path) }
+
+ where(:path, :result) do
+ '/public/index.html' | '/public/'
+ '/non/existing/path' | nil
+ '/public' | nil
+ '/folder/with/index.html' | nil
+ end
+
+ with_them do
+ it { is_expected.to eq(result ? target_path + result : nil) }
+ end
+ end
+
+ describe '#target_directories' do
+ subject { params.target_directories }
+
+ it 'starts with target_path' do
+ is_expected.to all(start_with(target_path + '/'))
+ end
+
+ it 'ends with / for all paths' do
+ is_expected.to all(end_with('/'))
+ end
+ end
+
+ describe '#directories_wildcard' do
+ subject { params.directories_wildcard }
+
+ it 'adds * for all paths' do
+ is_expected.to all(end_with('/*'))
+ end
+ end
+end
diff --git a/spec/lib/safe_zip/extract_spec.rb b/spec/lib/safe_zip/extract_spec.rb
new file mode 100644
index 00000000000..b75a8fede00
--- /dev/null
+++ b/spec/lib/safe_zip/extract_spec.rb
@@ -0,0 +1,80 @@
+require 'spec_helper'
+
+describe SafeZip::Extract do
+ let(:target_path) { Dir.mktmpdir('safe-zip') }
+ let(:directories) { %w(public) }
+ let(:object) { described_class.new(archive) }
+ let(:archive) { Rails.root.join('spec', 'fixtures', 'safe_zip', archive_name) }
+
+ after do
+ FileUtils.remove_entry_secure(target_path)
+ end
+
+ context '#extract' do
+ subject { object.extract(directories: directories, to: target_path) }
+
+ shared_examples 'extracts archive' do |param|
+ before do
+ stub_feature_flags(safezip_use_rubyzip: param)
+ end
+
+ it 'does extract archive' do
+ subject
+
+ expect(File.exist?(File.join(target_path, 'public', 'index.html'))).to eq(true)
+ expect(File.exist?(File.join(target_path, 'source'))).to eq(false)
+ end
+ end
+
+ shared_examples 'fails to extract archive' do |param|
+ before do
+ stub_feature_flags(safezip_use_rubyzip: param)
+ end
+
+ it 'does not extract archive' do
+ expect { subject }.to raise_error(SafeZip::Extract::Error)
+ end
+ end
+
+ %w(valid-simple.zip valid-symlinks-first.zip valid-non-writeable.zip).each do |name|
+ context "when using #{name} archive" do
+ let(:archive_name) { name }
+
+ context 'for RubyZip' do
+ it_behaves_like 'extracts archive', true
+ end
+
+ context 'for UnZip' do
+ it_behaves_like 'extracts archive', false
+ end
+ end
+ end
+
+ %w(invalid-symlink-does-not-exist.zip invalid-symlinks-outside.zip).each do |name|
+ context "when using #{name} archive" do
+ let(:archive_name) { name }
+
+ context 'for RubyZip' do
+ it_behaves_like 'fails to extract archive', true
+ end
+
+ context 'for UnZip (UNSAFE)' do
+ it_behaves_like 'extracts archive', false
+ end
+ end
+ end
+
+ context 'when no matching directories are found' do
+ let(:archive_name) { 'valid-simple.zip' }
+ let(:directories) { %w(non/existing) }
+
+ context 'for RubyZip' do
+ it_behaves_like 'fails to extract archive', true
+ end
+
+ context 'for UnZip' do
+ it_behaves_like 'fails to extract archive', false
+ end
+ end
+ end
+end
diff --git a/spec/lib/sentry/client_spec.rb b/spec/lib/sentry/client_spec.rb
index b36be0fd9c1..6fbf60a6222 100644
--- a/spec/lib/sentry/client_spec.rb
+++ b/spec/lib/sentry/client_spec.rb
@@ -3,30 +3,76 @@
require 'spec_helper'
describe Sentry::Client do
- let(:issue_status) { 'unresolved' }
- let(:limit) { 20 }
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
- let(:sample_response) do
+ let(:issues_sample_response) do
Gitlab::Utils.deep_indifferent_access(
- JSON.parse(File.read(Rails.root.join('spec/fixtures/sentry/issues_sample_response.json')))
+ JSON.parse(fixture_file('sentry/issues_sample_response.json'))
+ )
+ end
+
+ let(:projects_sample_response) do
+ Gitlab::Utils.deep_indifferent_access(
+ JSON.parse(fixture_file('sentry/list_projects_sample_response.json'))
)
end
subject(:client) { described_class.new(sentry_url, token) }
- describe '#list_issues' do
- subject { client.list_issues(issue_status: issue_status, limit: limit) }
+ # Requires sentry_api_url and subject to be defined
+ shared_examples 'no redirects' do
+ let(:redirect_to) { 'https://redirected.example.com' }
+ let(:other_url) { 'https://other.example.org' }
+
+ let!(:redirected_req_stub) { stub_sentry_request(other_url) }
+
+ let!(:redirect_req_stub) do
+ stub_sentry_request(
+ sentry_api_url,
+ status: 302,
+ headers: { location: redirect_to }
+ )
+ end
- before do
- stub_sentry_request(sentry_url + '/issues/?limit=20&query=is:unresolved', body: sample_response)
+ it 'does not follow redirects' do
+ expect { subject }.to raise_exception(Sentry::Client::Error, 'Sentry response error: 302')
+ expect(redirect_req_stub).to have_been_requested
+ expect(redirected_req_stub).not_to have_been_requested
end
+ end
- it 'returns objects of type ErrorTracking::Error' do
- expect(subject.length).to eq(1)
- expect(subject[0]).to be_a(Gitlab::ErrorTracking::Error)
+ shared_examples 'has correct return type' do |klass|
+ it "returns objects of type #{klass}" do
+ expect(subject).to all( be_a(klass) )
end
+ end
+
+ shared_examples 'has correct length' do |length|
+ it { expect(subject.length).to eq(length) }
+ end
+
+ # Requires sentry_api_request and subject to be defined
+ shared_examples 'calls sentry api' do
+ it 'calls sentry api' do
+ subject
+
+ expect(sentry_api_request).to have_been_requested
+ end
+ end
+
+ describe '#list_issues' do
+ let(:issue_status) { 'unresolved' }
+ let(:limit) { 20 }
+
+ let!(:sentry_api_request) { stub_sentry_request(sentry_url + '/issues/?limit=20&query=is:unresolved', body: issues_sample_response) }
+
+ subject { client.list_issues(issue_status: issue_status, limit: limit) }
+
+ it_behaves_like 'calls sentry api'
+
+ it_behaves_like 'has correct return type', Gitlab::ErrorTracking::Error
+ it_behaves_like 'has correct length', 1
context 'error object created from sentry response' do
using RSpec::Parameterized::TableSyntax
@@ -50,7 +96,7 @@ describe Sentry::Client do
end
with_them do
- it { expect(subject[0].public_send(error_object)).to eq(sample_response[0].dig(*sentry_response)) }
+ it { expect(subject[0].public_send(error_object)).to eq(issues_sample_response[0].dig(*sentry_response)) }
end
context 'external_url' do
@@ -61,24 +107,9 @@ describe Sentry::Client do
end
context 'redirects' do
- let(:redirect_to) { 'https://redirected.example.com' }
- let(:other_url) { 'https://other.example.org' }
-
- let!(:redirected_req_stub) { stub_sentry_request(other_url) }
-
- let!(:redirect_req_stub) do
- stub_sentry_request(
- sentry_url + '/issues/?limit=20&query=is:unresolved',
- status: 302,
- headers: { location: redirect_to }
- )
- end
+ let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
- it 'does not follow redirects' do
- expect { subject }.to raise_exception(Sentry::Client::Error, 'Sentry response error: 302')
- expect(redirect_req_stub).to have_been_requested
- expect(redirected_req_stub).not_to have_been_requested
- end
+ it_behaves_like 'no redirects'
end
# Sentry API returns 404 if there are extra slashes in the URL!
@@ -99,7 +130,75 @@ describe Sentry::Client do
anything
).and_call_original
- client.list_issues(issue_status: issue_status, limit: limit)
+ subject
+
+ expect(valid_req_stub).to have_been_requested
+ end
+ end
+ end
+
+ describe '#list_projects' do
+ let(:sentry_list_projects_url) { 'https://sentrytest.gitlab.com/api/0/projects/' }
+
+ let!(:sentry_api_request) { stub_sentry_request(sentry_list_projects_url, body: projects_sample_response) }
+
+ subject { client.list_projects }
+
+ it_behaves_like 'calls sentry api'
+
+ it_behaves_like 'has correct return type', Gitlab::ErrorTracking::Project
+ it_behaves_like 'has correct length', 2
+
+ context 'keys missing in API response' do
+ it 'raises exception' do
+ projects_sample_response[0].delete(:slug)
+
+ stub_sentry_request(sentry_list_projects_url, body: projects_sample_response)
+
+ expect { subject }.to raise_error(Sentry::Client::SentryError, 'Sentry API response is missing keys. key not found: "slug"')
+ end
+ end
+
+ context 'error object created from sentry response' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:sentry_project_object, :sentry_response) do
+ :id | :id
+ :name | :name
+ :status | :status
+ :slug | :slug
+ :organization_name | [:organization, :name]
+ :organization_id | [:organization, :id]
+ :organization_slug | [:organization, :slug]
+ end
+
+ with_them do
+ it { expect(subject[0].public_send(sentry_project_object)).to eq(projects_sample_response[0].dig(*sentry_response)) }
+ end
+ end
+
+ context 'redirects' do
+ let(:sentry_api_url) { sentry_list_projects_url }
+
+ it_behaves_like 'no redirects'
+ end
+
+ # Sentry API returns 404 if there are extra slashes in the URL!
+ context 'extra slashes in URL' do
+ let(:sentry_url) { 'https://sentrytest.gitlab.com/api//0/projects//' }
+ let(:client) { described_class.new(sentry_url, token) }
+
+ let!(:valid_req_stub) do
+ stub_sentry_request(sentry_list_projects_url)
+ end
+
+ it 'removes extra slashes in api url' do
+ expect(Gitlab::HTTP).to receive(:get).with(
+ URI(sentry_list_projects_url),
+ anything
+ ).and_call_original
+
+ subject
expect(valid_req_stub).to have_been_requested
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 1f5b4a8f908..4f578c48d5b 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -9,8 +9,10 @@ describe Notify do
include_context 'gitlab email notification'
+ let(:current_user_sanitized) { 'www_example_com' }
+
set(:user) { create(:user) }
- set(:current_user) { create(:user, email: "current@email.com") }
+ set(:current_user) { create(:user, email: "current@email.com", name: 'www.example.com') }
set(:assignee) { create(:user, email: 'assignee@example.com', name: 'John Doe') }
set(:merge_request) do
@@ -182,7 +184,7 @@ describe Notify do
aggregate_failures do
is_expected.to have_referable_subject(issue, reply: true)
is_expected.to have_body_text(status)
- is_expected.to have_body_text(current_user.name)
+ is_expected.to have_body_text(current_user_sanitized)
is_expected.to have_body_text(project_issue_path project, issue)
end
end
@@ -361,7 +363,7 @@ describe Notify do
aggregate_failures do
is_expected.to have_referable_subject(merge_request, reply: true)
is_expected.to have_body_text(status)
- is_expected.to have_body_text(current_user.name)
+ is_expected.to have_body_text(current_user_sanitized)
is_expected.to have_body_text(project_merge_request_path(project, merge_request))
end
end
diff --git a/spec/migrations/clean_up_for_members_spec.rb b/spec/migrations/clean_up_for_members_spec.rb
index 7876536cb3e..1a79f94cf0d 100644
--- a/spec/migrations/clean_up_for_members_spec.rb
+++ b/spec/migrations/clean_up_for_members_spec.rb
@@ -2,6 +2,10 @@ require 'spec_helper'
require Rails.root.join('db', 'migrate', '20171216111734_clean_up_for_members.rb')
describe CleanUpForMembers, :migration do
+ before do
+ stub_feature_flags(enforced_sso: false)
+ end
+
let(:migration) { described_class.new }
let(:groups) { table(:namespaces) }
let!(:group_member) { create_group_member }
diff --git a/spec/migrations/fix_null_type_labels_spec.rb b/spec/migrations/fix_null_type_labels_spec.rb
new file mode 100644
index 00000000000..462ae9b913f
--- /dev/null
+++ b/spec/migrations/fix_null_type_labels_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20190131122559_fix_null_type_labels')
+
+describe FixNullTypeLabels, :migration do
+ let(:migration) { described_class.new }
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:labels) { table(:labels) }
+
+ before do
+ group = namespaces.create(name: 'labels-test-project', path: 'labels-test-project', type: 'Group')
+ project = projects.create!(namespace_id: group.id, name: 'labels-test-group', path: 'labels-test-group')
+
+ @template_label = labels.create(title: 'template', template: true)
+ @project_label = labels.create(title: 'project label', project_id: project.id, type: 'ProjectLabel')
+ @group_label = labels.create(title: 'group_label', group_id: group.id, type: 'GroupLabel')
+ @broken_label_1 = labels.create(title: 'broken 1', project_id: project.id)
+ @broken_label_2 = labels.create(title: 'broken 2', project_id: project.id)
+ end
+
+ describe '#up' do
+ it 'fix labels with type missing' do
+ migration.up
+
+ # Labels that requires type change
+ expect(@broken_label_1.reload.type).to eq('ProjectLabel')
+ expect(@broken_label_2.reload.type).to eq('ProjectLabel')
+ # Labels out of scope
+ expect(@template_label.reload.type).to be_nil
+ expect(@project_label.reload.type).to eq('ProjectLabel')
+ expect(@group_label.reload.type).to eq('GroupLabel')
+ end
+ end
+end
diff --git a/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb b/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb
new file mode 100644
index 00000000000..b1ff3cfd355
--- /dev/null
+++ b/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb')
+
+describe MigrateAutoDevOpsDomainToClusterDomain, :migration do
+ include MigrationHelpers::ClusterHelpers
+
+ let(:migration) { described_class.new }
+ let(:project_auto_devops_table) { table(:project_auto_devops) }
+ let(:clusters_table) { table(:clusters) }
+ let(:cluster_projects_table) { table(:cluster_projects) }
+
+ # Following lets are needed by MigrationHelpers::ClusterHelpers
+ let(:cluster_kubernetes_namespaces_table) { table(:clusters_kubernetes_namespaces) }
+ let(:projects_table) { table(:projects) }
+ let(:namespaces_table) { table(:namespaces) }
+ let(:provider_gcp_table) { table(:cluster_providers_gcp) }
+ let(:platform_kubernetes_table) { table(:cluster_platforms_kubernetes) }
+
+ before do
+ setup_cluster_projects_with_domain(quantity: 20, domain: domain)
+ end
+
+ context 'with ProjectAutoDevOps with no domain' do
+ let(:domain) { nil }
+
+ it 'should not update cluster project' do
+ migrate!
+
+ expect(clusters_without_domain.count).to eq(clusters_table.count)
+ end
+ end
+
+ context 'with ProjectAutoDevOps with domain' do
+ let(:domain) { 'example-domain.com' }
+
+ it 'should update all cluster projects' do
+ migrate!
+
+ expect(clusters_with_domain.count).to eq(clusters_table.count)
+ end
+ end
+
+ context 'when only some ProjectAutoDevOps have domain set' do
+ let(:domain) { 'example-domain.com' }
+
+ before do
+ setup_cluster_projects_with_domain(quantity: 25, domain: nil)
+ end
+
+ it 'should only update specific cluster projects' do
+ migrate!
+
+ expect(clusters_with_domain.count).to eq(20)
+
+ project_auto_devops_with_domain.each do |project_auto_devops|
+ cluster_project = find_cluster_project(project_auto_devops.project_id)
+ cluster = find_cluster(cluster_project.cluster_id)
+
+ expect(cluster.domain).to be_present
+ end
+
+ expect(clusters_without_domain.count).to eq(25)
+
+ project_auto_devops_without_domain.each do |project_auto_devops|
+ cluster_project = find_cluster_project(project_auto_devops.project_id)
+ cluster = find_cluster(cluster_project.cluster_id)
+
+ expect(cluster.domain).not_to be_present
+ end
+ end
+ end
+
+ def setup_cluster_projects_with_domain(quantity:, domain:)
+ create_cluster_project_list(quantity)
+
+ cluster_projects = cluster_projects_table.last(quantity)
+
+ cluster_projects.each do |cluster_project|
+ specific_domain = "#{cluster_project.id}-#{domain}" if domain
+
+ project_auto_devops_table.create(
+ project_id: cluster_project.project_id,
+ enabled: true,
+ domain: specific_domain
+ )
+ end
+ end
+
+ def find_cluster_project(project_id)
+ cluster_projects_table.where(project_id: project_id).first
+ end
+
+ def find_cluster(cluster_id)
+ clusters_table.where(id: cluster_id).first
+ end
+
+ def project_auto_devops_with_domain
+ project_auto_devops_table.where.not("domain IS NULL OR domain = ''")
+ end
+
+ def project_auto_devops_without_domain
+ project_auto_devops_table.where("domain IS NULL OR domain = ''")
+ end
+
+ def clusters_with_domain
+ clusters_table.where.not("domain IS NULL OR domain = ''")
+ end
+
+ def clusters_without_domain
+ clusters_table.where("domain IS NULL OR domain = ''")
+ end
+end
diff --git a/spec/migrations/update_project_import_visibility_level_spec.rb b/spec/migrations/update_project_import_visibility_level_spec.rb
new file mode 100644
index 00000000000..9ea9b956f67
--- /dev/null
+++ b/spec/migrations/update_project_import_visibility_level_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20181219130552_update_project_import_visibility_level.rb')
+
+describe UpdateProjectImportVisibilityLevel, :migration do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:project) { projects.find_by_name(name) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+ end
+
+ context 'private visibility level' do
+ let(:name) { 'private-public' }
+
+ it 'updates the project visibility' do
+ create_namespace(name, Gitlab::VisibilityLevel::PRIVATE)
+ create_project(name, Gitlab::VisibilityLevel::PUBLIC)
+
+ expect { migrate! }.to change { project.reload.visibility_level }.to(Gitlab::VisibilityLevel::PRIVATE)
+ end
+ end
+
+ context 'internal visibility level' do
+ let(:name) { 'internal-public' }
+
+ it 'updates the project visibility' do
+ create_namespace(name, Gitlab::VisibilityLevel::INTERNAL)
+ create_project(name, Gitlab::VisibilityLevel::PUBLIC)
+
+ expect { migrate! }.to change { project.reload.visibility_level }.to(Gitlab::VisibilityLevel::INTERNAL)
+ end
+ end
+
+ context 'public visibility level' do
+ let(:name) { 'public-public' }
+
+ it 'does not update the project visibility' do
+ create_namespace(name, Gitlab::VisibilityLevel::PUBLIC)
+ create_project(name, Gitlab::VisibilityLevel::PUBLIC)
+
+ expect { migrate! }.not_to change { project.reload.visibility_level }
+ end
+ end
+
+ context 'private project visibility level' do
+ let(:name) { 'public-private' }
+
+ it 'does not update the project visibility' do
+ create_namespace(name, Gitlab::VisibilityLevel::PUBLIC)
+ create_project(name, Gitlab::VisibilityLevel::PRIVATE)
+
+ expect { migrate! }.not_to change { project.reload.visibility_level }
+ end
+ end
+
+ context 'no namespace' do
+ let(:name) { 'no-namespace' }
+
+ it 'does not update the project visibility' do
+ create_namespace(name, Gitlab::VisibilityLevel::PRIVATE, type: nil)
+ create_project(name, Gitlab::VisibilityLevel::PUBLIC)
+
+ expect { migrate! }.not_to change { project.reload.visibility_level }
+ end
+ end
+
+ def create_namespace(name, visibility, options = {})
+ namespaces.create({
+ name: name,
+ path: name,
+ type: 'Group',
+ visibility_level: visibility
+ }.merge(options))
+ end
+
+ def create_project(name, visibility)
+ projects.create!(namespace_id: namespaces.find_by_name(name).id,
+ name: name,
+ path: name,
+ import_type: 'gitlab_project',
+ visibility_level: visibility)
+ end
+end
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb
index 199f49d0bf2..eee80e9bad7 100644
--- a/spec/models/ability_spec.rb
+++ b/spec/models/ability_spec.rb
@@ -298,7 +298,6 @@ describe Ability do
context 'wiki named abilities' do
it 'disables wiki abilities if the project has no wiki' do
- expect(project).to receive(:has_external_wiki?).and_return(false)
expect(subject).not_to be_allowed(:read_wiki)
expect(subject).not_to be_allowed(:create_wiki)
expect(subject).not_to be_allowed(:update_wiki)
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
index 68aed387bfc..fd25132ed3a 100644
--- a/spec/models/application_record_spec.rb
+++ b/spec/models/application_record_spec.rb
@@ -10,4 +10,27 @@ describe ApplicationRecord do
expect(User.id_in(records.last(2).map(&:id))).to eq(records.last(2))
end
end
+
+ describe '.safe_find_or_create_by' do
+ it 'creates the user avoiding race conditions' do
+ expect(Suggestion).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique)
+ allow(Suggestion).to receive(:find_or_create_by).and_call_original
+
+ expect { Suggestion.safe_find_or_create_by(build(:suggestion).attributes) }
+ .to change { Suggestion.count }.by(1)
+ end
+ end
+
+ describe '.safe_find_or_create_by!' do
+ it 'creates a record using safe_find_or_create_by' do
+ expect(Suggestion).to receive(:find_or_create_by).and_call_original
+
+ expect(Suggestion.safe_find_or_create_by!(build(:suggestion).attributes))
+ .to be_a(Suggestion)
+ end
+
+ it 'raises a validation error if the record was not persisted' do
+ expect { Suggestion.find_or_create_by!(note: nil) }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 96aa9a82b71..789e14e8a20 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -70,6 +70,13 @@ describe ApplicationSetting do
.is_greater_than(0)
end
+ it do
+ is_expected.to validate_numericality_of(:local_markdown_version)
+ .only_integer
+ .is_greater_than_or_equal_to(0)
+ .is_less_than(65536)
+ end
+
context 'key restrictions' do
it 'supports all key types' do
expect(described_class::SUPPORTED_KEY_TYPES).to contain_exactly(:rsa, :dsa, :ecdsa, :ed25519)
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 8a1bbb26e57..47865e4d08f 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1844,6 +1844,26 @@ describe Ci::Build do
context 'when there is no environment' do
it { is_expected.to be_nil }
end
+
+ context 'when build has a start environment' do
+ let(:build) { create(:ci_build, :deploy_to_production, pipeline: pipeline) }
+
+ it 'does not expand environment name' do
+ expect(build).not_to receive(:expanded_environment_name)
+
+ subject
+ end
+ end
+
+ context 'when build has a stop environment' do
+ let(:build) { create(:ci_build, :stop_review_app, pipeline: pipeline) }
+
+ it 'expands environment name' do
+ expect(build).to receive(:expanded_environment_name)
+
+ subject
+ end
+ end
end
describe '#play' do
diff --git a/spec/models/clusters/applications/cert_manager_spec.rb b/spec/models/clusters/applications/cert_manager_spec.rb
index 8e14abe098d..cf5cbf8ec5c 100644
--- a/spec/models/clusters/applications/cert_manager_spec.rb
+++ b/spec/models/clusters/applications/cert_manager_spec.rb
@@ -4,20 +4,9 @@ describe Clusters::Applications::CertManager do
let(:cert_manager) { create(:clusters_applications_cert_managers) }
include_examples 'cluster application core specs', :clusters_applications_cert_managers
-
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_cert_managers, :scheduled, version: 'v0.4.0') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('v0.5.2')
- end
- end
- end
+ include_examples 'cluster application status specs', :clusters_applications_cert_managers
+ include_examples 'cluster application version specs', :clusters_applications_cert_managers
+ include_examples 'cluster application initial status specs'
describe '#install_command' do
let(:cluster_issuer_file) { { "cluster_issuer.yaml": "---\napiVersion: certmanager.k8s.io/v1alpha1\nkind: ClusterIssuer\nmetadata:\n name: letsencrypt-prod\nspec:\n acme:\n server: https://acme-v02.api.letsencrypt.org/directory\n email: admin@example.com\n privateKeySecretRef:\n name: letsencrypt-prod\n http01: {}\n" } }
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
index 52c347229c6..03ca18c6943 100644
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -7,7 +7,9 @@ describe Clusters::Applications::Ingress do
include_examples 'cluster application core specs', :clusters_applications_ingress
include_examples 'cluster application status specs', :clusters_applications_ingress
+ include_examples 'cluster application version specs', :clusters_applications_ingress
include_examples 'cluster application helm specs', :clusters_applications_ingress
+ include_examples 'cluster application initial status specs'
before do
allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
@@ -26,20 +28,6 @@ describe Clusters::Applications::Ingress do
it { is_expected.to contain_exactly(cluster) }
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_ingress, :scheduled, version: '0.22.0') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('1.1.2')
- end
- end
- end
-
describe '#make_installed!' do
before do
application.make_installed!
diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb
index 391e5425384..2c22c24c498 100644
--- a/spec/models/clusters/applications/jupyter_spec.rb
+++ b/spec/models/clusters/applications/jupyter_spec.rb
@@ -2,6 +2,8 @@ require 'rails_helper'
describe Clusters::Applications::Jupyter do
include_examples 'cluster application core specs', :clusters_applications_jupyter
+ include_examples 'cluster application status specs', :clusters_applications_jupyter
+ include_examples 'cluster application version specs', :clusters_applications_jupyter
include_examples 'cluster application helm specs', :clusters_applications_jupyter
it { is_expected.to belong_to(:oauth_application) }
@@ -26,20 +28,6 @@ describe Clusters::Applications::Jupyter do
end
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_jupyter, :scheduled, version: 'v0.5') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('v0.6')
- end
- end
- end
-
describe '#install_command' do
let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') }
let!(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb
index 35818be8deb..cd29e0d4f53 100644
--- a/spec/models/clusters/applications/knative_spec.rb
+++ b/spec/models/clusters/applications/knative_spec.rb
@@ -9,6 +9,8 @@ describe Clusters::Applications::Knative do
include_examples 'cluster application core specs', :clusters_applications_knative
include_examples 'cluster application status specs', :clusters_applications_knative
include_examples 'cluster application helm specs', :clusters_applications_knative
+ include_examples 'cluster application version specs', :clusters_applications_knative
+ include_examples 'cluster application initial status specs'
before do
allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
@@ -34,20 +36,6 @@ describe Clusters::Applications::Knative do
it { is_expected.to contain_exactly(cluster) }
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_knative, :scheduled, version: '0.2.2') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('0.2.2')
- end
- end
- end
-
describe '#make_installed' do
subject { described_class.installed }
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
index e50ba67c493..caf59b0fc31 100644
--- a/spec/models/clusters/applications/prometheus_spec.rb
+++ b/spec/models/clusters/applications/prometheus_spec.rb
@@ -5,7 +5,9 @@ describe Clusters::Applications::Prometheus do
include_examples 'cluster application core specs', :clusters_applications_prometheus
include_examples 'cluster application status specs', :clusters_applications_prometheus
+ include_examples 'cluster application version specs', :clusters_applications_prometheus
include_examples 'cluster application helm specs', :clusters_applications_prometheus
+ include_examples 'cluster application initial status specs'
describe '.installed' do
subject { described_class.installed }
@@ -19,20 +21,6 @@ describe Clusters::Applications::Prometheus do
it { is_expected.to contain_exactly(cluster) }
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_prometheus, :scheduled, version: '6.7.2') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('6.7.3')
- end
- end
- end
-
describe 'transition to installed' do
let(:project) { create(:project) }
let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) }
@@ -219,8 +207,8 @@ describe Clusters::Applications::Prometheus do
let(:prometheus) { build(:clusters_applications_prometheus) }
let(:values) { prometheus.values }
- it 'returns an instance of Gitlab::Kubernetes::Helm::GetCommand' do
- expect(prometheus.upgrade_command(values)).to be_an_instance_of(::Gitlab::Kubernetes::Helm::UpgradeCommand)
+ it 'returns an instance of Gitlab::Kubernetes::Helm::InstallCommand' do
+ expect(prometheus.upgrade_command(values)).to be_an_instance_of(::Gitlab::Kubernetes::Helm::InstallCommand)
end
it 'should be initialized with 3 arguments' do
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
index 8ad41e997c2..38758ff97bc 100644
--- a/spec/models/clusters/applications/runner_spec.rb
+++ b/spec/models/clusters/applications/runner_spec.rb
@@ -5,24 +5,12 @@ describe Clusters::Applications::Runner do
include_examples 'cluster application core specs', :clusters_applications_runner
include_examples 'cluster application status specs', :clusters_applications_runner
+ include_examples 'cluster application version specs', :clusters_applications_runner
include_examples 'cluster application helm specs', :clusters_applications_runner
+ include_examples 'cluster application initial status specs'
it { is_expected.to belong_to(:runner) }
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_runner, :scheduled, version: '0.1.30') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('0.1.45')
- end
- end
- end
-
describe '.installed' do
subject { described_class.installed }
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index 0161db740ee..92ce2b0999a 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -30,6 +30,7 @@ describe Clusters::Cluster do
it { is_expected.to delegate_method(:available?).to(:application_ingress).with_prefix }
it { is_expected.to delegate_method(:available?).to(:application_prometheus).with_prefix }
it { is_expected.to delegate_method(:available?).to(:application_knative).with_prefix }
+ it { is_expected.to delegate_method(:external_ip).to(:application_ingress).with_prefix }
it { is_expected.to respond_to :project }
@@ -514,4 +515,108 @@ describe Clusters::Cluster do
it { is_expected.to be_falsey }
end
end
+
+ describe '#kube_ingress_domain' do
+ let(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ subject { cluster.kube_ingress_domain }
+
+ context 'with domain set in cluster' do
+ let(:cluster) { create(:cluster, :provided_by_gcp, :with_domain) }
+
+ it { is_expected.to eq(cluster.domain) }
+ end
+
+ context 'with no domain on cluster' do
+ context 'with a project cluster' do
+ let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:project) { cluster.project }
+
+ context 'with domain set at instance level' do
+ before do
+ stub_application_setting(auto_devops_domain: 'global_domain.com')
+
+ it { is_expected.to eq('global_domain.com') }
+ end
+ end
+
+ context 'with domain set on ProjectAutoDevops' do
+ before do
+ auto_devops = project.build_auto_devops(domain: 'legacy-ado-domain.com')
+ auto_devops.save
+ end
+
+ it { is_expected.to eq('legacy-ado-domain.com') }
+ end
+
+ context 'with domain set as environment variable on project' do
+ before do
+ variable = project.variables.build(key: 'AUTO_DEVOPS_DOMAIN', value: 'project-ado-domain.com')
+ variable.save
+ end
+
+ it { is_expected.to eq('project-ado-domain.com') }
+ end
+
+ context 'with domain set as environment variable on the group project' do
+ let(:group) { create(:group) }
+
+ before do
+ project.update(parent_id: group.id)
+ variable = group.variables.build(key: 'AUTO_DEVOPS_DOMAIN', value: 'group-ado-domain.com')
+ variable.save
+ end
+
+ it { is_expected.to eq('group-ado-domain.com') }
+ end
+ end
+
+ context 'with a group cluster' do
+ let(:cluster) { create(:cluster, :group, :provided_by_gcp) }
+
+ context 'with domain set as environment variable for the group' do
+ let(:group) { cluster.group }
+
+ before do
+ variable = group.variables.build(key: 'AUTO_DEVOPS_DOMAIN', value: 'group-ado-domain.com')
+ variable.save
+ end
+
+ it { is_expected.to eq('group-ado-domain.com') }
+ end
+ end
+ end
+ end
+
+ describe '#predefined_variables' do
+ subject { cluster.predefined_variables }
+
+ context 'with an instance domain' do
+ let(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ before do
+ stub_application_setting(auto_devops_domain: 'global_domain.com')
+ end
+
+ it 'should include KUBE_INGRESS_BASE_DOMAIN' do
+ expect(subject.to_hash).to include(KUBE_INGRESS_BASE_DOMAIN: 'global_domain.com')
+ end
+ end
+
+ context 'with a cluster domain' do
+ let(:cluster) { create(:cluster, :provided_by_gcp, domain: 'example.com') }
+
+ it 'should include KUBE_INGRESS_BASE_DOMAIN' do
+ expect(subject.to_hash).to include(KUBE_INGRESS_BASE_DOMAIN: 'example.com')
+ end
+ end
+
+ context 'with no domain' do
+ let(:cluster) { create(:cluster, :provided_by_gcp, :project) }
+
+ it 'should return an empty array' do
+ expect(subject.to_hash).to be_empty
+ end
+ end
+ end
end
diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb
index 6c8a223092e..c273fa7e164 100644
--- a/spec/models/clusters/platforms/kubernetes_spec.rb
+++ b/spec/models/clusters/platforms/kubernetes_spec.rb
@@ -297,6 +297,19 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching
end
end
end
+
+ context 'with a domain' do
+ let!(:cluster) do
+ create(:cluster, :provided_by_gcp, :with_domain,
+ platform_kubernetes: kubernetes)
+ end
+
+ it 'sets KUBE_INGRESS_BASE_DOMAIN' do
+ expect(subject).to include(
+ { key: 'KUBE_INGRESS_BASE_DOMAIN', value: cluster.domain, public: true }
+ )
+ end
+ end
end
describe '#terminals' do
diff --git a/spec/models/commit_collection_spec.rb b/spec/models/commit_collection_spec.rb
index 005005b236b..12e59b35428 100644
--- a/spec/models/commit_collection_spec.rb
+++ b/spec/models/commit_collection_spec.rb
@@ -35,6 +35,17 @@ describe CommitCollection do
end
end
+ describe '#without_merge_commits' do
+ it 'returns all commits except merge commits' do
+ collection = described_class.new(project, [
+ build(:commit),
+ build(:commit, :merge_commit)
+ ])
+
+ expect(collection.without_merge_commits.size).to eq(1)
+ end
+ end
+
describe '#with_pipeline_status' do
it 'sets the pipeline status for every commit so no additional queries are necessary' do
create(
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index a2d2d77746d..baad8352185 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -11,6 +11,7 @@ describe Commit do
it { is_expected.to include_module(Participable) }
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(StaticModel) }
+ it { is_expected.to include_module(Presentable) }
end
describe '.lazy' do
diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb
index ef6af232999..447279f19a8 100644
--- a/spec/models/concerns/cache_markdown_field_spec.rb
+++ b/spec/models/concerns/cache_markdown_field_spec.rb
@@ -60,6 +60,10 @@ describe CacheMarkdownField do
changes_applied
end
end
+
+ def has_attribute?(attr_name)
+ attribute_names.include?(attr_name)
+ end
end
def thing_subclass(new_attr)
@@ -72,7 +76,8 @@ describe CacheMarkdownField do
let(:updated_markdown) { '`Bar`' }
let(:updated_html) { '<p dir="auto"><code>Bar</code></p>' }
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) }
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ let(:cache_version) { CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16 }
before do
stub_commonmark_sourcepos_disabled
@@ -93,24 +98,19 @@ describe CacheMarkdownField do
it { expect(thing.foo).to eq(markdown) }
it { expect(thing.foo_html).to eq(html) }
it { expect(thing.foo_html_changed?).not_to be_truthy }
- it { expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) }
+ it { expect(thing.cached_markdown_version).to eq(cache_version) }
end
context 'a changed markdown field' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
-
- before do
- thing.foo = updated_markdown
- thing.save
- end
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version - 1) }
- it { expect(thing.foo_html).to eq(updated_html) }
- it { expect(thing.cached_markdown_version).to eq(cache_version) }
+ before do
+ thing.foo = updated_markdown
+ thing.save
end
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
+ it { expect(thing.foo_html).to eq(updated_html) }
+ it { expect(thing.cached_markdown_version).to eq(cache_version) }
end
context 'when a markdown field is set repeatedly to an empty string' do
@@ -133,23 +133,27 @@ describe CacheMarkdownField do
end
end
- context 'a non-markdown field changed' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ context 'when a markdown field and html field are both changed' do
+ it do
+ expect(thing).not_to receive(:refresh_markdown_cache)
+ thing.foo = '_look over there!_'
+ thing.foo_html = '<em>look over there!</em>'
+ thing.save
+ end
+ end
- before do
- thing.bar = 'OK'
- thing.save
- end
+ context 'a non-markdown field changed' do
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version - 1) }
- it { expect(thing.bar).to eq('OK') }
- it { expect(thing.foo).to eq(markdown) }
- it { expect(thing.foo_html).to eq(html) }
- it { expect(thing.cached_markdown_version).to eq(cache_version) }
+ before do
+ thing.bar = 'OK'
+ thing.save
end
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
+ it { expect(thing.bar).to eq('OK') }
+ it { expect(thing.foo).to eq(markdown) }
+ it { expect(thing.foo_html).to eq(html) }
+ it { expect(thing.cached_markdown_version).to eq(cache_version) }
end
context 'version is out of date' do
@@ -160,85 +164,84 @@ describe CacheMarkdownField do
end
it { expect(thing.foo_html).to eq(updated_html) }
- it { expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) }
+ it { expect(thing.cached_markdown_version).to eq(cache_version) }
end
describe '#cached_html_up_to_date?' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
- subject { thing.cached_html_up_to_date?(:foo) }
+ subject { thing.cached_html_up_to_date?(:foo) }
- it 'returns false when the version is absent' do
- thing.cached_markdown_version = nil
+ it 'returns false when the version is absent' do
+ thing.cached_markdown_version = nil
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
+ end
- it 'returns false when the version is too early' do
- thing.cached_markdown_version -= 1
+ it 'returns false when the cached version is too old' do
+ thing.cached_markdown_version = cache_version - 1
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
+ end
- it 'returns false when the version is too late' do
- thing.cached_markdown_version += 1
+ it 'returns false when the cached version is in future' do
+ thing.cached_markdown_version = cache_version + 1
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
+ end
- it 'returns true when the version is just right' do
- thing.cached_markdown_version = cache_version
+ it 'returns false when the local version was bumped' do
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:local_markdown_version).and_return(2)
+ thing.cached_markdown_version = cache_version
- is_expected.to be_truthy
- end
+ is_expected.to be_falsy
+ end
- it 'returns false if markdown has been changed but html has not' do
- thing.foo = updated_html
+ it 'returns true when the local version is default' do
+ thing.cached_markdown_version = cache_version
- is_expected.to be_falsy
- end
+ is_expected.to be_truthy
+ end
- it 'returns true if markdown has not been changed but html has' do
- thing.foo_html = updated_html
+ it 'returns true when the cached version is just right' do
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:local_markdown_version).and_return(2)
+ thing.cached_markdown_version = cache_version + 2
- is_expected.to be_truthy
- end
+ is_expected.to be_truthy
+ end
- it 'returns true if markdown and html have both been changed' do
- thing.foo = updated_markdown
- thing.foo_html = updated_html
+ it 'returns false if markdown has been changed but html has not' do
+ thing.foo = updated_html
- is_expected.to be_truthy
- end
+ is_expected.to be_falsy
+ end
- it 'returns false if the markdown field is set but the html is not' do
- thing.foo_html = nil
+ it 'returns true if markdown has not been changed but html has' do
+ thing.foo_html = updated_html
- is_expected.to be_falsy
- end
+ is_expected.to be_truthy
end
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
- end
-
- describe '#latest_cached_markdown_version' do
- subject { thing.latest_cached_markdown_version }
+ it 'returns true if markdown and html have both been changed' do
+ thing.foo = updated_markdown
+ thing.foo_html = updated_html
- it 'returns redcarpet version' do
- thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - 1
- is_expected.to eq(CacheMarkdownField::CACHE_REDCARPET_VERSION)
+ is_expected.to be_truthy
end
- it 'returns commonmark version' do
- thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + 1
- is_expected.to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION)
+ it 'returns false if the markdown field is set but the html is not' do
+ thing.foo_html = nil
+
+ is_expected.to be_falsy
end
+ end
+
+ describe '#latest_cached_markdown_version' do
+ subject { thing.latest_cached_markdown_version }
- it 'returns default version when version is nil' do
+ it 'returns default version' do
thing.cached_markdown_version = nil
- is_expected.to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION)
+ is_expected.to eq(cache_version)
end
end
@@ -265,44 +268,39 @@ describe CacheMarkdownField do
thing.cached_markdown_version = nil
thing.refresh_markdown_cache
- expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION)
+ expect(thing.cached_markdown_version).to eq(cache_version)
end
end
describe '#refresh_markdown_cache!' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
- before do
- thing.foo = updated_markdown
- end
+ before do
+ thing.foo = updated_markdown
+ end
- it 'fills all html fields' do
- thing.refresh_markdown_cache!
+ it 'fills all html fields' do
+ thing.refresh_markdown_cache!
- expect(thing.foo_html).to eq(updated_html)
- expect(thing.foo_html_changed?).to be_truthy
- expect(thing.baz_html_changed?).to be_truthy
- end
+ expect(thing.foo_html).to eq(updated_html)
+ expect(thing.foo_html_changed?).to be_truthy
+ expect(thing.baz_html_changed?).to be_truthy
+ end
- it 'skips saving if not persisted' do
- expect(thing).to receive(:persisted?).and_return(false)
- expect(thing).not_to receive(:update_columns)
+ it 'skips saving if not persisted' do
+ expect(thing).to receive(:persisted?).and_return(false)
+ expect(thing).not_to receive(:update_columns)
- thing.refresh_markdown_cache!
- end
+ thing.refresh_markdown_cache!
+ end
- it 'saves the changes using #update_columns' do
- expect(thing).to receive(:persisted?).and_return(true)
- expect(thing).to receive(:update_columns)
- .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => cache_version)
+ it 'saves the changes using #update_columns' do
+ expect(thing).to receive(:persisted?).and_return(true)
+ expect(thing).to receive(:update_columns)
+ .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => cache_version)
- thing.refresh_markdown_cache!
- end
+ thing.refresh_markdown_cache!
end
-
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
end
describe '#banzai_render_context' do
@@ -351,7 +349,7 @@ describe CacheMarkdownField do
expect(thing.foo_html).to eq(updated_html)
expect(thing.baz_html).to eq(updated_html)
- expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION)
+ expect(thing.cached_markdown_version).to eq(cache_version)
end
end
@@ -371,24 +369,8 @@ describe CacheMarkdownField do
expect(thing.foo_html).to eq(updated_html)
expect(thing.baz_html).to eq(updated_html)
- expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION)
+ expect(thing.cached_markdown_version).to eq(cache_version)
end
end
end
-
- describe CacheMarkdownField::MarkdownEngine do
- subject { lambda { |version| CacheMarkdownField::MarkdownEngine.from_version(version) } }
-
- it 'returns :common_mark as a default' do
- expect(subject.call(nil)).to eq :common_mark
- end
-
- it 'returns :common_mark' do
- expect(subject.call(CacheMarkdownField::CACHE_COMMONMARK_VERSION)).to eq :common_mark
- end
-
- it 'returns :redcarpet' do
- expect(subject.call(CacheMarkdownField::CACHE_REDCARPET_VERSION)).to eq :redcarpet
- end
- end
end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 5753c646106..41159348e04 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -139,6 +139,78 @@ describe Issuable do
it 'returns issues with a matching description for a query shorter than 3 chars' do
expect(issuable_class.full_search(searchable_issue2.description.downcase)).to eq([searchable_issue2])
end
+
+ context 'when matching columns is "title"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns no issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title'))
+ .to be_empty
+ end
+ end
+
+ context 'when matching columns is "description"' do
+ it 'returns no issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'description'))
+ .to be_empty
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'description'))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is "title,description"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,description'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,description'))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is nil"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: nil))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: nil))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is "invalid"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'invalid'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'invalid'))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is "title,invalid"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,invalid'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns no issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,invalid'))
+ .to be_empty
+ end
+ end
end
describe '.to_ability_name' do
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 9a3f1f1c5a1..2d554326f05 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -41,6 +41,76 @@ describe Environment do
end
end
+ describe '.for_name_like' do
+ subject { project.environments.for_name_like(query, limit: limit) }
+
+ let!(:environment) { create(:environment, name: 'production', project: project) }
+ let(:query) { 'pro' }
+ let(:limit) { 5 }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+
+ context 'when query is production' do
+ let(:query) { 'production' }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+ end
+
+ context 'when query is productionA' do
+ let(:query) { 'productionA' }
+
+ it 'returns empty array' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when query is empty' do
+ let(:query) { '' }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+ end
+
+ context 'when query is nil' do
+ let(:query) { }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(NoMethodError)
+ end
+ end
+
+ context 'when query is partially matched in the middle of environment name' do
+ let(:query) { 'duction' }
+
+ it 'returns empty array' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when query contains a wildcard character' do
+ let(:query) { 'produc%' }
+
+ it 'prevents wildcard injection' do
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ describe '.pluck_names' do
+ subject { described_class.pluck_names }
+
+ let!(:environment) { create(:environment, name: 'production', project: project) }
+
+ it 'plucks names' do
+ is_expected.to eq(%w[production])
+ end
+ end
+
describe '#expire_etag_cache' do
let(:store) { Gitlab::EtagCaching::Store.new }
diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
index 2f8ab21d4b2..d30228b863c 100644
--- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb
+++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
@@ -15,9 +15,11 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
describe 'Validations' do
context 'when api_url is over 255 chars' do
- it 'fails validation' do
+ before do
subject.api_url = 'https://' + 'a' * 250
+ end
+ it 'fails validation' do
expect(subject).not_to be_valid
expect(subject.errors.messages[:api_url]).to include('is too long (maximum is 255 characters)')
end
@@ -31,6 +33,34 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
+ context 'presence validations' do
+ using RSpec::Parameterized::TableSyntax
+
+ valid_api_url = 'http://example.com/api/0/projects/org-slug/proj-slug/'
+ valid_token = 'token'
+
+ where(:enabled, :token, :api_url, :valid?) do
+ true | nil | nil | false
+ true | nil | valid_api_url | false
+ true | valid_token | nil | false
+ true | valid_token | valid_api_url | true
+ false | nil | nil | true
+ false | nil | valid_api_url | true
+ false | valid_token | nil | true
+ false | valid_token | valid_api_url | true
+ end
+
+ with_them do
+ before do
+ subject.enabled = enabled
+ subject.token = token
+ subject.api_url = api_url
+ end
+
+ it { expect(subject.valid?).to eq(valid?) }
+ end
+ end
+
context 'URL path' do
it 'fails validation with wrong path' do
subject.api_url = 'http://gitlab.com/project1/something'
@@ -45,6 +75,16 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
expect(subject).to be_valid
end
end
+
+ context 'non ascii chars in api_url' do
+ before do
+ subject.api_url = 'http://gitlab.com/api/0/projects/project1/something€'
+ end
+
+ it 'fails validation' do
+ expect(subject).not_to be_valid
+ end
+ end
end
describe '#sentry_external_url' do
@@ -106,4 +146,138 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
end
+
+ describe '#list_sentry_projects' do
+ let(:projects) { [:list, :of, :projects] }
+ let(:sentry_client) { spy(:sentry_client) }
+
+ it 'calls sentry client' do
+ expect(subject).to receive(:sentry_client).and_return(sentry_client)
+ expect(sentry_client).to receive(:list_projects).and_return(projects)
+
+ result = subject.list_sentry_projects
+
+ expect(result).to eq(projects: projects)
+ end
+ end
+
+ context 'slugs' do
+ shared_examples_for 'slug from api_url' do |method, slug|
+ context 'when api_url is correct' do
+ before do
+ subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug/'
+ end
+
+ it 'returns slug' do
+ expect(subject.public_send(method)).to eq(slug)
+ end
+ end
+
+ context 'when api_url is blank' do
+ before do
+ subject.api_url = nil
+ end
+
+ it 'returns nil' do
+ expect(subject.public_send(method)).to be_nil
+ end
+ end
+ end
+
+ it_behaves_like 'slug from api_url', :project_slug, 'project-slug'
+ it_behaves_like 'slug from api_url', :organization_slug, 'org-slug'
+ end
+
+ context 'names from api_url' do
+ shared_examples_for 'name from api_url' do |name, titleized_slug|
+ context 'name is present in DB' do
+ it 'returns name from DB' do
+ subject[name] = 'Sentry name'
+ subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug'
+
+ expect(subject.public_send(name)).to eq('Sentry name')
+ end
+ end
+
+ context 'name is null in DB' do
+ it 'titleizes and returns slug from api_url' do
+ subject[name] = nil
+ subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug'
+
+ expect(subject.public_send(name)).to eq(titleized_slug)
+ end
+
+ it 'returns nil when api_url is incorrect' do
+ subject[name] = nil
+ subject.api_url = 'http://gitlab.com/api/0/projects/'
+
+ expect(subject.public_send(name)).to be_nil
+ end
+
+ it 'returns nil when api_url is blank' do
+ subject[name] = nil
+ subject.api_url = nil
+
+ expect(subject.public_send(name)).to be_nil
+ end
+ end
+ end
+
+ it_behaves_like 'name from api_url', :organization_name, 'Org Slug'
+ it_behaves_like 'name from api_url', :project_name, 'Project Slug'
+ end
+
+ describe '.build_api_url_from' do
+ it 'correctly builds api_url with slugs' do
+ api_url = described_class.build_api_url_from(
+ api_host: 'http://sentry.com/',
+ organization_slug: 'org-slug',
+ project_slug: 'proj-slug'
+ )
+
+ expect(api_url).to eq('http://sentry.com/api/0/projects/org-slug/proj-slug/')
+ end
+
+ it 'correctly builds api_url without slugs' do
+ api_url = described_class.build_api_url_from(
+ api_host: 'http://sentry.com/',
+ organization_slug: nil,
+ project_slug: nil
+ )
+
+ expect(api_url).to eq('http://sentry.com/api/0/projects/')
+ end
+
+ it 'does not raise exception with invalid url' do
+ api_url = described_class.build_api_url_from(
+ api_host: ':::',
+ organization_slug: 'org-slug',
+ project_slug: 'proj-slug'
+ )
+
+ expect(api_url).to eq(':::')
+ end
+ end
+
+ describe '#api_host' do
+ context 'when api_url exists' do
+ before do
+ subject.api_url = 'https://example.com/api/0/projects/org-slug/proj-slug/'
+ end
+
+ it 'extracts the api_host from api_url' do
+ expect(subject.api_host).to eq('https://example.com/')
+ end
+ end
+
+ context 'when api_url is nil' do
+ before do
+ subject.api_url = nil
+ end
+
+ it 'returns nil' do
+ expect(subject.api_url).to eq(nil)
+ end
+ end
+ end
end
diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb
index cdd7dea2064..e90319c39b1 100644
--- a/spec/models/gpg_signature_spec.rb
+++ b/spec/models/gpg_signature_spec.rb
@@ -23,6 +23,41 @@ RSpec.describe GpgSignature do
it { is_expected.to validate_presence_of(:gpg_key_primary_keyid) }
end
+ describe '.safe_create!' do
+ let(:attributes) do
+ {
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key_primary_keyid: gpg_key.keyid
+ }
+ end
+
+ it 'finds a signature by commit sha if it existed' do
+ gpg_signature
+
+ expect(described_class.safe_create!(commit_sha: commit_sha)).to eq(gpg_signature)
+ end
+
+ it 'creates a new signature if it was not found' do
+ expect { described_class.safe_create!(attributes) }.to change { described_class.count }.by(1)
+ end
+
+ it 'assigns the correct attributes when creating' do
+ signature = described_class.safe_create!(attributes)
+
+ expect(signature.project).to eq(project)
+ expect(signature.commit_sha).to eq(commit_sha)
+ expect(signature.gpg_key_primary_keyid).to eq(gpg_key.keyid)
+ end
+
+ it 'does not raise an error in case of a race condition' do
+ expect(described_class).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique)
+ allow(described_class).to receive(:find_or_create_by).and_call_original
+
+ described_class.safe_create!(attributes)
+ end
+ end
+
describe '#commit' do
it 'fetches the commit through the project' do
expect_any_instance_of(Project).to receive(:commit).with(commit_sha).and_return(commit)
diff --git a/spec/models/lfs_download_object_spec.rb b/spec/models/lfs_download_object_spec.rb
new file mode 100644
index 00000000000..88838b127d2
--- /dev/null
+++ b/spec/models/lfs_download_object_spec.rb
@@ -0,0 +1,68 @@
+require 'rails_helper'
+
+describe LfsDownloadObject do
+ let(:oid) { 'cd293be6cea034bd45a0352775a219ef5dc7825ce55d1f7dae9762d80ce64411' }
+ let(:link) { 'http://www.example.com' }
+ let(:size) { 1 }
+
+ subject { described_class.new(oid: oid, size: size, link: link) }
+
+ describe 'validations' do
+ it { is_expected.to validate_numericality_of(:size).is_greater_than_or_equal_to(0) }
+
+ context 'oid attribute' do
+ it 'must be 64 characters long' do
+ aggregate_failures do
+ expect(described_class.new(oid: 'a' * 63, size: size, link: link)).to be_invalid
+ expect(described_class.new(oid: 'a' * 65, size: size, link: link)).to be_invalid
+ expect(described_class.new(oid: 'a' * 64, size: size, link: link)).to be_valid
+ end
+ end
+
+ it 'must contain only hexadecimal characters' do
+ aggregate_failures do
+ expect(subject).to be_valid
+ expect(described_class.new(oid: 'g' * 64, size: size, link: link)).to be_invalid
+ end
+ end
+ end
+
+ context 'link attribute' do
+ it 'only http and https protocols are valid' do
+ aggregate_failures do
+ expect(described_class.new(oid: oid, size: size, link: 'http://www.example.com')).to be_valid
+ expect(described_class.new(oid: oid, size: size, link: 'https://www.example.com')).to be_valid
+ expect(described_class.new(oid: oid, size: size, link: 'ftp://www.example.com')).to be_invalid
+ expect(described_class.new(oid: oid, size: size, link: 'ssh://www.example.com')).to be_invalid
+ expect(described_class.new(oid: oid, size: size, link: 'git://www.example.com')).to be_invalid
+ end
+ end
+
+ it 'cannot be empty' do
+ expect(described_class.new(oid: oid, size: size, link: '')).not_to be_valid
+ end
+
+ context 'when localhost or local network addresses' do
+ subject { described_class.new(oid: oid, size: size, link: 'http://192.168.1.1') }
+
+ before do
+ allow(ApplicationSetting)
+ .to receive(:current)
+ .and_return(ApplicationSetting.build_from_defaults(allow_local_requests_from_hooks_and_services: setting))
+ end
+
+ context 'are allowed' do
+ let(:setting) { true }
+
+ it { expect(subject).to be_valid }
+ end
+
+ context 'are not allowed' do
+ let(:setting) { false }
+
+ it { expect(subject).to be_invalid }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 33e984dc399..1849d3bac12 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -46,7 +46,7 @@ describe MergeRequestDiff do
it { expect(first_diff.reload).not_to be_latest }
end
- describe '#diffs' do
+ shared_examples_for 'merge request diffs' do
let(:merge_request) { create(:merge_request, :with_diffs) }
let!(:diff) { merge_request.merge_request_diff.reload }
@@ -91,98 +91,110 @@ describe MergeRequestDiff do
diff.diffs.diff_files
end
end
- end
- describe '#raw_diffs' do
- context 'when the :ignore_whitespace_change option is set' do
- it 'creates a new compare object instead of loading from the DB' do
- expect(diff_with_commits).not_to receive(:load_diffs)
- expect(diff_with_commits.compare).to receive(:diffs).and_call_original
+ describe '#raw_diffs' do
+ context 'when the :ignore_whitespace_change option is set' do
+ it 'creates a new compare object instead of using preprocessed data' do
+ expect(diff_with_commits).not_to receive(:load_diffs)
+ expect(diff_with_commits.compare).to receive(:diffs).and_call_original
- diff_with_commits.raw_diffs(ignore_whitespace_change: true)
+ diff_with_commits.raw_diffs(ignore_whitespace_change: true)
+ end
end
- end
- context 'when the raw diffs are empty' do
- before do
- MergeRequestDiffFile.where(merge_request_diff_id: diff_with_commits.id).delete_all
- end
+ context 'when the raw diffs are empty' do
+ before do
+ MergeRequestDiffFile.where(merge_request_diff_id: diff_with_commits.id).delete_all
+ end
- it 'returns an empty DiffCollection' do
- expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
- expect(diff_with_commits.raw_diffs).to be_empty
+ it 'returns an empty DiffCollection' do
+ expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
+ expect(diff_with_commits.raw_diffs).to be_empty
+ end
end
- end
- context 'when the raw diffs exist' do
- it 'returns the diffs' do
- expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
- expect(diff_with_commits.raw_diffs).not_to be_empty
- end
+ context 'when the raw diffs exist' do
+ it 'returns the diffs' do
+ expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
+ expect(diff_with_commits.raw_diffs).not_to be_empty
+ end
- context 'when the :paths option is set' do
- let(:diffs) { diff_with_commits.raw_diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) }
+ context 'when the :paths option is set' do
+ let(:diffs) { diff_with_commits.raw_diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) }
- it 'only returns diffs that match the (old path, new path) given' do
- expect(diffs.map(&:new_path)).to contain_exactly('files/ruby/popen.rb')
- end
+ it 'only returns diffs that match the (old path, new path) given' do
+ expect(diffs.map(&:new_path)).to contain_exactly('files/ruby/popen.rb')
+ end
- it 'only serializes diff files found by query' do
- expect(diff_with_commits.merge_request_diff_files.count).to be > 10
- expect_any_instance_of(MergeRequestDiffFile).to receive(:to_hash).once
+ it 'only serializes diff files found by query' do
+ expect(diff_with_commits.merge_request_diff_files.count).to be > 10
+ expect_any_instance_of(MergeRequestDiffFile).to receive(:to_hash).once
- diffs
- end
+ diffs
+ end
- it 'uses the diffs from the DB' do
- expect(diff_with_commits).to receive(:load_diffs)
+ it 'uses the preprocessed diffs' do
+ expect(diff_with_commits).to receive(:load_diffs)
- diffs
+ diffs
+ end
end
end
end
- end
- describe '#save_diffs' do
- it 'saves collected state' do
- mr_diff = create(:merge_request).merge_request_diff
+ describe '#save_diffs' do
+ it 'saves collected state' do
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.collected?).to be_truthy
- end
+ expect(mr_diff.collected?).to be_truthy
+ end
- it 'saves overflow state' do
- allow(Commit).to receive(:max_diff_options)
- .and_return(max_lines: 0, max_files: 0)
+ it 'saves overflow state' do
+ allow(Commit).to receive(:max_diff_options)
+ .and_return(max_lines: 0, max_files: 0)
- mr_diff = create(:merge_request).merge_request_diff
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.overflow?).to be_truthy
- end
+ expect(mr_diff.overflow?).to be_truthy
+ end
- it 'saves empty state' do
- allow_any_instance_of(described_class).to receive_message_chain(:compare, :commits)
- .and_return([])
+ it 'saves empty state' do
+ allow_any_instance_of(described_class).to receive_message_chain(:compare, :commits)
+ .and_return([])
- mr_diff = create(:merge_request).merge_request_diff
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.empty?).to be_truthy
- end
+ expect(mr_diff.empty?).to be_truthy
+ end
- it 'expands collapsed diffs before saving' do
- mr_diff = create(:merge_request, source_branch: 'expand-collapse-lines', target_branch: 'master').merge_request_diff
- diff_file = mr_diff.merge_request_diff_files.find_by(new_path: 'expand-collapse/file-5.txt')
+ it 'expands collapsed diffs before saving' do
+ mr_diff = create(:merge_request, source_branch: 'expand-collapse-lines', target_branch: 'master').merge_request_diff
+ diff_file = mr_diff.merge_request_diff_files.find_by(new_path: 'expand-collapse/file-5.txt')
- expect(diff_file.diff).not_to be_empty
+ expect(diff_file.diff).not_to be_empty
+ end
+
+ it 'saves binary diffs correctly' do
+ path = 'files/images/icn-time-tracking.pdf'
+ mr_diff = create(:merge_request, source_branch: 'add-pdf-text-binary', target_branch: 'master').merge_request_diff
+ diff_file = mr_diff.merge_request_diff_files.find_by(new_path: path)
+
+ expect(diff_file).to be_binary
+ expect(diff_file.diff).to eq(mr_diff.compare.diffs(paths: [path]).to_a.first.diff)
+ end
end
+ end
- it 'saves binary diffs correctly' do
- path = 'files/images/icn-time-tracking.pdf'
- mr_diff = create(:merge_request, source_branch: 'add-pdf-text-binary', target_branch: 'master').merge_request_diff
- diff_file = mr_diff.merge_request_diff_files.find_by(new_path: path)
+ describe 'internal diffs configured' do
+ include_examples 'merge request diffs'
+ end
- expect(diff_file).to be_binary
- expect(diff_file.diff).to eq(mr_diff.compare.diffs(paths: [path]).to_a.first.diff)
+ describe 'external diffs configured' do
+ before do
+ stub_external_diffs_setting(enabled: true)
end
+
+ include_examples 'merge request diffs'
end
describe '#commit_shas' do
@@ -245,4 +257,55 @@ describe MergeRequestDiff do
expect(subject.modified_paths).to eq(%w{foo bar baz})
end
end
+
+ describe '#opening_external_diff' do
+ subject(:diff) { diff_with_commits }
+
+ context 'external diffs disabled' do
+ it { expect(diff.external_diff).not_to be_exists }
+
+ it 'yields nil' do
+ expect { |b| diff.opening_external_diff(&b) }.to yield_with_args(nil)
+ end
+ end
+
+ context 'external diffs enabled' do
+ let(:test_dir) { 'tmp/tests/external-diffs' }
+
+ around do |example|
+ FileUtils.mkdir_p(test_dir)
+
+ begin
+ example.run
+ ensure
+ FileUtils.rm_rf(test_dir)
+ end
+ end
+
+ before do
+ stub_external_diffs_setting(enabled: true, storage_path: test_dir)
+ end
+
+ it { expect(diff.external_diff).to be_exists }
+
+ it 'yields an open file' do
+ expect { |b| diff.opening_external_diff(&b) }.to yield_with_args(File)
+ end
+
+ it 'is re-entrant' do
+ outer_file_a =
+ diff.opening_external_diff do |outer_file|
+ diff.opening_external_diff do |inner_file|
+ expect(outer_file).to eq(inner_file)
+ end
+
+ outer_file
+ end
+
+ diff.opening_external_diff do |outer_file_b|
+ expect(outer_file_a).not_to eq(outer_file_b)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index b62f973ad1e..afa87b8a62d 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -82,6 +82,38 @@ describe MergeRequest do
end
end
+ describe '#default_squash_commit_message' do
+ let(:project) { subject.project }
+
+ def commit_collection(commit_hashes)
+ raw_commits = commit_hashes.map { |raw| Commit.from_hash(raw, project) }
+
+ CommitCollection.new(project, raw_commits)
+ end
+
+ it 'returns the oldest multiline commit message' do
+ commits = commit_collection([
+ { message: 'Singleline', parent_ids: [] },
+ { message: "Second multiline\nCommit message", parent_ids: [] },
+ { message: "First multiline\nCommit message", parent_ids: [] }
+ ])
+
+ expect(subject).to receive(:commits).and_return(commits)
+
+ expect(subject.default_squash_commit_message).to eq("First multiline\nCommit message")
+ end
+
+ it 'returns the merge request title if there are no multiline commits' do
+ commits = commit_collection([
+ { message: 'Singleline', parent_ids: [] }
+ ])
+
+ expect(subject).to receive(:commits).and_return(commits)
+
+ expect(subject.default_squash_commit_message).to eq(subject.title)
+ end
+ end
+
describe 'modules' do
subject { described_class }
@@ -920,18 +952,18 @@ describe MergeRequest do
end
end
- describe '#merge_commit_message' do
+ describe '#default_merge_commit_message' do
it 'includes merge information as the title' do
request = build(:merge_request, source_branch: 'source', target_branch: 'target')
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.to match("Merge branch 'source' into 'target'\n\n")
end
it 'includes its title in the body' do
request = build(:merge_request, title: 'Remove all technical debt')
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.to match("Remove all technical debt\n\n")
end
@@ -943,34 +975,34 @@ describe MergeRequest do
allow(subject.project).to receive(:default_branch).and_return(subject.target_branch)
subject.cache_merge_request_closes_issues!
- expect(subject.merge_commit_message)
+ expect(subject.default_merge_commit_message)
.to match("Closes #{issue.to_reference}")
end
it 'includes its reference in the body' do
request = build_stubbed(:merge_request)
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.to match("See merge request #{request.to_reference(full: true)}")
end
it 'excludes multiple linebreak runs when description is blank' do
request = build(:merge_request, title: 'Title', description: nil)
- expect(request.merge_commit_message).not_to match("Title\n\n\n\n")
+ expect(request.default_merge_commit_message).not_to match("Title\n\n\n\n")
end
it 'includes its description in the body' do
request = build(:merge_request, description: 'By removing all code')
- expect(request.merge_commit_message(include_description: true))
+ expect(request.default_merge_commit_message(include_description: true))
.to match("By removing all code\n\n")
end
it 'does not includes its description in the body' do
request = build(:merge_request, description: 'By removing all code')
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.not_to match("By removing all code\n\n")
end
end
diff --git a/spec/models/project_services/external_wiki_service_spec.rb b/spec/models/project_services/external_wiki_service_spec.rb
index 25e6ce7e804..62fd97b038b 100644
--- a/spec/models/project_services/external_wiki_service_spec.rb
+++ b/spec/models/project_services/external_wiki_service_spec.rb
@@ -1,7 +1,6 @@
require 'spec_helper'
describe ExternalWikiService do
- include ExternalWikiHelper
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -25,24 +24,4 @@ describe ExternalWikiService do
it { is_expected.not_to validate_presence_of(:external_wiki_url) }
end
end
-
- describe 'External wiki' do
- let(:project) { create(:project) }
-
- context 'when it is active' do
- before do
- properties = { 'external_wiki_url' => 'https://gitlab.com' }
- @service = project.create_external_wiki_service(active: true, properties: properties)
- end
-
- after do
- @service.destroy!
- end
-
- it 'replaces the wiki url' do
- wiki_path = get_project_wiki_path(project)
- expect(wiki_path).to match('https://gitlab.com')
- end
- end
- end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 7d3f2dfe374..c1767ed0535 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -405,6 +405,30 @@ describe Project do
end
end
+ describe '#all_pipelines' do
+ let(:project) { create(:project) }
+
+ before do
+ create(:ci_pipeline, project: project, ref: 'master', source: :web)
+ create(:ci_pipeline, project: project, ref: 'master', source: :external)
+ end
+
+ it 'has all pipelines' do
+ expect(project.all_pipelines.size).to eq(2)
+ end
+
+ context 'when builds are disabled' do
+ before do
+ project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
+ end
+
+ it 'should return .external pipelines' do
+ expect(project.all_pipelines).to all(have_attributes(source: 'external'))
+ expect(project.all_pipelines.size).to eq(1)
+ end
+ end
+ end
+
describe 'project token' do
it 'sets an random token if none provided' do
project = FactoryBot.create(:project, runners_token: '')
@@ -1741,7 +1765,7 @@ describe Project do
context 'using a regular repository' do
it 'creates the repository' do
expect(shell).to receive(:create_repository)
- .with(project.repository_storage, project.disk_path)
+ .with(project.repository_storage, project.disk_path, project.full_path)
.and_return(true)
expect(project.repository).to receive(:after_create)
@@ -1751,7 +1775,7 @@ describe Project do
it 'adds an error if the repository could not be created' do
expect(shell).to receive(:create_repository)
- .with(project.repository_storage, project.disk_path)
+ .with(project.repository_storage, project.disk_path, project.full_path)
.and_return(false)
expect(project.repository).not_to receive(:after_create)
@@ -1784,7 +1808,7 @@ describe Project do
.and_return(false)
allow(shell).to receive(:create_repository)
- .with(project.repository_storage, project.disk_path)
+ .with(project.repository_storage, project.disk_path, project.full_path)
.and_return(true)
expect(project).to receive(:create_repository).with(force: true)
@@ -1808,7 +1832,7 @@ describe Project do
.and_return(false)
expect(shell).to receive(:create_repository)
- .with(project.repository_storage, project.disk_path)
+ .with(project.repository_storage, project.disk_path, project.full_path)
.and_return(true)
project.ensure_repository
@@ -3074,6 +3098,66 @@ describe Project do
end
end
+ describe '.with_feature_available_for_user' do
+ let!(:user) { create(:user) }
+ let!(:feature) { MergeRequest }
+ let!(:project) { create(:project, :public, :merge_requests_enabled) }
+
+ subject { described_class.with_feature_available_for_user(feature, user) }
+
+ context 'when user has access to project' do
+ subject { described_class.with_feature_available_for_user(feature, user) }
+
+ before do
+ project.add_guest(user)
+ end
+
+ context 'when public project' do
+ context 'when feature is public' do
+ it 'returns project' do
+ is_expected.to include(project)
+ end
+ end
+
+ context 'when feature is private' do
+ let!(:project) { create(:project, :public, :merge_requests_private) }
+
+ it 'returns project when user has access to the feature' do
+ project.add_maintainer(user)
+
+ is_expected.to include(project)
+ end
+
+ it 'does not return project when user does not have the minimum access level required' do
+ is_expected.not_to include(project)
+ end
+ end
+ end
+
+ context 'when private project' do
+ let!(:project) { create(:project) }
+
+ it 'returns project when user has access to the feature' do
+ project.add_maintainer(user)
+
+ is_expected.to include(project)
+ end
+
+ it 'does not return project when user does not have the minimum access level required' do
+ is_expected.not_to include(project)
+ end
+ end
+ end
+
+ context 'when user does not have access to project' do
+ let!(:project) { create(:project) }
+
+ it 'does not return project when user cant access project' do
+ is_expected.not_to include(project)
+ end
+ end
+ end
+
describe '#pages_available?' do
let(:project) { create(:project, group: group) }
diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb
index c4af17f4726..3537dead5d1 100644
--- a/spec/models/project_team_spec.rb
+++ b/spec/models/project_team_spec.rb
@@ -178,6 +178,21 @@ describe ProjectTeam do
end
end
+ describe '#members_in_project_and_ancestors' do
+ context 'group project' do
+ it 'filters out users who are not members of the project' do
+ group = create(:group)
+ project = create(:project, group: group)
+ group_member = create(:group_member, group: group)
+ old_user = create(:user)
+
+ ProjectAuthorization.create!(project: project, user: old_user, access_level: Gitlab::Access::GUEST)
+
+ expect(project.team.members_in_project_and_ancestors).to contain_exactly(group_member.user)
+ end
+ end
+ end
+
describe "#human_max_access" do
it 'returns Maintainer role' do
user = create(:user)
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index 48a43801b9f..3ccc706edf2 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -7,7 +7,7 @@ describe ProjectWiki do
let(:repository) { project.repository }
let(:gitlab_shell) { Gitlab::Shell.new }
let(:project_wiki) { described_class.new(project, user) }
- let(:raw_repository) { Gitlab::Git::Repository.new(project.repository_storage, subject.disk_path + '.git', 'foo') }
+ let(:raw_repository) { Gitlab::Git::Repository.new(project.repository_storage, subject.disk_path + '.git', 'foo', 'group/project.wiki') }
let(:commit) { project_wiki.repository.head_commit }
subject { project_wiki }
@@ -75,7 +75,7 @@ describe ProjectWiki do
# Create a fresh project which will not have a wiki
project_wiki = described_class.new(create(:project), user)
gitlab_shell = double(:gitlab_shell)
- allow(gitlab_shell).to receive(:create_repository)
+ allow(gitlab_shell).to receive(:create_wiki_repository)
allow(project_wiki).to receive(:gitlab_shell).and_return(gitlab_shell)
expect { project_wiki.send(:wiki) }.to raise_exception(ProjectWiki::CouldNotCreateWikiError)
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index ac5874fd0f7..f78760bf567 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -1237,6 +1237,27 @@ describe Repository do
end
end
+ describe '#blobs_at' do
+ let(:empty_repository) { create(:project_empty_repo).repository }
+
+ it 'returns empty array for an empty repository' do
+ # rubocop:disable Style/WordArray
+ expect(empty_repository.blobs_at(['master', 'foobar'])).to eq([])
+ # rubocop:enable Style/WordArray
+ end
+
+ it 'returns blob array for a non-empty repository' do
+ repository.create_file(User.last, 'foobar', 'CONTENT', message: 'message', branch_name: 'master')
+
+ # rubocop:disable Style/WordArray
+ blobs = repository.blobs_at([['master', 'foobar']])
+ # rubocop:enable Style/WordArray
+
+ expect(blobs.first.name).to eq('foobar')
+ expect(blobs.size).to eq(1)
+ end
+ end
+
describe '#root_ref' do
it 'returns a branch name' do
expect(repository.root_ref).to be_an_instance_of(String)
@@ -2270,6 +2291,7 @@ describe Repository do
expect(subject).to be_a(Gitlab::Git::Repository)
expect(subject.relative_path).to eq(project.disk_path + '.git')
expect(subject.gl_repository).to eq("project-#{project.id}")
+ expect(subject.gl_project_path).to eq(project.full_path)
end
context 'with a wiki repository' do
@@ -2279,6 +2301,7 @@ describe Repository do
expect(subject).to be_a(Gitlab::Git::Repository)
expect(subject.relative_path).to eq(project.disk_path + '.wiki.git')
expect(subject.gl_repository).to eq("wiki-#{project.id}")
+ expect(subject.gl_project_path).to eq(project.full_path)
end
end
end
diff --git a/spec/models/resource_label_event_spec.rb b/spec/models/resource_label_event_spec.rb
index 3797960ac3d..7eeb2fae57d 100644
--- a/spec/models/resource_label_event_spec.rb
+++ b/spec/models/resource_label_event_spec.rb
@@ -81,14 +81,14 @@ RSpec.describe ResourceLabelEvent, type: :model do
expect(subject.outdated_markdown?).to be true
end
- it 'returns true markdown is outdated' do
- subject.attributes = { cached_markdown_version: 0 }
+ it 'returns true if markdown is outdated' do
+ subject.attributes = { cached_markdown_version: ((CacheMarkdownField::CACHE_COMMONMARK_VERSION - 1) << 16) | 0 }
expect(subject.outdated_markdown?).to be true
end
it 'returns false if label and reference are set' do
- subject.attributes = { reference: 'whatever', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION }
+ subject.attributes = { reference: 'whatever', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16 }
expect(subject.outdated_markdown?).to be false
end
diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb
index 5ec04b99957..6c35ed8f649 100644
--- a/spec/models/sent_notification_spec.rb
+++ b/spec/models/sent_notification_spec.rb
@@ -36,19 +36,41 @@ describe SentNotification do
end
end
+ shared_examples 'a successful sent notification' do
+ it 'creates a new SentNotification' do
+ expect { subject }.to change { described_class.count }.by(1)
+ end
+ end
+
describe '.record' do
let(:issue) { create(:issue) }
- it 'creates a new SentNotification' do
- expect { described_class.record(issue, user.id) }.to change { described_class.count }.by(1)
- end
+ subject { described_class.record(issue, user.id) }
+
+ it_behaves_like 'a successful sent notification'
end
describe '.record_note' do
- let(:note) { create(:diff_note_on_merge_request) }
+ subject { described_class.record_note(note, note.author.id) }
- it 'creates a new SentNotification' do
- expect { described_class.record_note(note, user.id) }.to change { described_class.count }.by(1)
+ context 'for a discussion note' do
+ let(:note) { create(:diff_note_on_merge_request) }
+
+ it_behaves_like 'a successful sent notification'
+
+ it 'sets in_reply_to_discussion_id' do
+ expect(subject.in_reply_to_discussion_id).to eq(note.discussion_id)
+ end
+ end
+
+ context 'for an individual note' do
+ let(:note) { create(:note_on_merge_request) }
+
+ it_behaves_like 'a successful sent notification'
+
+ it 'does not set in_reply_to_discussion_id' do
+ expect(subject.in_reply_to_discussion_id).to be_nil
+ end
end
end
diff --git a/spec/models/ssh_host_key_spec.rb b/spec/models/ssh_host_key_spec.rb
index 75db43b3d56..4c677569561 100644
--- a/spec/models/ssh_host_key_spec.rb
+++ b/spec/models/ssh_host_key_spec.rb
@@ -50,6 +50,35 @@ describe SshHostKey do
subject(:ssh_host_key) { described_class.new(project: project, url: 'ssh://example.com:2222', compare_host_keys: compare_host_keys) }
+ describe '.primary_key' do
+ it 'returns a symbol' do
+ expect(described_class.primary_key).to eq(:id)
+ end
+ end
+
+ describe '.find_by' do
+ let(:project) { create(:project) }
+ let(:url) { 'ssh://invalid.invalid:2222' }
+
+ let(:finding_id) { [project.id, url].join(':') }
+
+ it 'accepts a string key' do
+ result = described_class.find_by('id' => finding_id)
+
+ expect(result).to be_a(described_class)
+ expect(result.project).to eq(project)
+ expect(result.url.to_s).to eq(url)
+ end
+
+ it 'accepts a symbol key' do
+ result = described_class.find_by(id: finding_id)
+
+ expect(result).to be_a(described_class)
+ expect(result.project).to eq(project)
+ expect(result.url.to_s).to eq(url)
+ end
+ end
+
describe '#fingerprints', :use_clean_rails_memory_store_caching do
it 'returns an array of indexed fingerprints when the cache is filled' do
stub_reactive_cache(ssh_host_key, known_hosts: known_hosts)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 33842e74b92..78477ab0a5a 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1997,6 +1997,33 @@ describe User do
expect(subject).to include(accessible)
expect(subject).not_to include(other)
end
+
+ context 'with min_access_level' do
+ let!(:user) { create(:user) }
+ let!(:project) { create(:project, :private, namespace: user.namespace) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ subject { Project.where("EXISTS (?)", user.authorizations_for_projects(min_access_level: min_access_level)) }
+
+ context 'when developer access' do
+ let(:min_access_level) { Gitlab::Access::DEVELOPER }
+
+ it 'includes projects a user has access to' do
+ expect(subject).to include(project)
+ end
+ end
+
+ context 'when owner access' do
+ let(:min_access_level) { Gitlab::Access::OWNER }
+
+ it 'does not include projects with higher access level' do
+ expect(subject).not_to include(project)
+ end
+ end
+ end
end
describe '#authorized_projects', :delete do
diff --git a/spec/policies/ci/pipeline_policy_spec.rb b/spec/policies/ci/pipeline_policy_spec.rb
index 8022f61e67d..844d96017de 100644
--- a/spec/policies/ci/pipeline_policy_spec.rb
+++ b/spec/policies/ci/pipeline_policy_spec.rb
@@ -75,6 +75,14 @@ describe Ci::PipelinePolicy, :models do
end
end
+ context 'when user does not have access to internal CI' do
+ let(:project) { create(:project, :builds_disabled, :public) }
+
+ it 'disallows the user from reading the pipeline' do
+ expect(policy).to be_disallowed :read_pipeline
+ end
+ end
+
describe 'destroy_pipeline' do
let(:project) { create(:project, :public) }
diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb
index 7e25c53e77c..0e848c74659 100644
--- a/spec/policies/note_policy_spec.rb
+++ b/spec/policies/note_policy_spec.rb
@@ -28,6 +28,7 @@ describe NotePolicy, mdoels: true do
expect(policy).to be_disallowed(:admin_note)
expect(policy).to be_disallowed(:resolve_note)
expect(policy).to be_disallowed(:read_note)
+ expect(policy).to be_disallowed(:award_emoji)
end
end
@@ -40,6 +41,7 @@ describe NotePolicy, mdoels: true do
expect(policy).to be_allowed(:admin_note)
expect(policy).to be_allowed(:resolve_note)
expect(policy).to be_allowed(:read_note)
+ expect(policy).to be_allowed(:award_emoji)
end
end
end
diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb
index 397eaee068c..a38e0dbd797 100644
--- a/spec/policies/personal_snippet_policy_spec.rb
+++ b/spec/policies/personal_snippet_policy_spec.rb
@@ -14,6 +14,13 @@ describe PersonalSnippetPolicy do
]
end
+ let(:comment_permissions) do
+ [
+ :comment_personal_snippet,
+ :create_note
+ ]
+ end
+
def permissions(user)
described_class.new(user, snippet)
end
@@ -26,7 +33,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_allowed(:read_personal_snippet)
- is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*comment_permissions)
is_expected.to be_disallowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -37,7 +44,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_allowed(:read_personal_snippet)
- is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*comment_permissions)
is_expected.to be_allowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -48,7 +55,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_allowed(:read_personal_snippet)
- is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*comment_permissions)
is_expected.to be_allowed(:award_emoji)
is_expected.to be_allowed(*author_permissions)
end
@@ -63,7 +70,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_disallowed(:read_personal_snippet)
- is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*comment_permissions)
is_expected.to be_disallowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -74,7 +81,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_allowed(:read_personal_snippet)
- is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*comment_permissions)
is_expected.to be_allowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -85,7 +92,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_disallowed(:read_personal_snippet)
- is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*comment_permissions)
is_expected.to be_disallowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -96,7 +103,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_allowed(:read_personal_snippet)
- is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*comment_permissions)
is_expected.to be_allowed(:award_emoji)
is_expected.to be_allowed(*author_permissions)
end
@@ -111,7 +118,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_disallowed(:read_personal_snippet)
- is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*comment_permissions)
is_expected.to be_disallowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -122,7 +129,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_disallowed(:read_personal_snippet)
- is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*comment_permissions)
is_expected.to be_disallowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -144,7 +151,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_disallowed(:read_personal_snippet)
- is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*comment_permissions)
is_expected.to be_disallowed(:award_emoji)
is_expected.to be_disallowed(*author_permissions)
end
@@ -155,7 +162,7 @@ describe PersonalSnippetPolicy do
it do
is_expected.to be_allowed(:read_personal_snippet)
- is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*comment_permissions)
is_expected.to be_allowed(:award_emoji)
is_expected.to be_allowed(*author_permissions)
end
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 7705704a07f..93a468f585b 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -12,7 +12,7 @@ describe ProjectPolicy do
let(:base_guest_permissions) do
%i[
read_project read_board read_list read_wiki read_issue
- read_project_for_iids read_issue_iid read_merge_request_iid read_label
+ read_project_for_iids read_issue_iid read_label
read_milestone read_project_snippet read_project_member read_note
create_project create_issue create_note upload_file create_merge_request_in
award_emoji read_release
@@ -102,15 +102,27 @@ describe ProjectPolicy do
expect(Ability).not_to be_allowed(user, :read_issue, project)
end
- context 'when the feature is disabled' do
+ context 'wiki feature' do
+ let(:permissions) { %i(read_wiki create_wiki update_wiki admin_wiki download_wiki_code) }
+
subject { described_class.new(owner, project) }
- before do
- project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
- end
+ context 'when the feature is disabled' do
+ before do
+ project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
+ end
- it 'does not include the wiki permissions' do
- expect_disallowed :read_wiki, :create_wiki, :update_wiki, :admin_wiki, :download_wiki_code
+ it 'does not include the wiki permissions' do
+ expect_disallowed(*permissions)
+ end
+
+ context 'when there is an external wiki' do
+ it 'does not include the wiki permissions' do
+ allow(project).to receive(:has_external_wiki?).and_return(true)
+
+ expect_disallowed(*permissions)
+ end
+ end
end
end
@@ -152,22 +164,52 @@ describe ProjectPolicy do
end
end
+ context 'for a guest in a private project' do
+ let(:project) { create(:project, :private) }
+ subject { described_class.new(guest, project) }
+
+ it 'disallows the guest from reading the merge request and merge request iid' do
+ expect_disallowed(:read_merge_request)
+ expect_disallowed(:read_merge_request_iid)
+ end
+ end
+
context 'builds feature' do
- subject { described_class.new(owner, project) }
+ context 'when builds are disabled' do
+ subject { described_class.new(owner, project) }
- it 'disallows all permissions when the feature is disabled' do
- project.project_feature.update(builds_access_level: ProjectFeature::DISABLED)
+ before do
+ project.project_feature.update(builds_access_level: ProjectFeature::DISABLED)
+ end
- builds_permissions = [
- :create_pipeline, :update_pipeline, :admin_pipeline, :destroy_pipeline,
- :create_build, :read_build, :update_build, :admin_build, :destroy_build,
- :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
- :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
- :create_cluster, :read_cluster, :update_cluster, :admin_cluster,
- :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment
- ]
+ it 'disallows all permissions except pipeline when the feature is disabled' do
+ builds_permissions = [
+ :create_build, :read_build, :update_build, :admin_build, :destroy_build,
+ :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
+ :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
+ :create_cluster, :read_cluster, :update_cluster, :admin_cluster, :destroy_cluster,
+ :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment
+ ]
- expect_disallowed(*builds_permissions)
+ expect_disallowed(*builds_permissions)
+ end
+ end
+
+ context 'when builds are disabled only for some users' do
+ subject { described_class.new(guest, project) }
+
+ before do
+ project.project_feature.update(builds_access_level: ProjectFeature::PRIVATE)
+ end
+
+ it 'disallows pipeline and commit_status permissions' do
+ builds_permissions = [
+ :create_pipeline, :update_pipeline, :admin_pipeline, :destroy_pipeline,
+ :create_commit_status, :update_commit_status, :admin_commit_status, :destroy_commit_status
+ ]
+
+ expect_disallowed(*builds_permissions)
+ end
end
end
diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb
index 4d32e06b553..d6329e84579 100644
--- a/spec/policies/project_snippet_policy_spec.rb
+++ b/spec/policies/project_snippet_policy_spec.rb
@@ -41,7 +41,7 @@ describe ProjectSnippetPolicy do
subject { abilities(regular_user, :public) }
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -50,7 +50,7 @@ describe ProjectSnippetPolicy do
subject { abilities(external_user, :public) }
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -70,7 +70,7 @@ describe ProjectSnippetPolicy do
subject { abilities(regular_user, :internal) }
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -79,7 +79,7 @@ describe ProjectSnippetPolicy do
subject { abilities(external_user, :internal) }
it do
- expect_disallowed(:read_project_snippet)
+ expect_disallowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -92,7 +92,7 @@ describe ProjectSnippetPolicy do
end
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -112,7 +112,7 @@ describe ProjectSnippetPolicy do
subject { abilities(regular_user, :private) }
it do
- expect_disallowed(:read_project_snippet)
+ expect_disallowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -123,7 +123,7 @@ describe ProjectSnippetPolicy do
subject { described_class.new(regular_user, snippet) }
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_allowed(*author_permissions)
end
end
@@ -136,7 +136,7 @@ describe ProjectSnippetPolicy do
end
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -149,7 +149,7 @@ describe ProjectSnippetPolicy do
end
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_disallowed(*author_permissions)
end
end
@@ -158,7 +158,7 @@ describe ProjectSnippetPolicy do
subject { abilities(create(:admin), :private) }
it do
- expect_allowed(:read_project_snippet)
+ expect_allowed(:read_project_snippet, :create_note)
expect_allowed(*author_permissions)
end
end
diff --git a/spec/presenters/blob_presenter_spec.rb b/spec/presenters/blob_presenter_spec.rb
index e85e7a41017..bb1db9a3d51 100644
--- a/spec/presenters/blob_presenter_spec.rb
+++ b/spec/presenters/blob_presenter_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe BlobPresenter, :seed_helper do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:git_blob) do
Gitlab::Git::Blob.find(
diff --git a/spec/presenters/ci/trigger_presenter_spec.rb b/spec/presenters/ci/trigger_presenter_spec.rb
new file mode 100644
index 00000000000..231b539c188
--- /dev/null
+++ b/spec/presenters/ci/trigger_presenter_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe Ci::TriggerPresenter do
+ set(:user) { create(:user) }
+ set(:project) { create(:project) }
+
+ set(:trigger) do
+ create(:ci_trigger, token: '123456789abcd', project: project)
+ end
+
+ subject do
+ described_class.new(trigger, current_user: user)
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when user is not a trigger owner' do
+ describe '#token' do
+ it 'exposes only short token' do
+ expect(subject.token).not_to eq trigger.token
+ expect(subject.token).to eq '1234'
+ end
+ end
+
+ describe '#has_token_exposed?' do
+ it 'does not have token exposed' do
+ expect(subject).not_to have_token_exposed
+ end
+ end
+ end
+
+ context 'when user is a trigger owner and builds admin' do
+ before do
+ trigger.update(owner: user)
+ end
+
+ describe '#token' do
+ it 'exposes full token' do
+ expect(subject.token).to eq trigger.token
+ end
+ end
+
+ describe '#has_token_exposed?' do
+ it 'has token exposed' do
+ expect(subject).to have_token_exposed
+ end
+ end
+ end
+end
diff --git a/spec/presenters/commit_presenter_spec.rb b/spec/presenters/commit_presenter_spec.rb
new file mode 100644
index 00000000000..4a0d3a28c32
--- /dev/null
+++ b/spec/presenters/commit_presenter_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe CommitPresenter do
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit }
+ let(:user) { create(:user) }
+ let(:presenter) { described_class.new(commit, current_user: user) }
+
+ describe '#status_for' do
+ subject { presenter.status_for('ref') }
+
+ context 'when user can read_commit_status' do
+ before do
+ allow(presenter).to receive(:can?).with(user, :read_commit_status, project).and_return(true)
+ end
+
+ it 'returns commit status for ref' do
+ expect(commit).to receive(:status).with('ref').and_return('test')
+
+ expect(subject).to eq('test')
+ end
+ end
+
+ context 'when user can not read_commit_status' do
+ it 'is false' do
+ is_expected.to eq(false)
+ end
+ end
+ end
+
+ describe '#any_pipelines?' do
+ subject { presenter.any_pipelines? }
+
+ context 'when user can read pipeline' do
+ before do
+ allow(presenter).to receive(:can?).with(user, :read_pipeline, project).and_return(true)
+ end
+
+ it 'returns if there are any pipelines for commit' do
+ expect(commit).to receive_message_chain(:pipelines, :any?).and_return(true)
+
+ expect(subject).to eq(true)
+ end
+ end
+
+ context 'when user can not read pipeline' do
+ it 'is false' do
+ is_expected.to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index 9b32dc78274..1ad536258ba 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -191,7 +191,7 @@ describe API::Files do
get api(url, current_user), params: params
- expect(headers['Content-Disposition']).to eq('inline; filename="popen.rb"')
+ expect(headers['Content-Disposition']).to eq(%q(inline; filename="popen.rb"; filename*=UTF-8''popen.rb))
end
context 'when mandatory params are not given' do
diff --git a/spec/requests/api/group_labels_spec.rb b/spec/requests/api/group_labels_spec.rb
new file mode 100644
index 00000000000..3769f8b78e4
--- /dev/null
+++ b/spec/requests/api/group_labels_spec.rb
@@ -0,0 +1,258 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::GroupLabels do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+ let!(:label1) { create(:group_label, title: 'feature', group: group) }
+ let!(:label2) { create(:group_label, title: 'bug', group: group) }
+
+ describe 'GET :id/labels' do
+ it 'returns all available labels for the group' do
+ get api("/groups/#{group.id}/labels", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/group_labels')
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(2)
+ expect(json_response.map {|r| r['name'] }).to contain_exactly('feature', 'bug')
+ end
+ end
+
+ describe 'POST /groups/:id/labels' do
+ it 'returns created label when all params are given' do
+ post api("/groups/#{group.id}/labels", user),
+ params: {
+ name: 'Foo',
+ color: '#FFAABB',
+ description: 'test'
+ }
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq('Foo')
+ expect(json_response['color']).to eq('#FFAABB')
+ expect(json_response['description']).to eq('test')
+ end
+
+ it 'returns created label when only required params are given' do
+ post api("/groups/#{group.id}/labels", user),
+ params: {
+ name: 'Foo & Bar',
+ color: '#FFAABB'
+ }
+
+ expect(response.status).to eq(201)
+ expect(json_response['name']).to eq('Foo & Bar')
+ expect(json_response['color']).to eq('#FFAABB')
+ expect(json_response['description']).to be_nil
+ end
+
+ it 'returns a 400 bad request if name not given' do
+ post api("/groups/#{group.id}/labels", user), params: { color: '#FFAABB' }
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it 'returns a 400 bad request if color is not given' do
+ post api("/groups/#{group.id}/labels", user), params: { name: 'Foobar' }
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it 'returns 409 if label already exists' do
+ post api("/groups/#{group.id}/labels", user),
+ params: {
+ name: label1.name,
+ color: '#FFAABB'
+ }
+
+ expect(response).to have_gitlab_http_status(409)
+ expect(json_response['message']).to eq('Label already exists')
+ end
+ end
+
+ describe 'DELETE /groups/:id/labels' do
+ it 'returns 204 for existing label' do
+ delete api("/groups/#{group.id}/labels", user), params: { name: label1.name }
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+
+ it 'returns 404 for non existing label' do
+ delete api("/groups/#{group.id}/labels", user), params: { name: 'label2' }
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(json_response['message']).to eq('404 Label Not Found')
+ end
+
+ it 'returns 400 for wrong parameters' do
+ delete api("/groups/#{group.id}/labels", user)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it "does not delete parent's group labels", :nested_groups do
+ subgroup = create(:group, parent: group)
+ subgroup_label = create(:group_label, title: 'feature', group: subgroup)
+
+ delete api("/groups/#{subgroup.id}/labels", user), params: { name: subgroup_label.name }
+
+ expect(response).to have_gitlab_http_status(204)
+ expect(subgroup.labels.size).to eq(0)
+ expect(group.labels).to include(label1)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api("/groups/#{group.id}/labels", user) }
+ let(:params) { { name: label1.name } }
+ end
+ end
+
+ describe 'PUT /groups/:id/labels' do
+ it 'returns 200 if name and colors and description are changed' do
+ put api("/groups/#{group.id}/labels", user),
+ params: {
+ name: label1.name,
+ new_name: 'New Label',
+ color: '#FFFFFF',
+ description: 'test'
+ }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['name']).to eq('New Label')
+ expect(json_response['color']).to eq('#FFFFFF')
+ expect(json_response['description']).to eq('test')
+ end
+
+ it "does not update parent's group label", :nested_groups do
+ subgroup = create(:group, parent: group)
+ subgroup_label = create(:group_label, title: 'feature', group: subgroup)
+
+ put api("/groups/#{subgroup.id}/labels", user),
+ params: {
+ name: subgroup_label.name,
+ new_name: 'New Label'
+ }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(subgroup.labels[0].name).to eq('New Label')
+ expect(label1.name).to eq('feature')
+ end
+
+ it 'returns 404 if label does not exist' do
+ put api("/groups/#{group.id}/labels", user),
+ params: {
+ name: 'label2',
+ new_name: 'label3'
+ }
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'returns 400 if no label name given' do
+ put api("/groups/#{group.id}/labels", user), params: { new_name: label1.name }
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['error']).to eq('name is missing')
+ end
+
+ it 'returns 400 if no new parameters given' do
+ put api("/groups/#{group.id}/labels", user), params: { name: label1.name }
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['error']).to eq('new_name, color, description are missing, '\
+ 'at least one parameter must be provided')
+ end
+ end
+
+ describe 'POST /groups/:id/labels/:label_id/subscribe' do
+ context 'when label_id is a label title' do
+ it 'subscribes to the label' do
+ post api("/groups/#{group.id}/labels/#{label1.title}/subscribe", user)
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(label1.title)
+ expect(json_response['subscribed']).to be_truthy
+ end
+ end
+
+ context 'when label_id is a label ID' do
+ it 'subscribes to the label' do
+ post api("/groups/#{group.id}/labels/#{label1.id}/subscribe", user)
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(label1.title)
+ expect(json_response['subscribed']).to be_truthy
+ end
+ end
+
+ context 'when user is already subscribed to label' do
+ before do
+ label1.subscribe(user)
+ end
+
+ it 'returns 304' do
+ post api("/groups/#{group.id}/labels/#{label1.id}/subscribe", user)
+
+ expect(response).to have_gitlab_http_status(304)
+ end
+ end
+
+ context 'when label ID is not found' do
+ it 'returns 404 error' do
+ post api("/groups/#{group.id}/labels/1234/subscribe", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+
+ describe 'POST /groups/:id/labels/:label_id/unsubscribe' do
+ before do
+ label1.subscribe(user)
+ end
+
+ context 'when label_id is a label title' do
+ it 'unsubscribes from the label' do
+ post api("/groups/#{group.id}/labels/#{label1.title}/unsubscribe", user)
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(label1.title)
+ expect(json_response['subscribed']).to be_falsey
+ end
+ end
+
+ context 'when label_id is a label ID' do
+ it 'unsubscribes from the label' do
+ post api("/groups/#{group.id}/labels/#{label1.id}/unsubscribe", user)
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(label1.title)
+ expect(json_response['subscribed']).to be_falsey
+ end
+ end
+
+ context 'when user is already unsubscribed from label' do
+ before do
+ label1.unsubscribe(user)
+ end
+
+ it 'returns 304' do
+ post api("/groups/#{group.id}/labels/#{label1.id}/unsubscribe", user)
+
+ expect(response).to have_gitlab_http_status(304)
+ end
+ end
+
+ context 'when label ID is not found' do
+ it 'returns 404 error' do
+ post api("/groups/#{group.id}/labels/1234/unsubscribe", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index e0f1e303e96..04908378a24 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -208,6 +208,18 @@ describe API::Issues do
expect_paginated_array_response(issue.id)
end
+ it 'returns issues matching given search string for title and scoped in title' do
+ get api("/issues", user), params: { search: issue.title, in: 'title' }
+
+ expect_paginated_array_response(issue.id)
+ end
+
+ it 'returns an empty array if no issue matches given search string for title and scoped in description' do
+ get api("/issues", user), params: { search: issue.title, in: 'description' }
+
+ expect_paginated_array_response([])
+ end
+
it 'returns issues matching given search string for description' do
get api("/issues", user), params: { search: issue.description }
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 97aa71bf231..3defe8bbf51 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -403,7 +403,7 @@ describe API::Jobs do
shared_examples 'downloads artifact' do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
end
it 'returns specific job artifacts' do
@@ -555,7 +555,7 @@ describe API::Jobs do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
'Content-Disposition' =>
- "attachment; filename=#{job.artifacts_file.filename}" }
+ %Q(attachment; filename="#{job.artifacts_file.filename}"; filename*=UTF-8''#{job.artifacts_file.filename}) }
end
it { expect(response).to have_http_status(:ok) }
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 4d42bc39ac3..0f5f6e38819 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -260,6 +260,18 @@ describe API::MergeRequests do
expect_response_ordered_exactly(merge_request)
end
+ it 'returns merge requests matching given search string for title and scoped in title' do
+ get api("/merge_requests", user), params: { search: merge_request.title, in: 'title' }
+
+ expect_response_ordered_exactly(merge_request)
+ end
+
+ it 'returns an empty array if no merge reques matches given search string for description and scoped in title' do
+ get api("/merge_requests", user), params: { search: merge_request.description, in: 'title' }
+
+ expect_response_contain_exactly
+ end
+
it 'returns merge requests for project matching given search string for description' do
get api("/merge_requests", user), params: { project_id: project.id, search: merge_request.description }
@@ -939,6 +951,29 @@ describe API::MergeRequests do
expect(response).to have_gitlab_http_status(404)
end
+
+ describe "the squash_commit_message param" do
+ let(:squash_commit) do
+ project.repository.commits_between(json_response['diff_refs']['start_sha'], json_response['merge_commit_sha']).first
+ end
+
+ it "results in a specific squash commit message when set" do
+ squash_commit_message = 'My custom squash commit message'
+
+ put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), params: {
+ squash: true,
+ squash_commit_message: squash_commit_message
+ }
+
+ expect(squash_commit.message.chomp).to eq(squash_commit_message)
+ end
+
+ it "results in a default squash commit message when not set" do
+ put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), params: { squash: true }
+
+ expect(squash_commit.message).to eq(merge_request.default_squash_commit_message)
+ end
+ end
end
describe "PUT /projects/:id/merge_requests/:merge_request_iid" do
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 7248908b494..cfa7a1a31a3 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -21,7 +21,7 @@ describe API::Projects do
let(:project) { create(:project, :repository, namespace: user.namespace) }
let(:project2) { create(:project, namespace: user.namespace) }
let(:project_member) { create(:project_member, :developer, user: user3, project: project) }
- let(:user4) { create(:user) }
+ let(:user4) { create(:user, username: 'user.with.dot') }
let(:project3) do
create(:project,
:private,
@@ -49,6 +49,27 @@ describe API::Projects do
namespace: user4.namespace)
end
+ shared_context 'with language detection' do
+ let(:ruby) { create(:programming_language, name: 'Ruby') }
+ let(:javascript) { create(:programming_language, name: 'JavaScript') }
+ let(:html) { create(:programming_language, name: 'HTML') }
+
+ let(:mock_repo_languages) do
+ {
+ project => { ruby => 0.5, html => 0.5 },
+ project3 => { html => 0.7, javascript => 0.3 }
+ }
+ end
+
+ before do
+ mock_repo_languages.each do |proj, lang_shares|
+ lang_shares.each do |lang, share|
+ create(:repository_language, project: proj, programming_language: lang, share: share)
+ end
+ end
+ end
+ end
+
describe 'GET /projects' do
shared_examples_for 'projects response' do
it 'returns an array of projects' do
@@ -344,6 +365,19 @@ describe API::Projects do
end
end
+ context 'and using the programming language filter' do
+ include_context 'with language detection'
+
+ it 'filters case-insensitively by programming language' do
+ get api('/projects', user), params: { with_programming_language: 'javascript' }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).to contain_exactly(project3.id)
+ end
+ end
+
context 'and using sorting' do
it 'returns the correct order when sorted by id' do
get api('/projects', user), params: { order_by: 'id', sort: 'desc' }
@@ -724,7 +758,7 @@ describe API::Projects do
expect(json_response['message']).to eq('404 User Not Found')
end
- it 'returns projects filtered by user' do
+ it 'returns projects filtered by user id' do
get api("/users/#{user4.id}/projects/", user)
expect(response).to have_gitlab_http_status(200)
@@ -733,6 +767,15 @@ describe API::Projects do
expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id)
end
+ it 'returns projects filtered by username' do
+ get api("/users/#{user4.username}/projects/", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id)
+ end
+
it 'returns projects filtered by minimal access level' do
private_project1 = create(:project, :private, name: 'private_project1', creator_id: user4.id, namespace: user4.namespace)
private_project2 = create(:project, :private, name: 'private_project2', creator_id: user4.id, namespace: user4.namespace)
@@ -746,6 +789,19 @@ describe API::Projects do
expect(json_response).to be_an Array
expect(json_response.map { |project| project['id'] }).to contain_exactly(private_project1.id)
end
+
+ context 'and using the programming language filter' do
+ include_context 'with language detection'
+
+ it 'filters case-insensitively by programming language' do
+ get api('/projects', user), params: { with_programming_language: 'ruby' }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).to contain_exactly(project.id)
+ end
+ end
end
describe 'POST /projects/user/:id' do
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index 811e23fb854..1f317971a66 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -127,6 +127,31 @@ describe API::Releases do
.to match_array(release.sources.map(&:url))
end
+ context "when release description contains confidential issue's link" do
+ let(:confidential_issue) do
+ create(:issue,
+ :confidential,
+ project: project,
+ title: 'A vulnerability')
+ end
+
+ let!(:release) do
+ create(:release,
+ project: project,
+ tag: 'v0.1',
+ sha: commit.id,
+ author: maintainer,
+ description: "This is confidential #{confidential_issue.to_reference}")
+ end
+
+ it "does not expose confidential issue's title" do
+ get api("/projects/#{project.id}/releases/v0.1", maintainer)
+
+ expect(json_response['description_html']).to include(confidential_issue.to_reference)
+ expect(json_response['description_html']).not_to include('A vulnerability')
+ end
+ end
+
context 'when release has link asset' do
let!(:link) do
create(:release_link,
diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb
index ed0108c846a..d7ddd97e8c8 100644
--- a/spec/requests/api/runner_spec.rb
+++ b/spec/requests/api/runner_spec.rb
@@ -1584,7 +1584,7 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
context 'when artifacts are stored locally' do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
end
before do
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 45fb1562e84..f33eb5b9e02 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -64,7 +64,8 @@ describe API::Settings, 'Settings' do
performance_bar_allowed_group_path: group.full_path,
instance_statistics_visibility_private: true,
diff_max_patch_bytes: 150_000,
- default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE
+ default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE,
+ local_markdown_version: 3
}
expect(response).to have_gitlab_http_status(200)
@@ -90,6 +91,7 @@ describe API::Settings, 'Settings' do
expect(json_response['instance_statistics_visibility_private']).to be(true)
expect(json_response['diff_max_patch_bytes']).to eq(150_000)
expect(json_response['default_branch_protection']).to eq(Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
+ expect(json_response['local_markdown_version']).to eq(3)
end
end
diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb
index 15dc901d06e..f0f01e97f1d 100644
--- a/spec/requests/api/triggers_spec.rb
+++ b/spec/requests/api/triggers_spec.rb
@@ -1,8 +1,9 @@
require 'spec_helper'
describe API::Triggers do
- let(:user) { create(:user) }
- let(:user2) { create(:user) }
+ set(:user) { create(:user) }
+ set(:user2) { create(:user) }
+
let!(:trigger_token) { 'secure_token' }
let!(:trigger_token_2) { 'secure_token_2' }
let!(:project) { create(:project, :repository, creator: user) }
@@ -132,14 +133,17 @@ describe API::Triggers do
end
describe 'GET /projects/:id/triggers' do
- context 'authenticated user with valid permissions' do
- it 'returns list of triggers' do
+ context 'authenticated user who can access triggers' do
+ it 'returns a list of triggers with tokens exposed correctly' do
get api("/projects/#{project.id}/triggers", user)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
+
expect(json_response).to be_a(Array)
- expect(json_response[0]).to have_key('token')
+ expect(json_response.size).to eq 2
+ expect(json_response.dig(0, 'token')).to eq trigger_token
+ expect(json_response.dig(1, 'token')).to eq trigger_token_2[0..3]
end
end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 89151021f90..b381431306d 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe API::Users do
- let(:user) { create(:user) }
+ let(:user) { create(:user, username: 'user.with.dot') }
let(:admin) { create(:admin) }
let(:key) { create(:key, user: user) }
let(:gpg_key) { create(:gpg_key, user: user) }
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index f1514e90eb2..1781759c54b 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -1086,6 +1086,12 @@ describe 'Git LFS API and storage' do
end
end
+ context 'and request to finalize the upload is not sent by gitlab-workhorse' do
+ it 'fails with a JWT decode error' do
+ expect { put_finalize(lfs_tmp_file, verified: false) }.to raise_error(JWT::DecodeError)
+ end
+ end
+
context 'and workhorse requests upload finalize for a new lfs object' do
before do
lfs_object.destroy
@@ -1347,9 +1353,13 @@ describe 'Git LFS API and storage' do
context 'when pushing the same lfs object to the second project' do
before do
+ finalize_headers = headers
+ .merge('X-Gitlab-Lfs-Tmp' => lfs_tmp_file)
+ .merge(workhorse_internal_api_request_header)
+
put "#{second_project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}",
params: {},
- headers: headers.merge('X-Gitlab-Lfs-Tmp' => lfs_tmp_file).compact
+ headers: finalize_headers
end
it 'responds with status 200' do
@@ -1370,7 +1380,7 @@ describe 'Git LFS API and storage' do
put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}/authorize", params: {}, headers: authorize_headers
end
- def put_finalize(lfs_tmp = lfs_tmp_file, with_tempfile: false, args: {})
+ def put_finalize(lfs_tmp = lfs_tmp_file, with_tempfile: false, verified: true, args: {})
upload_path = LfsObjectUploader.workhorse_local_upload_path
file_path = upload_path + '/' + lfs_tmp if lfs_tmp
@@ -1384,11 +1394,14 @@ describe 'Git LFS API and storage' do
'file.name' => File.basename(file_path)
}
- put_finalize_with_args(args.merge(extra_args).compact)
+ put_finalize_with_args(args.merge(extra_args).compact, verified: verified)
end
- def put_finalize_with_args(args)
- put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", params: args, headers: headers
+ def put_finalize_with_args(args, verified:)
+ finalize_headers = headers
+ finalize_headers.merge!(workhorse_internal_api_request_header) if verified
+
+ put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", params: args, headers: finalize_headers
end
def lfs_tmp_file
diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb
index 2b148c1b563..2a455523e2c 100644
--- a/spec/requests/openid_connect_spec.rb
+++ b/spec/requests/openid_connect_spec.rb
@@ -35,7 +35,7 @@ describe 'OpenID Connect requests' do
'name' => 'Alice',
'nickname' => 'alice',
'email' => 'public@example.com',
- 'email_verified' => true,
+ 'email_verified' => false,
'website' => 'https://example.com',
'profile' => 'http://localhost/alice',
'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png",
@@ -111,6 +111,18 @@ describe 'OpenID Connect requests' do
it 'does not include any unknown claims' do
expect(json_response.keys).to eq %w[sub sub_legacy] + user_info_claims.keys
end
+
+ it 'includes email and email_verified claims' do
+ expect(json_response.keys).to include('email', 'email_verified')
+ end
+
+ it 'has public email in email claim' do
+ expect(json_response['email']).to eq(user.public_email)
+ end
+
+ it 'has false in email_verified claim' do
+ expect(json_response['email_verified']).to eq(false)
+ end
end
context 'ID token payload' do
@@ -175,7 +187,35 @@ describe 'OpenID Connect requests' do
expect(response).to have_gitlab_http_status(200)
expect(json_response['issuer']).to eq('http://localhost')
expect(json_response['jwks_uri']).to eq('http://www.example.com/oauth/discovery/keys')
- expect(json_response['scopes_supported']).to eq(%w[api read_user sudo read_repository openid])
+ expect(json_response['scopes_supported']).to eq(%w[api read_user sudo read_repository openid profile email])
+ end
+ end
+
+ context 'Application with OpenID and email scopes' do
+ let(:application) { create :oauth_application, scopes: 'openid email' }
+
+ it 'token response includes an ID token' do
+ request_access_token!
+
+ expect(json_response).to include 'id_token'
+ end
+
+ context 'UserInfo payload' do
+ before do
+ request_user_info!
+ end
+
+ it 'includes the email and email_verified claims' do
+ expect(json_response.keys).to include('email', 'email_verified')
+ end
+
+ it 'has private email in email claim' do
+ expect(json_response['email']).to eq(user.email)
+ end
+
+ it 'has true in email_verified claim' do
+ expect(json_response['email_verified']).to eq(true)
+ end
end
end
end
diff --git a/spec/requests/user_activity_spec.rb b/spec/requests/user_activity_spec.rb
new file mode 100644
index 00000000000..15666e00b9f
--- /dev/null
+++ b/spec/requests/user_activity_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Update of user activity' do
+ let(:user) { create(:user, last_activity_on: nil) }
+
+ before do
+ group = create(:group, name: 'group')
+ project = create(:project, :public, namespace: group, name: 'project')
+
+ create(:issue, project: project, iid: 10)
+ create(:merge_request, source_project: project, iid: 15)
+
+ project.add_maintainer(user)
+ end
+
+ paths_to_visit = [
+ '/group',
+ '/group/project',
+ '/groups/group/-/issues',
+ '/groups/group/-/boards',
+ '/dashboard/projects',
+ '/dashboard/snippets',
+ '/dashboard/groups',
+ '/dashboard/todos',
+ '/group/project/issues',
+ '/group/project/issues/10',
+ '/group/project/merge_requests',
+ '/group/project/merge_requests/15'
+ ]
+
+ context 'without an authenticated user' do
+ it 'does not set the last activity cookie' do
+ get "/group/project"
+
+ expect(response.cookies['user_last_activity_on']).to be_nil
+ end
+ end
+
+ context 'with an authenticated user' do
+ before do
+ login_as(user)
+ end
+
+ context 'with a POST request' do
+ it 'does not set the last activity cookie' do
+ post "/group/project/archive"
+
+ expect(response.cookies['user_last_activity_on']).to be_nil
+ end
+ end
+
+ paths_to_visit.each do |path|
+ context "on GET to #{path}" do
+ it 'updates the last activity date' do
+ expect(Users::ActivityService).to receive(:new).and_call_original
+
+ get path
+
+ expect(user.last_activity_on).to eq(Date.today)
+ end
+
+ context 'when calling it twice' do
+ it 'updates last_activity_on just once' do
+ expect(Users::ActivityService).to receive(:new).once.and_call_original
+
+ 2.times do
+ get path
+ end
+ end
+ end
+
+ context 'when last_activity_on is nil' do
+ before do
+ user.update_attribute(:last_activity_on, nil)
+ end
+
+ it 'updates the last activity date' do
+ expect(user.last_activity_on).to be_nil
+
+ get path
+
+ expect(user.last_activity_on).to eq(Date.today)
+ end
+ end
+
+ context 'when last_activity_on is stale' do
+ before do
+ user.update_attribute(:last_activity_on, 2.days.ago.to_date)
+ end
+
+ it 'updates the last activity date' do
+ get path
+
+ expect(user.last_activity_on).to eq(Date.today)
+ end
+ end
+
+ context 'when last_activity_on is up to date' do
+ before do
+ user.update_attribute(:last_activity_on, Date.today)
+ end
+
+ it 'does not try to update it' do
+ expect(Users::ActivityService).not_to receive(:new)
+
+ get path
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb
index 88d16a5b360..7e151c3744e 100644
--- a/spec/serializers/cluster_application_entity_spec.rb
+++ b/spec/serializers/cluster_application_entity_spec.rb
@@ -21,6 +21,14 @@ describe ClusterApplicationEntity do
expect(subject[:status_reason]).to be_nil
end
+ context 'non-helm application' do
+ let(:application) { build(:clusters_applications_runner, version: '0.0.0') }
+
+ it 'has update_available' do
+ expect(subject[:update_available]).to be_truthy
+ end
+ end
+
context 'when application is errored' do
let(:application) { build(:clusters_applications_helm, :errored) }
diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb
index cfa5414b40f..894fd7a0a12 100644
--- a/spec/serializers/deployment_entity_spec.rb
+++ b/spec/serializers/deployment_entity_spec.rb
@@ -1,14 +1,22 @@
require 'spec_helper'
describe DeploymentEntity do
- let(:user) { create(:user) }
+ let(:user) { developer }
+ let(:developer) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:project) { create(:project) }
let(:request) { double('request') }
- let(:deployment) { create(:deployment) }
+ let(:deployment) { create(:deployment, deployable: build, project: project) }
+ let(:build) { create(:ci_build, :manual, pipeline: pipeline) }
+ let(:pipeline) { create(:ci_pipeline, project: project, user: user) }
let(:entity) { described_class.new(deployment, request: request) }
subject { entity.as_json }
before do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:project).and_return(project)
end
it 'exposes internal deployment id' do
@@ -23,6 +31,24 @@ describe DeploymentEntity do
expect(subject).to include(:created_at)
end
+ context 'when the pipeline has another manual action' do
+ let(:other_build) { create(:ci_build, :manual, name: 'another deploy', pipeline: pipeline) }
+ let!(:other_deployment) { create(:deployment, deployable: other_build) }
+
+ it 'returns another manual action' do
+ expect(subject[:manual_actions].count).to eq(1)
+ expect(subject[:manual_actions].first[:name]).to eq('another deploy')
+ end
+
+ context 'when user is a reporter' do
+ let(:user) { reporter }
+
+ it 'returns another manual action' do
+ expect(subject[:manual_actions]).not_to be_present
+ end
+ end
+ end
+
describe 'scheduled_actions' do
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project, user: user) }
diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb
index 87493a28d1f..3541bd5f12e 100644
--- a/spec/serializers/environment_serializer_spec.rb
+++ b/spec/serializers/environment_serializer_spec.rb
@@ -10,6 +10,10 @@ describe EnvironmentSerializer do
.represent(resource)
end
+ before do
+ project.add_developer(user)
+ end
+
context 'when there is a single object provided' do
let(:project) { create(:project, :repository) }
let(:deployable) { create(:ci_build) }
diff --git a/spec/serializers/merge_request_widget_commit_entity_spec.rb b/spec/serializers/merge_request_widget_commit_entity_spec.rb
new file mode 100644
index 00000000000..ce83978c49a
--- /dev/null
+++ b/spec/serializers/merge_request_widget_commit_entity_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe MergeRequestWidgetCommitEntity do
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit }
+ let(:request) { double('request') }
+
+ let(:entity) do
+ described_class.new(commit, request: request)
+ end
+
+ context 'as json' do
+ subject { entity.as_json }
+
+ it { expect(subject[:message]).to eq(commit.safe_message) }
+ it { expect(subject[:short_id]).to eq(commit.short_id) }
+ it { expect(subject[:title]).to eq(commit.title) }
+ end
+end
diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb
index 561421d5ac8..4dbd79f2fc0 100644
--- a/spec/serializers/merge_request_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_widget_entity_spec.rb
@@ -31,23 +31,40 @@ describe MergeRequestWidgetEntity do
describe 'pipeline' do
let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: resource.source_branch, sha: resource.source_branch_sha, head_pipeline_of: resource) }
- context 'when is up to date' do
- let(:req) { double('request', current_user: user, project: project) }
+ before do
+ allow_any_instance_of(MergeRequestPresenter).to receive(:can?).and_call_original
+ allow_any_instance_of(MergeRequestPresenter).to receive(:can?).with(user, :read_pipeline, anything).and_return(result)
+ end
- it 'returns pipeline' do
- pipeline_payload = PipelineDetailsEntity
- .represent(pipeline, request: req)
- .as_json
+ context 'when user has access to pipelines' do
+ let(:result) { true }
+
+ context 'when is up to date' do
+ let(:req) { double('request', current_user: user, project: project) }
+
+ it 'returns pipeline' do
+ pipeline_payload = PipelineDetailsEntity
+ .represent(pipeline, request: req)
+ .as_json
- expect(subject[:pipeline]).to eq(pipeline_payload)
+ expect(subject[:pipeline]).to eq(pipeline_payload)
+ end
+ end
+
+ context 'when is not up to date' do
+ it 'returns nil' do
+ pipeline.update(sha: "not up to date")
+
+ expect(subject[:pipeline]).to eq(nil)
+ end
end
end
- context 'when is not up to date' do
- it 'returns nil' do
- pipeline.update(sha: "not up to date")
+ context 'when user does not have access to pipelines' do
+ let(:result) { false }
- expect(subject[:pipeline]).to be_nil
+ it 'does not have pipeline' do
+ expect(subject[:pipeline]).to eq(nil)
end
end
end
@@ -171,9 +188,14 @@ describe MergeRequestWidgetEntity do
.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.diff")
end
- it 'has merge_commit_message_with_description' do
- expect(subject[:merge_commit_message_with_description])
- .to eq(resource.merge_commit_message(include_description: true))
+ it 'has default_merge_commit_message_with_description' do
+ expect(subject[:default_merge_commit_message_with_description])
+ .to eq(resource.default_merge_commit_message(include_description: true))
+ end
+
+ it 'has default_squash_commit_message' do
+ expect(subject[:default_squash_commit_message])
+ .to eq(resource.default_squash_commit_message)
end
describe 'new_blob_path' do
@@ -255,4 +277,15 @@ describe MergeRequestWidgetEntity do
expect(entity[:rebase_path]).to be_nil
end
end
+
+ describe 'commits_without_merge_commits' do
+ it 'should not include merge commits' do
+ # Mock all but the first 5 commits to be merge commits
+ resource.commits.each_with_index do |commit, i|
+ expect(commit).to receive(:merge_commit?).at_least(:once).and_return(i > 4)
+ end
+
+ expect(subject[:commits_without_merge_commits].size).to eq(5)
+ end
+ end
end
diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb
index 45b8ce94815..19446ce1cf8 100644
--- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb
+++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Clusters::Applications::CheckInstallationProgressService do
+describe Clusters::Applications::CheckInstallationProgressService, '#execute' do
RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze
let(:application) { create(:clusters_applications_helm, :installing) }
@@ -21,24 +21,39 @@ describe Clusters::Applications::CheckInstallationProgressService do
expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once
expect(service).not_to receive(:remove_installation_pod)
- service.execute
+ expect do
+ service.execute
+
+ application.reload
+ end.not_to change(application, :status)
- expect(application).to be_installing
expect(application.status_reason).to be_nil
end
end
+ end
+ end
- context 'when timeouted' do
- let(:application) { create(:clusters_applications_helm, :timeouted) }
+ shared_examples 'error logging' do
+ context 'when installation raises a Kubeclient::HttpError' do
+ let(:cluster) { create(:cluster, :provided_by_user, :project) }
- it 'make the application errored' do
- expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in)
+ before do
+ application.update!(cluster: cluster)
- service.execute
+ expect(service).to receive(:installation_phase).and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil))
+ end
- expect(application).to be_errored
- expect(application.status_reason).to eq("Installation timed out. Check pod logs for install-helm for more details.")
- end
+ it 'shows the response code from the error' do
+ service.execute
+
+ expect(application).to be_errored.or(be_update_errored)
+ expect(application.status_reason).to eq('Kubernetes error: 401')
+ end
+
+ it 'should log error' do
+ expect(service.send(:logger)).to receive(:error)
+
+ service.execute
end
end
end
@@ -48,10 +63,15 @@ describe Clusters::Applications::CheckInstallationProgressService do
allow(service).to receive(:remove_installation_pod).and_return(nil)
end
- describe '#execute' do
+ context 'when application is updating' do
+ let(:application) { create(:clusters_applications_helm, :updating) }
+
+ include_examples 'error logging'
+
+ RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase }
+
context 'when installation POD succeeded' do
let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED }
-
before do
expect(service).to receive(:installation_phase).once.and_return(phase)
end
@@ -67,7 +87,7 @@ describe Clusters::Applications::CheckInstallationProgressService do
service.execute
- expect(application).to be_installed
+ expect(application).to be_updated
expect(application.status_reason).to be_nil
end
end
@@ -83,33 +103,86 @@ describe Clusters::Applications::CheckInstallationProgressService do
it 'make the application errored' do
service.execute
- expect(application).to be_errored
- expect(application.status_reason).to eq("Installation failed. Check pod logs for install-helm for more details.")
+ expect(application).to be_update_errored
+ expect(application.status_reason).to eq('Operation failed. Check pod logs for install-helm for more details.')
end
end
- RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase }
+ context 'when timed out' do
+ let(:application) { create(:clusters_applications_helm, :timeouted, :updating) }
- context 'when installation raises a Kubeclient::HttpError' do
- let(:cluster) { create(:cluster, :provided_by_user, :project) }
+ before do
+ expect(service).to receive(:installation_phase).once.and_return(phase)
+ end
+
+ it 'make the application errored' do
+ expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in)
+
+ service.execute
+
+ expect(application).to be_update_errored
+ expect(application.status_reason).to eq('Operation timed out. Check pod logs for install-helm for more details.')
+ end
+ end
+ end
+
+ context 'when application is installing' do
+ include_examples 'error logging'
+
+ RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase }
+ context 'when installation POD succeeded' do
+ let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED }
before do
- application.update!(cluster: cluster)
+ expect(service).to receive(:installation_phase).once.and_return(phase)
+ end
- expect(service).to receive(:installation_phase).and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil))
+ it 'removes the installation POD' do
+ expect(service).to receive(:remove_installation_pod).once
+
+ service.execute
end
- it 'shows the response code from the error' do
+ it 'make the application installed' do
+ expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in)
+
+ service.execute
+
+ expect(application).to be_installed
+ expect(application.status_reason).to be_nil
+ end
+ end
+
+ context 'when installation POD failed' do
+ let(:phase) { Gitlab::Kubernetes::Pod::FAILED }
+ let(:errors) { 'test installation failed' }
+
+ before do
+ expect(service).to receive(:installation_phase).once.and_return(phase)
+ end
+
+ it 'make the application errored' do
service.execute
expect(application).to be_errored
- expect(application.status_reason).to eq('Kubernetes error: 401')
+ expect(application.status_reason).to eq('Operation failed. Check pod logs for install-helm for more details.')
+ end
+ end
+
+ context 'when timed out' do
+ let(:application) { create(:clusters_applications_helm, :timeouted) }
+
+ before do
+ expect(service).to receive(:installation_phase).once.and_return(phase)
end
- it 'should log error' do
- expect(service.send(:logger)).to receive(:error)
+ it 'make the application errored' do
+ expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in)
service.execute
+
+ expect(application).to be_errored
+ expect(application.status_reason).to eq('Operation timed out. Check pod logs for install-helm for more details.')
end
end
end
diff --git a/spec/services/clusters/applications/create_service_spec.rb b/spec/services/clusters/applications/create_service_spec.rb
index 1a2ca23748a..3f621ed5944 100644
--- a/spec/services/clusters/applications/create_service_spec.rb
+++ b/spec/services/clusters/applications/create_service_spec.rb
@@ -13,6 +13,7 @@ describe Clusters::Applications::CreateService do
describe '#execute' do
before do
allow(ClusterInstallAppWorker).to receive(:perform_async)
+ allow(ClusterUpgradeAppWorker).to receive(:perform_async)
end
subject { service.execute(test_request) }
@@ -31,6 +32,22 @@ describe Clusters::Applications::CreateService do
subject
end
+ context 'application already installed' do
+ let!(:application) { create(:clusters_applications_helm, :installed, cluster: cluster) }
+
+ it 'does not create a new application' do
+ expect do
+ subject
+ end.not_to change(Clusters::Applications::Helm, :count)
+ end
+
+ it 'schedules an upgrade for the application' do
+ expect(Clusters::Applications::ScheduleInstallationService).to receive(:new).with(application).and_call_original
+
+ subject
+ end
+ end
+
context 'cert manager application' do
let(:params) do
{
diff --git a/spec/services/clusters/applications/schedule_installation_service_spec.rb b/spec/services/clusters/applications/schedule_installation_service_spec.rb
index 21797edd533..8380932dfaa 100644
--- a/spec/services/clusters/applications/schedule_installation_service_spec.rb
+++ b/spec/services/clusters/applications/schedule_installation_service_spec.rb
@@ -49,5 +49,29 @@ describe Clusters::Applications::ScheduleInstallationService do
it_behaves_like 'a failing service'
end
+
+ context 'when application is installed' do
+ let(:application) { create(:clusters_applications_helm, :installed) }
+
+ it 'schedules an upgrade via worker' do
+ expect(ClusterUpgradeAppWorker).to receive(:perform_async).with(application.name, application.id).once
+
+ service.execute
+
+ expect(application).to be_scheduled
+ end
+ end
+
+ context 'when application is updated' do
+ let(:application) { create(:clusters_applications_helm, :updated) }
+
+ it 'schedules an upgrade via worker' do
+ expect(ClusterUpgradeAppWorker).to receive(:perform_async).with(application.name, application.id).once
+
+ service.execute
+
+ expect(application).to be_scheduled
+ end
+ end
end
end
diff --git a/spec/services/clusters/applications/upgrade_service_spec.rb b/spec/services/clusters/applications/upgrade_service_spec.rb
new file mode 100644
index 00000000000..1822fc38dbd
--- /dev/null
+++ b/spec/services/clusters/applications/upgrade_service_spec.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Clusters::Applications::UpgradeService do
+ describe '#execute' do
+ let(:application) { create(:clusters_applications_helm, :scheduled) }
+ let!(:install_command) { application.install_command }
+ let(:service) { described_class.new(application) }
+ let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm::Api) }
+
+ before do
+ allow(service).to receive(:install_command).and_return(install_command)
+ allow(service).to receive(:helm_api).and_return(helm_client)
+ end
+
+ context 'when there are no errors' do
+ before do
+ expect(helm_client).to receive(:update).with(install_command)
+ allow(ClusterWaitForAppInstallationWorker).to receive(:perform_in).and_return(nil)
+ end
+
+ it 'make the application updating' do
+ expect(application.cluster).not_to be_nil
+ service.execute
+
+ expect(application).to be_updating
+ end
+
+ it 'schedule async installation status check' do
+ expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once
+
+ service.execute
+ end
+ end
+
+ context 'when kubernetes cluster communication fails' do
+ let(:error) { Kubeclient::HttpError.new(500, 'system failure', nil) }
+
+ before do
+ expect(helm_client).to receive(:update).with(install_command).and_raise(error)
+ end
+
+ it 'make the application errored' do
+ service.execute
+
+ expect(application).to be_update_errored
+ expect(application.status_reason).to match('Kubernetes error: 500')
+ end
+
+ it 'logs errors' do
+ expect(service.send(:logger)).to receive(:error).with(
+ {
+ exception: 'Kubeclient::HttpError',
+ message: 'system failure',
+ service: 'Clusters::Applications::UpgradeService',
+ app_id: application.id,
+ project_ids: application.cluster.project_ids,
+ group_ids: [],
+ error_code: 500
+ }
+ )
+
+ expect(Gitlab::Sentry).to receive(:track_acceptable_exception).with(
+ error,
+ extra: {
+ exception: 'Kubeclient::HttpError',
+ message: 'system failure',
+ service: 'Clusters::Applications::UpgradeService',
+ app_id: application.id,
+ project_ids: application.cluster.project_ids,
+ group_ids: [],
+ error_code: 500
+ }
+ )
+
+ service.execute
+ end
+ end
+
+ context 'a non kubernetes error happens' do
+ let(:application) { create(:clusters_applications_helm, :scheduled) }
+ let(:error) { StandardError.new('something bad happened') }
+
+ before do
+ expect(application).to receive(:make_updating!).once.and_raise(error)
+ end
+
+ it 'make the application errored' do
+ expect(helm_client).not_to receive(:update)
+
+ service.execute
+
+ expect(application).to be_update_errored
+ expect(application.status_reason).to eq("Can't start upgrade process.")
+ end
+
+ it 'logs errors' do
+ expect(service.send(:logger)).to receive(:error).with(
+ {
+ exception: 'StandardError',
+ error_code: nil,
+ message: 'something bad happened',
+ service: 'Clusters::Applications::UpgradeService',
+ app_id: application.id,
+ project_ids: application.cluster.projects.pluck(:id),
+ group_ids: []
+ }
+ )
+
+ expect(Gitlab::Sentry).to receive(:track_acceptable_exception).with(
+ error,
+ extra: {
+ exception: 'StandardError',
+ error_code: nil,
+ message: 'something bad happened',
+ service: 'Clusters::Applications::UpgradeService',
+ app_id: application.id,
+ project_ids: application.cluster.projects.pluck(:id),
+ group_ids: []
+ }
+ )
+
+ service.execute
+ end
+ end
+ end
+end
diff --git a/spec/services/error_tracking/list_projects_service_spec.rb b/spec/services/error_tracking/list_projects_service_spec.rb
new file mode 100644
index 00000000000..ee9c59e3f65
--- /dev/null
+++ b/spec/services/error_tracking/list_projects_service_spec.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ErrorTracking::ListProjectsService do
+ set(:user) { create(:user) }
+ set(:project) { create(:project) }
+
+ let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
+ let(:token) { 'test-token' }
+ let(:new_api_host) { 'https://gitlab.com/' }
+ let(:new_token) { 'new-token' }
+ let(:params) { ActionController::Parameters.new(api_host: new_api_host, token: new_token) }
+
+ let(:error_tracking_setting) do
+ create(:project_error_tracking_setting, api_url: sentry_url, token: token, project: project)
+ end
+
+ subject { described_class.new(project, user, params) }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ describe '#execute' do
+ let(:result) { subject.execute }
+
+ context 'with authorized user' do
+ before do
+ expect(project).to receive(:error_tracking_setting).at_least(:once)
+ .and_return(error_tracking_setting)
+ end
+
+ context 'set model attributes to new values' do
+ let(:new_api_url) { new_api_host + 'api/0/projects/' }
+
+ before do
+ expect(error_tracking_setting).to receive(:list_sentry_projects)
+ .and_return({ projects: [] })
+ end
+
+ it 'uses new api_url and token' do
+ subject.execute
+
+ expect(error_tracking_setting.api_url).to eq(new_api_url)
+ expect(error_tracking_setting.token).to eq(new_token)
+ error_tracking_setting.reload
+ expect(error_tracking_setting.api_url).to eq(sentry_url)
+ expect(error_tracking_setting.token).to eq(token)
+ end
+ end
+
+ context 'sentry client raises exception' do
+ before do
+ expect(error_tracking_setting).to receive(:list_sentry_projects)
+ .and_raise(Sentry::Client::Error, 'Sentry response error: 500')
+ end
+
+ it 'returns error response' do
+ expect(result[:message]).to eq('Sentry response error: 500')
+ expect(result[:http_status]).to eq(:bad_request)
+ end
+ end
+
+ context 'with invalid url' do
+ let(:params) do
+ ActionController::Parameters.new(
+ api_host: 'https://localhost',
+ token: new_token
+ )
+ end
+
+ before do
+ error_tracking_setting.enabled = false
+ end
+
+ it 'returns error' do
+ expect(result[:message]).to start_with('Api url is blocked')
+ expect(error_tracking_setting).not_to be_valid
+ end
+ end
+
+ context 'when list_sentry_projects returns projects' do
+ let(:projects) { [:list, :of, :projects] }
+
+ before do
+ expect(error_tracking_setting)
+ .to receive(:list_sentry_projects).and_return(projects: projects)
+ end
+
+ it 'returns the projects' do
+ expect(result).to eq(status: :success, projects: projects)
+ end
+ end
+ end
+
+ context 'with unauthorized user' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns error' do
+ expect(result).to include(status: :error, message: 'access denied')
+ end
+ end
+
+ context 'with error tracking disabled' do
+ before do
+ expect(project).to receive(:error_tracking_setting).at_least(:once)
+ .and_return(error_tracking_setting)
+ expect(error_tracking_setting)
+ .to receive(:list_sentry_projects).and_return(projects: [])
+
+ error_tracking_setting.enabled = false
+ end
+
+ it 'ignores enabled flag' do
+ expect(result).to include(status: :success, projects: [])
+ end
+ end
+
+ context 'error_tracking_setting is nil' do
+ let(:error_tracking_setting) { build(:project_error_tracking_setting) }
+ let(:new_api_url) { new_api_host + 'api/0/projects/' }
+
+ before do
+ expect(project).to receive(:build_error_tracking_setting).once
+ .and_return(error_tracking_setting)
+
+ expect(error_tracking_setting).to receive(:list_sentry_projects)
+ .and_return(projects: [:project1, :project2])
+ end
+
+ it 'builds a new error_tracking_setting' do
+ expect(project.error_tracking_setting).to be_nil
+
+ expect(result[:projects]).to eq([:project1, :project2])
+
+ expect(error_tracking_setting.api_url).to eq(new_api_url)
+ expect(error_tracking_setting.token).to eq(new_token)
+ expect(error_tracking_setting.enabled).to be true
+ expect(error_tracking_setting.persisted?).to be false
+ expect(error_tracking_setting.project_id).not_to be_nil
+
+ expect(project.error_tracking_setting).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index ce20bf2bef6..931e47d3a77 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -471,6 +471,8 @@ describe Issues::UpdateService, :mailer do
it { expect(issue.tasks?).to eq(true) }
+ it_behaves_like 'updating a single task'
+
context 'when tasks are marked as completed' do
before do
update_issue(description: "- [x] Task 1\n- [X] Task 2")
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 5c01463d757..3bc05182932 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -36,4 +36,13 @@ describe Members::CreateService do
expect(result[:message]).to be_present
expect(project.users).not_to include project_user
end
+
+ it 'does not add an invalid member' do
+ params = { user_ids: project_user.id.to_s, access_level: -1 }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to include(project_user.username)
+ expect(project.users).not_to include project_user
+ end
end
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index 5aa7165e135..d37ca13ebd2 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -69,14 +69,14 @@ describe Members::DestroyService do
it 'calls Member#after_decline_request' do
expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(member)
- described_class.new(current_user).execute(member)
+ described_class.new(current_user).execute(member, opts)
end
context 'when current user is the member' do
it 'does not call Member#after_decline_request' do
expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(member)
- described_class.new(member_user).execute(member)
+ described_class.new(member_user).execute(member, opts)
end
end
end
@@ -159,7 +159,7 @@ describe Members::DestroyService do
end
it_behaves_like 'a service destroying a member' do
- let(:opts) { { skip_authorization: true } }
+ let(:opts) { { skip_authorization: true, skip_subresources: true } }
let(:member) { group_project.requesters.find_by(user_id: member_user.id) }
end
@@ -168,12 +168,14 @@ describe Members::DestroyService do
end
it_behaves_like 'a service destroying a member' do
- let(:opts) { { skip_authorization: true } }
+ let(:opts) { { skip_authorization: true, skip_subresources: true } }
let(:member) { group.requesters.find_by(user_id: member_user.id) }
end
end
context 'when current user can destroy the given access requester' do
+ let(:opts) { { skip_subresources: true } }
+
before do
group_project.add_maintainer(current_user)
group.add_owner(current_user)
@@ -229,4 +231,54 @@ describe Members::DestroyService do
end
end
end
+
+ context 'subresources' do
+ let(:user) { create(:user) }
+ let(:member_user) { create(:user) }
+ let(:opts) { {} }
+
+ let(:group) { create(:group, :public) }
+ let(:subgroup) { create(:group, parent: group) }
+ let(:subsubgroup) { create(:group, parent: subgroup) }
+ let(:subsubproject) { create(:project, group: subsubgroup) }
+
+ let(:group_project) { create(:project, :public, group: group) }
+ let(:control_project) { create(:project, group: subsubgroup) }
+
+ before do
+ create(:group_member, :developer, group: subsubgroup, user: member_user)
+
+ subsubproject.add_developer(member_user)
+ control_project.add_maintainer(user)
+ group.add_owner(user)
+
+ group_member = create(:group_member, :developer, group: group, user: member_user)
+
+ described_class.new(user).execute(group_member, opts)
+ end
+
+ it 'removes the project membership' do
+ expect(group_project.members.map(&:user)).not_to include(member_user)
+ end
+
+ it 'removes the group membership' do
+ expect(group.members.map(&:user)).not_to include(member_user)
+ end
+
+ it 'removes the subgroup membership', :postgresql do
+ expect(subgroup.members.map(&:user)).not_to include(member_user)
+ end
+
+ it 'removes the subsubgroup membership', :postgresql do
+ expect(subsubgroup.members.map(&:user)).not_to include(member_user)
+ end
+
+ it 'removes the subsubproject membership', :postgresql do
+ expect(subsubproject.members.map(&:user)).not_to include(member_user)
+ end
+
+ it 'does not remove the user from the control project' do
+ expect(control_project.members.map(&:user)).to include(user)
+ end
+ end
end
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index 4e64b0c9414..b46aa65818d 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -197,6 +197,24 @@ describe MergeRequests::CreateService do
expect(merge_request.actual_head_pipeline).to be_merge_request
end
+ context 'when there are no commits between source branch and target branch' do
+ let(:opts) do
+ {
+ title: 'Awesome merge_request',
+ description: 'please fix',
+ source_branch: 'not-merged-branch',
+ target_branch: 'master'
+ }
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(merge_request).to be_persisted
+
+ merge_request.reload
+ expect(merge_request.merge_request_pipelines.count).to eq(0)
+ end
+ end
+
context "when branch pipeline was created before a merge request pipline has been created" do
before do
create(:ci_pipeline, project: merge_request.source_project,
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index 5d96b5ce27c..04a62aa454d 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -258,7 +258,7 @@ describe MergeRequests::MergeService do
it 'logs and saves error if there is an error when squashing' do
error_message = 'Failed to squash. Should be done manually'
- allow_any_instance_of(MergeRequests::SquashService).to receive(:squash).and_return(nil)
+ allow_any_instance_of(MergeRequests::SquashService).to receive(:squash!).and_return(nil)
merge_request.update(squash: true)
service.execute(merge_request)
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 1169ed5f9f2..9e9dc5a576c 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -150,11 +150,15 @@ describe MergeRequests::RefreshService do
}
end
- it 'create merge request pipeline' do
+ it 'create merge request pipeline with commits' do
expect { subject }
.to change { @merge_request.merge_request_pipelines.count }.by(1)
.and change { @fork_merge_request.merge_request_pipelines.count }.by(1)
- .and change { @another_merge_request.merge_request_pipelines.count }.by(1)
+ .and change { @another_merge_request.merge_request_pipelines.count }.by(0)
+
+ expect(@merge_request.has_commits?).to be_truthy
+ expect(@fork_merge_request.has_commits?).to be_truthy
+ expect(@another_merge_request.has_commits?).to be_falsy
end
context "when branch pipeline was created before a merge request pipline has been created" do
diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb
index 53bce15735c..2713652873e 100644
--- a/spec/services/merge_requests/squash_service_spec.rb
+++ b/spec/services/merge_requests/squash_service_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe MergeRequests::SquashService do
include GitHelpers
- let(:service) { described_class.new(project, user, {}) }
+ let(:service) { described_class.new(project, user, { merge_request: merge_request }) }
let(:user) { project.owner }
let(:project) { create(:project, :repository) }
let(:repository) { project.repository.raw }
@@ -31,32 +31,49 @@ describe MergeRequests::SquashService do
shared_examples 'the squash succeeds' do
it 'returns the squashed commit SHA' do
- result = service.execute(merge_request)
+ result = service.execute
expect(result).to match(status: :success, squash_sha: a_string_matching(/\h{40}/))
expect(result[:squash_sha]).not_to eq(merge_request.diff_head_sha)
end
it 'cleans up the temporary directory' do
- service.execute(merge_request)
+ service.execute
expect(File.exist?(squash_dir_path)).to be(false)
end
it 'does not keep the branch push event' do
- expect { service.execute(merge_request) }.not_to change { Event.count }
+ expect { service.execute }.not_to change { Event.count }
+ end
+
+ context 'when there is a single commit in the merge request' do
+ before do
+ expect(merge_request).to receive(:commits_count).at_least(:once).and_return(1)
+ end
+
+ it 'will skip performing the squash, as the outcome would be the same' do
+ expect(merge_request.target_project.repository).not_to receive(:squash)
+
+ service.execute
+ end
+
+ it 'will still perform the squash when a custom squash commit message has been provided' do
+ service = described_class.new(project, user, { merge_request: merge_request, squash_commit_message: 'A custom commit message' })
+
+ expect(merge_request.target_project.repository).to receive(:squash).and_return('sha')
+
+ service.execute
+ end
end
context 'the squashed commit' do
- let(:squash_sha) { service.execute(merge_request)[:squash_sha] }
+ let(:squash_sha) { service.execute[:squash_sha] }
let(:squash_commit) { project.repository.commit(squash_sha) }
- it 'copies the author info and message from the merge request' do
+ it 'copies the author info from the merge request' do
expect(squash_commit.author_name).to eq(merge_request.author.name)
expect(squash_commit.author_email).to eq(merge_request.author.email)
-
- # Commit messages have a trailing newline, but titles don't.
- expect(squash_commit.message.chomp).to eq(merge_request.title)
end
it 'sets the current user as the committer' do
@@ -72,21 +89,37 @@ describe MergeRequests::SquashService do
expect(squash_diff.patch.length).to eq(mr_diff.patch.length)
expect(squash_commit.sha).not_to eq(merge_request.diff_head_sha)
end
+
+ it 'has a default squash commit message if no message was provided' do
+ expect(squash_commit.message.chomp).to eq(merge_request.default_squash_commit_message.chomp)
+ end
+
+ context 'if a message was provided' do
+ let(:service) { described_class.new(project, user, { merge_request: merge_request, squash_commit_message: message }) }
+ let(:message) { 'My custom message' }
+ let(:squash_sha) { service.execute[:squash_sha] }
+
+ it 'has the same message as the message provided' do
+ expect(squash_commit.message.chomp).to eq(message)
+ end
+ end
end
end
describe '#execute' do
context 'when there is only one commit in the merge request' do
+ let(:merge_request) { merge_request_with_one_commit }
+
it 'returns that commit SHA' do
- result = service.execute(merge_request_with_one_commit)
+ result = service.execute
- expect(result).to match(status: :success, squash_sha: merge_request_with_one_commit.diff_head_sha)
+ expect(result).to match(status: :success, squash_sha: merge_request.diff_head_sha)
end
it 'does not perform any git actions' do
expect(repository).not_to receive(:popen)
- service.execute(merge_request_with_one_commit)
+ service.execute
end
end
@@ -116,12 +149,11 @@ describe MergeRequests::SquashService do
expect(service).to receive(:log_error).with(log_error)
expect(service).to receive(:log_error).with(error)
- service.execute(merge_request)
+ service.execute
end
it 'returns an error' do
- expect(service.execute(merge_request)).to match(status: :error,
- message: a_string_including('squash'))
+ expect(service.execute).to match(status: :error, message: a_string_including('squash'))
end
end
end
@@ -131,23 +163,22 @@ describe MergeRequests::SquashService do
let(:error) { 'A test error' }
before do
- allow(merge_request).to receive(:commits_count).and_raise(error)
+ allow(merge_request.target_project.repository).to receive(:squash).and_raise(error)
end
it 'logs the MR reference and exception' do
expect(service).to receive(:log_error).with(a_string_including("#{project.full_path}#{merge_request.to_reference}"))
expect(service).to receive(:log_error).with(error)
- service.execute(merge_request)
+ service.execute
end
it 'returns an error' do
- expect(service.execute(merge_request)).to match(status: :error,
- message: a_string_including('squash'))
+ expect(service.execute).to match(status: :error, message: a_string_including('squash'))
end
it 'cleans up the temporary directory' do
- service.execute(merge_request)
+ service.execute
expect(File.exist?(squash_dir_path)).to be(false)
end
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index be5ad849ba7..20580bf14b9 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -466,6 +466,8 @@ describe MergeRequests::UpdateService, :mailer do
it { expect(@merge_request.tasks?).to eq(true) }
+ it_behaves_like 'updating a single task'
+
context 'when tasks are marked as completed' do
before do
update_merge_request({ description: "- [x] Task 1\n- [X] Task 2" })
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
index ff85c261cd4..af4daff336b 100644
--- a/spec/services/notes/build_service_spec.rb
+++ b/spec/services/notes/build_service_spec.rb
@@ -45,6 +45,15 @@ describe Notes::BuildService do
end
end
+ context 'when user has no access to discussion' do
+ it 'sets an error' do
+ another_user = create(:user)
+ new_note = described_class.new(project, another_user, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute
+
+ expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
+ end
+ end
+
context 'personal snippet note' do
def reply(note, user = nil)
user ||= create(:user)
@@ -114,6 +123,46 @@ describe Notes::BuildService do
end
end
+ context 'when replying to individual note' do
+ let(:note) { create(:note_on_issue) }
+
+ subject { described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute }
+
+ shared_examples 'an individual note reply' do
+ it 'builds another individual note' do
+ expect(subject).to be_valid
+ expect(subject).to be_a(Note)
+ expect(subject.discussion_id).not_to eq(note.discussion_id)
+ end
+ end
+
+ context 'when reply_to_individual_notes is disabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ end
+
+ it_behaves_like 'an individual note reply'
+ end
+
+ context 'when reply_to_individual_notes is enabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ end
+
+ it 'sets the note up to be in reply to that note' do
+ expect(subject).to be_valid
+ expect(subject).to be_a(DiscussionNote)
+ expect(subject.discussion_id).to eq(note.discussion_id)
+ end
+
+ context 'when noteable does not support replies' do
+ let(:note) { create(:note_on_commit) }
+
+ it_behaves_like 'an individual note reply'
+ end
+ end
+ end
+
it 'builds a note without saving it' do
new_note = described_class.new(project,
author,
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 80b015d4cd0..48f1d696ff6 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -127,6 +127,10 @@ describe Notes::CreateService do
create(:diff_note_on_merge_request, noteable: merge_request, project: project_with_repo)
end
+ before do
+ project_with_repo.add_maintainer(user)
+ end
+
context 'when eligible to have a note diff file' do
let(:new_opts) do
opts.merge(in_reply_to_discussion_id: nil,
@@ -274,5 +278,42 @@ describe Notes::CreateService do
expect(note.note).to eq(':smile:')
end
end
+
+ context 'reply to individual note' do
+ let(:existing_note) { create(:note_on_issue, noteable: issue, project: project) }
+ let(:reply_opts) { opts.merge(in_reply_to_discussion_id: existing_note.discussion_id) }
+
+ subject { described_class.new(project, user, reply_opts).execute }
+
+ context 'when reply_to_individual_notes is disabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ end
+
+ it 'creates an individual note' do
+ expect(subject.type).to eq(nil)
+ expect(subject.discussion_id).not_to eq(existing_note.discussion_id)
+ end
+
+ it 'does not convert existing note' do
+ expect { subject }.not_to change { existing_note.reload.type }
+ end
+ end
+
+ context 'when reply_to_individual_notes is enabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ end
+
+ it 'creates a DiscussionNote in reply to existing note' do
+ expect(subject).to be_a(DiscussionNote)
+ expect(subject.discussion_id).to eq(existing_note.discussion_id)
+ end
+
+ it 'converts existing note to DiscussionNote' do
+ expect { subject }.to change { existing_note.reload.type }.from(nil).to('DiscussionNote')
+ end
+ end
+ end
end
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index d20e712d365..6a5a6989607 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -1646,6 +1646,23 @@ describe NotificationService, :mailer do
should_not_email(@u_guest_custom)
should_not_email(@u_disabled)
end
+
+ context 'users not having access to the new location' do
+ it 'does not send email' do
+ old_user = create(:user)
+ ProjectAuthorization.create!(project: project, user: old_user, access_level: Gitlab::Access::GUEST)
+
+ build_group(project)
+ reset_delivered_emails!
+
+ notification.project_was_moved(project, "gitlab/gitlab")
+
+ should_email(@g_watcher)
+ should_email(@g_global_watcher)
+ should_email(project.creator)
+ should_not_email(old_user)
+ end
+ end
end
context 'user with notifications disabled' do
@@ -2232,8 +2249,8 @@ describe NotificationService, :mailer do
# Users in the project's group but not part of project's team
# with different notification settings
- def build_group(project)
- group = create_nested_group
+ def build_group(project, visibility: :public)
+ group = create_nested_group(visibility)
project.update(namespace_id: group.id)
# Group member: global=disabled, group=watch
@@ -2249,10 +2266,10 @@ describe NotificationService, :mailer do
# Creates a nested group only if supported
# to avoid errors on MySQL
- def create_nested_group
+ def create_nested_group(visibility)
if Group.supports_nested_objects?
- parent_group = create(:group, :public)
- child_group = create(:group, :public, parent: parent_group)
+ parent_group = create(:group, visibility)
+ child_group = create(:group, visibility, parent: parent_group)
# Parent group member: global=disabled, parent_group=watch, child_group=global
@pg_watcher ||= create_user_with_notification(:watch, 'parent_group_watcher', parent_group)
@@ -2272,7 +2289,7 @@ describe NotificationService, :mailer do
child_group
else
- create(:group, :public)
+ create(:group, visibility)
end
end
diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb
index 458cb8f1f31..85515d548a7 100644
--- a/spec/services/preview_markdown_service_spec.rb
+++ b/spec/services/preview_markdown_service_spec.rb
@@ -114,23 +114,4 @@ describe PreviewMarkdownService do
expect(result[:commands]).to eq 'Tags this commit to v1.2.3 with "Stable release".'
end
end
-
- it 'sets correct markdown engine' do
- service = described_class.new(project, user, { markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION })
- result = service.execute
-
- expect(result[:markdown_engine]).to eq :redcarpet
-
- service = described_class.new(project, user, { markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION })
- result = service.execute
-
- expect(result[:markdown_engine]).to eq :common_mark
- end
-
- it 'honors the legacy_render parameter' do
- service = described_class.new(project, user, { legacy_render: '1' })
- result = service.execute
-
- expect(result[:markdown_engine]).to eq :redcarpet
- end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index f71e2b4bc24..d1b110b9806 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -16,7 +16,11 @@ describe Projects::CreateService, '#execute' do
Label.create(title: "bug", template: true)
project = create_project(user, opts)
- expect(project.labels).not_to be_empty
+ created_label = project.reload.labels.last
+
+ expect(created_label.type).to eq('ProjectLabel')
+ expect(created_label.project_id).to eq(project.id)
+ expect(created_label.title).to eq('bug')
end
context 'user namespace' do
@@ -112,7 +116,7 @@ describe Projects::CreateService, '#execute' do
def wiki_repo(project)
relative_path = ProjectWiki.new(project).disk_path + '.git'
- Gitlab::Git::Repository.new(project.repository_storage, relative_path, 'foobar')
+ Gitlab::Git::Repository.new(project.repository_storage, relative_path, 'foobar', project.full_path)
end
end
@@ -194,7 +198,7 @@ describe Projects::CreateService, '#execute' do
context 'with legacy storage' do
before do
- gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing")
+ gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing", 'group/project')
end
after do
@@ -230,7 +234,7 @@ describe Projects::CreateService, '#execute' do
end
before do
- gitlab_shell.create_repository(repository_storage, hashed_path)
+ gitlab_shell.create_repository(repository_storage, hashed_path, 'group/project')
end
after do
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index 26e8d829345..23ec29cce7b 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -119,7 +119,7 @@ describe Projects::ForkService do
let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path }
before do
- gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}")
+ gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}", "#{@to_user.namespace.full_path}/#{@from_project.path}")
end
after do
diff --git a/spec/services/projects/import_error_filter_spec.rb b/spec/services/projects/import_error_filter_spec.rb
new file mode 100644
index 00000000000..312b658de89
--- /dev/null
+++ b/spec/services/projects/import_error_filter_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::ImportErrorFilter do
+ it 'filters any full paths' do
+ message = 'Error importing into /my/folder Permission denied @ unlink_internal - /var/opt/gitlab/gitlab-rails/shared/a/b/c/uploads/file'
+
+ expect(described_class.filter_message(message)).to eq('Error importing into [FILTERED] Permission denied @ unlink_internal - [FILTERED]')
+ end
+
+ it 'filters any relative paths ignoring single slash ones' do
+ message = 'Error importing into my/project Permission denied @ unlink_internal - ../file/ and folder/../file'
+
+ expect(described_class.filter_message(message)).to eq('Error importing into [FILTERED] Permission denied @ unlink_internal - [FILTERED] and [FILTERED]')
+ end
+end
diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb
index 06f865dc848..7faf0fc2868 100644
--- a/spec/services/projects/import_service_spec.rb
+++ b/spec/services/projects/import_service_spec.rb
@@ -136,12 +136,12 @@ describe Projects::ImportService do
end
it 'fails if repository import fails' do
- expect_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_raise(Gitlab::Shell::Error.new('Failed to import the repository'))
+ expect_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_raise(Gitlab::Shell::Error.new('Failed to import the repository /a/b/c'))
result = subject.execute
expect(result[:status]).to eq :error
- expect(result[:message]).to eq "Error importing repository #{project.safe_import_url} into #{project.full_path} - Failed to import the repository"
+ expect(result[:message]).to eq "Error importing repository #{project.safe_import_url} into #{project.full_path} - Failed to import the repository [FILTERED]"
end
context 'when repository import scheduled' do
@@ -152,8 +152,11 @@ describe Projects::ImportService do
it 'downloads lfs objects if lfs_enabled is enabled for project' do
allow(project).to receive(:lfs_enabled?).and_return(true)
+
+ service = double
expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(oid_download_links)
- expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).to receive(:execute).twice
+ expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice
+ expect(service).to receive(:execute).twice
subject.execute
end
@@ -211,8 +214,10 @@ describe Projects::ImportService do
it 'does not have a custom repository importer downloads lfs objects' do
allow(Gitlab::GithubImport::ParallelImporter).to receive(:imports_repository?).and_return(false)
+ service = double
expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(oid_download_links)
- expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).to receive(:execute)
+ expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice
+ expect(service).to receive(:execute).twice
subject.execute
end
diff --git a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb
index d7a2829d5f8..f222c52199f 100644
--- a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb
@@ -37,8 +37,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do
describe '#execute' do
it 'retrieves each download link of every non existent lfs object' do
- subject.execute(new_oids).each do |oid, link|
- expect(link).to eq "#{import_url}/gitlab-lfs/objects/#{oid}"
+ subject.execute(new_oids).each do |lfs_download_object|
+ expect(lfs_download_object.link).to eq "#{import_url}/gitlab-lfs/objects/#{lfs_download_object.oid}"
end
end
@@ -50,8 +50,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do
it 'adds credentials to the download_link' do
result = subject.execute(new_oids)
- result.each do |oid, link|
- expect(link.starts_with?('http://user:password@')).to be_truthy
+ result.each do |lfs_download_object|
+ expect(lfs_download_object.link.starts_with?('http://user:password@')).to be_truthy
end
end
end
@@ -60,8 +60,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do
it 'does not add any credentials' do
result = subject.execute(new_oids)
- result.each do |oid, link|
- expect(link.starts_with?('http://user:password@')).to be_falsey
+ result.each do |lfs_download_object|
+ expect(lfs_download_object.link.starts_with?('http://user:password@')).to be_falsey
end
end
end
@@ -74,8 +74,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do
it 'downloads without any credentials' do
result = subject.execute(new_oids)
- result.each do |oid, link|
- expect(link.starts_with?('http://user:password@')).to be_falsey
+ result.each do |lfs_download_object|
+ expect(lfs_download_object.link.starts_with?('http://user:password@')).to be_falsey
end
end
end
@@ -92,7 +92,7 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do
describe '#parse_response_links' do
it 'does not add oid entry if href not found' do
- expect(Rails.logger).to receive(:error).with("Link for Lfs Object with oid whatever not found or invalid.")
+ expect(subject).to receive(:log_error).with("Link for Lfs Object with oid whatever not found or invalid.")
result = subject.send(:parse_response_links, invalid_object_response)
diff --git a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
index fcc87196d5a..876beb39801 100644
--- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
@@ -2,68 +2,156 @@ require 'spec_helper'
describe Projects::LfsPointers::LfsDownloadService do
let(:project) { create(:project) }
- let(:oid) { '9e548e25631dd9ce6b43afd6359ab76da2819d6a5b474e66118c7819e1d8b3e8' }
- let(:download_link) { "http://gitlab.com/#{oid}" }
let(:lfs_content) { SecureRandom.random_bytes(10) }
+ let(:oid) { Digest::SHA256.hexdigest(lfs_content) }
+ let(:download_link) { "http://gitlab.com/#{oid}" }
+ let(:size) { lfs_content.size }
+ let(:lfs_object) { LfsDownloadObject.new(oid: oid, size: size, link: download_link) }
+ let(:local_request_setting) { false }
- subject { described_class.new(project) }
+ subject { described_class.new(project, lfs_object) }
before do
+ ApplicationSetting.create_from_defaults
+
+ stub_application_setting(allow_local_requests_from_hooks_and_services: local_request_setting)
allow(project).to receive(:lfs_enabled?).and_return(true)
- WebMock.stub_request(:get, download_link).to_return(body: lfs_content)
+ end
+
+ shared_examples 'lfs temporal file is removed' do
+ it do
+ subject.execute
- allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(false)
+ expect(File.exist?(subject.send(:tmp_filename))).to be false
+ end
+ end
+
+ shared_examples 'no lfs object is created' do
+ it do
+ expect { subject.execute }.not_to change { LfsObject.count }
+ end
+
+ it 'returns error result' do
+ expect(subject.execute[:status]).to eq :error
+ end
+
+ it 'an error is logged' do
+ expect(subject).to receive(:log_error)
+
+ subject.execute
+ end
+
+ it_behaves_like 'lfs temporal file is removed'
+ end
+
+ shared_examples 'lfs object is created' do
+ it do
+ expect(subject).to receive(:download_and_save_file!).and_call_original
+
+ expect { subject.execute }.to change { LfsObject.count }.by(1)
+ end
+
+ it 'returns success result' do
+ expect(subject.execute[:status]).to eq :success
+ end
+
+ it_behaves_like 'lfs temporal file is removed'
end
describe '#execute' do
context 'when file download succeeds' do
- it 'a new lfs object is created' do
- expect { subject.execute(oid, download_link) }.to change { LfsObject.count }.from(0).to(1)
+ before do
+ WebMock.stub_request(:get, download_link).to_return(body: lfs_content)
end
+ it_behaves_like 'lfs object is created'
+
it 'has the same oid' do
- subject.execute(oid, download_link)
+ subject.execute
expect(LfsObject.first.oid).to eq oid
end
+ it 'has the same size' do
+ subject.execute
+
+ expect(LfsObject.first.size).to eq size
+ end
+
it 'stores the content' do
- subject.execute(oid, download_link)
+ subject.execute
expect(File.binread(LfsObject.first.file.file.file)).to eq lfs_content
end
end
context 'when file download fails' do
- it 'no lfs object is created' do
- expect { subject.execute(oid, download_link) }.to change { LfsObject.count }
+ before do
+ allow(Gitlab::HTTP).to receive(:get).and_return(code: 500, 'success?' => false)
+ end
+
+ it_behaves_like 'no lfs object is created'
+
+ it 'raise StandardError exception' do
+ expect(subject).to receive(:download_and_save_file!).and_raise(StandardError)
+
+ subject.execute
+ end
+ end
+
+ context 'when downloaded lfs file has a different size' do
+ let(:size) { 1 }
+
+ before do
+ WebMock.stub_request(:get, download_link).to_return(body: lfs_content)
+ end
+
+ it_behaves_like 'no lfs object is created'
+
+ it 'raise SizeError exception' do
+ expect(subject).to receive(:download_and_save_file!).and_raise(described_class::SizeError)
+
+ subject.execute
+ end
+ end
+
+ context 'when downloaded lfs file has a different oid' do
+ before do
+ WebMock.stub_request(:get, download_link).to_return(body: lfs_content)
+ allow_any_instance_of(Digest::SHA256).to receive(:hexdigest).and_return('foobar')
+ end
+
+ it_behaves_like 'no lfs object is created'
+
+ it 'raise OidError exception' do
+ expect(subject).to receive(:download_and_save_file!).and_raise(described_class::OidError)
+
+ subject.execute
end
end
context 'when credentials present' do
let(:download_link_with_credentials) { "http://user:password@gitlab.com/#{oid}" }
+ let(:lfs_object) { LfsDownloadObject.new(oid: oid, size: size, link: download_link_with_credentials) }
before do
WebMock.stub_request(:get, download_link).with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNzd29yZA==' }).to_return(body: lfs_content)
end
it 'the request adds authorization headers' do
- subject.execute(oid, download_link_with_credentials)
+ subject
end
end
context 'when localhost requests are allowed' do
let(:download_link) { 'http://192.168.2.120' }
+ let(:local_request_setting) { true }
before do
- allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(true)
+ WebMock.stub_request(:get, download_link).to_return(body: lfs_content)
end
- it 'downloads the file' do
- expect(subject).to receive(:download_and_save_file).and_call_original
-
- expect { subject.execute(oid, download_link) }.to change { LfsObject.count }.by(1)
- end
+ it_behaves_like 'lfs object is created'
end
context 'when a bad URL is used' do
@@ -71,7 +159,9 @@ describe Projects::LfsPointers::LfsDownloadService do
with_them do
it 'does not download the file' do
- expect { subject.execute(oid, download_link) }.not_to change { LfsObject.count }
+ expect(subject).not_to receive(:download_lfs_file!)
+
+ expect { subject.execute }.not_to change { LfsObject.count }
end
end
end
@@ -85,15 +175,11 @@ describe Projects::LfsPointers::LfsDownloadService do
WebMock.stub_request(:get, download_link).to_return(status: 301, headers: { 'Location' => redirect_link })
end
- it 'does not follow the redirection' do
- expect(Rails.logger).to receive(:error).with(/LFS file with oid #{oid} couldn't be downloaded/)
-
- expect { subject.execute(oid, download_link) }.not_to change { LfsObject.count }
- end
+ it_behaves_like 'no lfs object is created'
end
end
- context 'that is valid' do
+ context 'that is not blocked' do
let(:redirect_link) { "http://example.com/"}
before do
@@ -101,21 +187,35 @@ describe Projects::LfsPointers::LfsDownloadService do
WebMock.stub_request(:get, redirect_link).to_return(body: lfs_content)
end
- it 'follows the redirection' do
- expect { subject.execute(oid, download_link) }.to change { LfsObject.count }.from(0).to(1)
- end
+ it_behaves_like 'lfs object is created'
+ end
+ end
+
+ context 'when the lfs object attributes are invalid' do
+ let(:oid) { 'foobar' }
+
+ before do
+ expect(lfs_object).to be_invalid
+ end
+
+ it_behaves_like 'no lfs object is created'
+
+ it 'does not download the file' do
+ expect(subject).not_to receive(:download_lfs_file!)
+
+ subject.execute
end
end
context 'when an lfs object with the same oid already exists' do
before do
- create(:lfs_object, oid: 'oid')
+ create(:lfs_object, oid: oid)
end
it 'does not download the file' do
- expect(subject).not_to receive(:download_and_save_file)
+ expect(subject).not_to receive(:download_lfs_file!)
- subject.execute('oid', download_link)
+ subject.execute
end
end
end
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index 766276fdba3..aae50d5307f 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -201,7 +201,7 @@ describe Projects::TransferService do
before do
group.add_owner(user)
- unless gitlab_shell.create_repository(repository_storage, "#{group.full_path}/#{project.path}")
+ unless gitlab_shell.create_repository(repository_storage, "#{group.full_path}/#{project.path}", project.full_path)
raise 'failed to add repository'
end
diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb
index e4d4e6ff3dd..7f5ef3129d7 100644
--- a/spec/services/projects/update_pages_configuration_service_spec.rb
+++ b/spec/services/projects/update_pages_configuration_service_spec.rb
@@ -2,23 +2,41 @@ require 'spec_helper'
describe Projects::UpdatePagesConfigurationService do
let(:project) { create(:project) }
- subject { described_class.new(project) }
+ let(:service) { described_class.new(project) }
describe "#update" do
let(:file) { Tempfile.new('pages-test') }
+ subject { service.execute }
+
after do
file.close
file.unlink
end
- it 'updates the .update file' do
- # Access this reference to ensure scoping works
- Projects::Settings # rubocop:disable Lint/Void
- expect(subject).to receive(:pages_config_file).and_return(file.path)
- expect(subject).to receive(:reload_daemon).and_call_original
+ before do
+ allow(service).to receive(:pages_config_file).and_return(file.path)
+ end
+
+ context 'when configuration changes' do
+ it 'updates the .update file' do
+ expect(service).to receive(:reload_daemon).and_call_original
+
+ expect(subject).to include(status: :success, reload: true)
+ end
+ end
+
+ context 'when configuration does not change' do
+ before do
+ # we set the configuration
+ service.execute
+ end
+
+ it 'does not update the .update file' do
+ expect(service).not_to receive(:reload_daemon)
- expect(subject.execute).to eq({ status: :success })
+ expect(subject).to include(status: :success, reload: false)
+ end
end
end
end
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 36b619ba9be..8b70845befe 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -5,24 +5,27 @@ describe Projects::UpdatePagesService do
set(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit('HEAD').sha) }
set(:build) { create(:ci_build, pipeline: pipeline, ref: 'HEAD') }
let(:invalid_file) { fixture_file_upload('spec/fixtures/dk.png') }
- let(:extension) { 'zip' }
- let(:file) { fixture_file_upload("spec/fixtures/pages.#{extension}") }
- let(:empty_file) { fixture_file_upload("spec/fixtures/pages_empty.#{extension}") }
- let(:metadata) do
- filename = "spec/fixtures/pages.#{extension}.meta"
- fixture_file_upload(filename) if File.exist?(filename)
- end
+ let(:file) { fixture_file_upload("spec/fixtures/pages.zip") }
+ let(:empty_file) { fixture_file_upload("spec/fixtures/pages_empty.zip") }
+ let(:metadata_filename) { "spec/fixtures/pages.zip.meta" }
+ let(:metadata) { fixture_file_upload(metadata_filename) if File.exist?(metadata_filename) }
subject { described_class.new(project, build) }
before do
+ stub_feature_flags(safezip_use_rubyzip: true)
+
project.remove_pages
end
- context 'legacy artifacts' do
- let(:extension) { 'zip' }
+ context '::TMP_EXTRACT_PATH' do
+ subject { described_class::TMP_EXTRACT_PATH }
+ it { is_expected.not_to match(Gitlab::PathRegex.namespace_format_regex) }
+ end
+
+ context 'legacy artifacts' do
before do
build.update(legacy_artifacts_file: file)
build.update(legacy_artifacts_metadata: metadata)
@@ -132,6 +135,20 @@ describe Projects::UpdatePagesService do
end
end
+ context 'when using pages with non-writeable public' do
+ let(:file) { fixture_file_upload("spec/fixtures/pages_non_writeable.zip") }
+
+ context 'when using RubyZip' do
+ before do
+ stub_feature_flags(safezip_use_rubyzip: true)
+ end
+
+ it 'succeeds to extract' do
+ expect(execute).to eq(:success)
+ end
+ end
+ end
+
context 'when timeout happens by DNS error' do
before do
allow_any_instance_of(described_class)
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index 8adfc63222e..90eaea9c872 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -232,7 +232,7 @@ describe Projects::UpdateService do
let(:project) { create(:project, :legacy_storage, :repository, creator: user, namespace: user.namespace) }
before do
- gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing")
+ gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing", user.namespace.full_path)
end
after do
diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb
new file mode 100644
index 00000000000..7c5480d382f
--- /dev/null
+++ b/spec/services/task_list_toggle_service_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe TaskListToggleService do
+ let(:markdown) do
+ <<-EOT.strip_heredoc
+ * [ ] Task 1
+ * [x] Task 2
+
+ A paragraph
+
+ 1. [X] Item 1
+ - [ ] Sub-item 1
+ EOT
+ end
+
+ let(:markdown_html) do
+ <<-EOT.strip_heredoc
+ <ul data-sourcepos="1:1-3:0" class="task-list" dir="auto">
+ <li data-sourcepos="1:1-1:12" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> Task 1
+ </li>
+ <li data-sourcepos="2:1-3:0" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled checked> Task 2
+ </li>
+ </ul>
+ <p data-sourcepos="4:1-4:11" dir="auto">A paragraph</p>
+ <ol data-sourcepos="6:1-7:19" class="task-list" dir="auto">
+ <li data-sourcepos="6:1-7:19" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled checked> Item 1
+ <ul data-sourcepos="7:4-7:19" class="task-list">
+ <li data-sourcepos="7:4-7:19" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> Sub-item 1
+ </li>
+ </ul>
+ </li>
+ </ol>
+ EOT
+ end
+
+ it 'checks Task 1' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: true,
+ line_source: '* [ ] Task 1', line_number: 1)
+
+ expect(toggler.execute).to be_truthy
+ expect(toggler.updated_markdown.lines[0]).to eq "* [x] Task 1\n"
+ expect(toggler.updated_markdown_html).to include('disabled checked> Task 1')
+ end
+
+ it 'unchecks Item 1' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: false,
+ line_source: '1. [X] Item 1', line_number: 6)
+
+ expect(toggler.execute).to be_truthy
+ expect(toggler.updated_markdown.lines[5]).to eq "1. [ ] Item 1\n"
+ expect(toggler.updated_markdown_html).to include('disabled> Item 1')
+ end
+
+ it 'returns false if line_source does not match the text' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: false,
+ line_source: '* [x] Task Added', line_number: 2)
+
+ expect(toggler.execute).to be_falsey
+ end
+
+ it 'tolerates \r\n line endings' do
+ rn_markdown = markdown.gsub("\n", "\r\n")
+ toggler = described_class.new(rn_markdown, markdown_html,
+ toggle_as_checked: true,
+ line_source: '* [ ] Task 1', line_number: 1)
+
+ expect(toggler.execute).to be_truthy
+ expect(toggler.updated_markdown.lines[0]).to eq "* [x] Task 1\r\n"
+ expect(toggler.updated_markdown_html).to include('disabled checked> Task 1')
+ end
+
+ it 'returns false if markdown is nil' do
+ toggler = described_class.new(nil, markdown_html,
+ toggle_as_checked: false,
+ line_source: '* [x] Task Added', line_number: 2)
+
+ expect(toggler.execute).to be_falsey
+ end
+
+ it 'returns false if markdown_html is nil' do
+ toggler = described_class.new(markdown, nil,
+ toggle_as_checked: false,
+ line_source: '* [x] Task Added', line_number: 2)
+
+ expect(toggler.execute).to be_falsey
+ end
+end
diff --git a/spec/support/helpers/features/responsive_table_helpers.rb b/spec/support/helpers/features/responsive_table_helpers.rb
new file mode 100644
index 00000000000..7a175219fe9
--- /dev/null
+++ b/spec/support/helpers/features/responsive_table_helpers.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+# These helpers allow you to access rows in a responsive table
+#
+# Usage:
+# describe "..." do
+# include Spec::Support::Helpers::Features::ResponsiveTableHelpers
+# ...
+#
+# expect(first_row.text).to include("John Doe")
+# expect(second_row.text).to include("John Smith")
+#
+# Note:
+# index starts at 1 as index 0 is expected to be the table header
+#
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module ResponsiveTableHelpers
+ def first_row
+ page.all('.gl-responsive-table-row')[1]
+ end
+
+ def second_row
+ page.all('.gl-responsive-table-row')[2]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/stub_configuration.rb b/spec/support/helpers/stub_configuration.rb
index 2851cd9733c..ff21bbe28ca 100644
--- a/spec/support/helpers/stub_configuration.rb
+++ b/spec/support/helpers/stub_configuration.rb
@@ -56,6 +56,10 @@ module StubConfiguration
allow(Gitlab.config.lfs).to receive_messages(to_settings(messages))
end
+ def stub_external_diffs_setting(messages)
+ allow(Gitlab.config.external_diffs).to receive_messages(to_settings(messages))
+ end
+
def stub_artifacts_setting(messages)
allow(Gitlab.config.artifacts).to receive_messages(to_settings(messages))
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index 58b5c6a6435..e0c50e533a6 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -42,6 +42,13 @@ module StubObjectStorage
**params)
end
+ def stub_external_diffs_object_storage(uploader = described_class, **params)
+ stub_object_storage_uploader(config: Gitlab.config.external_diffs.object_store,
+ uploader: uploader,
+ remote_directory: 'external_diffs',
+ **params)
+ end
+
def stub_lfs_object_storage(**params)
stub_object_storage_uploader(config: Gitlab.config.lfs.object_store,
uploader: LfsObjectUploader,
diff --git a/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb b/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb
index a3d31e26498..982e0317f7f 100644
--- a/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb
+++ b/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb
@@ -28,7 +28,13 @@ shared_examples 'repository lfs file load' do
end
it 'serves the file' do
- expect(controller).to receive(:send_file).with("#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: filename, disposition: 'attachment')
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ expect(controller).to receive(:send_file)
+ .with(
+ "#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897",
+ filename: filename,
+ disposition: %Q(attachment; filename*=UTF-8''#{filename}))
subject
@@ -56,7 +62,7 @@ shared_examples 'repository lfs file load' do
file_uri = URI.parse(response.location)
params = CGI.parse(file_uri.query)
- expect(params["response-content-disposition"].first).to eq "attachment;filename=\"#{filename}\""
+ expect(params["response-content-disposition"].first).to eq(%q(attachment; filename="lfs_object.iso"; filename*=UTF-8''lfs_object.iso))
end
end
end
diff --git a/spec/support/shared_examples/issuable_shared_examples.rb b/spec/support/shared_examples/issuable_shared_examples.rb
index 42f3b4db23c..c3d40c5b231 100644
--- a/spec/support/shared_examples/issuable_shared_examples.rb
+++ b/spec/support/shared_examples/issuable_shared_examples.rb
@@ -36,3 +36,76 @@ shared_examples 'system notes for milestones' do
end
end
end
+
+shared_examples 'updating a single task' do
+ def update_issuable(opts)
+ issuable = try(:issue) || try(:merge_request)
+ described_class.new(project, user, opts).execute(issuable)
+ end
+
+ before do
+ update_issuable(description: "- [ ] Task 1\n- [ ] Task 2")
+ end
+
+ context 'when a task is marked as completed' do
+ before do
+ update_issuable(update_task: { index: 1, checked: true, line_source: '- [ ] Task 1', line_number: 1 })
+ end
+
+ it 'creates system note about task status change' do
+ note1 = find_note('marked the task **Task 1** as completed')
+
+ expect(note1).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
+ end
+ end
+
+ context 'when a task is marked as incomplete' do
+ before do
+ update_issuable(description: "- [x] Task 1\n- [X] Task 2")
+ update_issuable(update_task: { index: 2, checked: false, line_source: '- [X] Task 2', line_number: 2 })
+ end
+
+ it 'creates system note about task status change' do
+ note1 = find_note('marked the task **Task 2** as incomplete')
+
+ expect(note1).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
+ end
+ end
+
+ context 'when the task position has been modified' do
+ before do
+ update_issuable(description: "- [ ] Task 1\n- [ ] Task 3\n- [ ] Task 2")
+ end
+
+ it 'raises an exception' do
+ expect(Note.count).to eq(2)
+ expect do
+ update_issuable(update_task: { index: 2, checked: true, line_source: '- [ ] Task 2', line_number: 2 })
+ end.to raise_error(ActiveRecord::StaleObjectError)
+ expect(Note.count).to eq(2)
+ end
+ end
+
+ context 'when the content changes but not task line number' do
+ before do
+ update_issuable(description: "Paragraph\n\n- [ ] Task 1\n- [x] Task 2")
+ update_issuable(description: "Paragraph with more words\n\n- [ ] Task 1\n- [x] Task 2")
+ update_issuable(update_task: { index: 2, checked: false, line_source: '- [x] Task 2', line_number: 4 })
+ end
+
+ it 'creates system note about task status change' do
+ note1 = find_note('marked the task **Task 2** as incomplete')
+
+ expect(note1).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(2)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/cluster_application_initial_status.rb b/spec/support/shared_examples/models/cluster_application_initial_status.rb
new file mode 100644
index 00000000000..9775d87953c
--- /dev/null
+++ b/spec/support/shared_examples/models/cluster_application_initial_status.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+shared_examples 'cluster application initial status specs' do
+ describe '#status' do
+ let(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ subject { described_class.new(cluster: cluster) }
+
+ context 'when application helm is scheduled' do
+ before do
+ create(:clusters_applications_helm, :scheduled, cluster: cluster)
+ end
+
+ it 'defaults to :not_installable' do
+ expect(subject.status_name).to be(:not_installable)
+ end
+ end
+
+ context 'when application is scheduled' do
+ before do
+ create(:clusters_applications_helm, :installed, cluster: cluster)
+ end
+
+ it 'sets a default status' do
+ expect(subject.status_name).to be(:installable)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
index c391cc48f4e..c96a65cb56a 100644
--- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
+++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
@@ -7,26 +7,6 @@ shared_examples 'cluster application status specs' do |application_name|
it 'sets a default status' do
expect(subject.status_name).to be(:not_installable)
end
-
- context 'when application helm is scheduled' do
- before do
- create(:clusters_applications_helm, :scheduled, cluster: cluster)
- end
-
- it 'defaults to :not_installable' do
- expect(subject.status_name).to be(:not_installable)
- end
- end
-
- context 'when application is scheduled' do
- before do
- create(:clusters_applications_helm, :installed, cluster: cluster)
- end
-
- it 'sets a default status' do
- expect(subject.status_name).to be(:installable)
- end
- end
end
describe 'status state machine' do
@@ -58,25 +38,45 @@ shared_examples 'cluster application status specs' do |application_name|
expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION)
end
- end
- describe '#make_updated' do
- subject { create(application_name, :updating) }
+ it 'sets the correct version of the application' do
+ subject.update!(version: '0.0.0')
- it 'is updated' do
- subject.make_updated!
+ subject.make_installed!
+
+ subject.reload
- expect(subject).to be_updated
+ expect(subject.version).to eq(subject.class.const_get(:VERSION))
end
- it 'updates helm version' do
- subject.cluster.application_helm.update!(version: '1.2.3')
+ context 'application is updating' do
+ subject { create(application_name, :updating) }
- subject.make_updated!
+ it 'is updated' do
+ subject.make_installed!
- subject.cluster.application_helm.reload
+ expect(subject).to be_updated
+ end
- expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION)
+ it 'updates helm version' do
+ subject.cluster.application_helm.update!(version: '1.2.3')
+
+ subject.make_installed!
+
+ subject.cluster.application_helm.reload
+
+ expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION)
+ end
+
+ it 'updates the version of the application' do
+ subject.update!(version: '0.0.0')
+
+ subject.make_installed!
+
+ subject.reload
+
+ expect(subject.version).to eq(subject.class.const_get(:VERSION))
+ end
end
end
@@ -90,6 +90,17 @@ shared_examples 'cluster application status specs' do |application_name|
expect(subject).to be_errored
expect(subject.status_reason).to eq(reason)
end
+
+ context 'application is updating' do
+ subject { create(application_name, :updating) }
+
+ it 'is update_errored' do
+ subject.make_errored(reason)
+
+ expect(subject).to be_update_errored
+ expect(subject.status_reason).to eq(reason)
+ end
+ end
end
describe '#make_scheduled' do
@@ -112,6 +123,18 @@ shared_examples 'cluster application status specs' do |application_name|
expect(subject.status_reason).to be_nil
end
end
+
+ describe 'when was updated_errored' do
+ subject { create(application_name, :update_errored) }
+
+ it 'clears #status_reason' do
+ expect(subject.status_reason).not_to be_nil
+
+ subject.make_scheduled!
+
+ expect(subject.status_reason).to be_nil
+ end
+ end
end
end
diff --git a/spec/support/shared_examples/models/cluster_application_version_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_version_shared_examples.rb
new file mode 100644
index 00000000000..181b102e685
--- /dev/null
+++ b/spec/support/shared_examples/models/cluster_application_version_shared_examples.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+shared_examples 'cluster application version specs' do |application_name|
+ describe 'update_available?' do
+ let(:version) { '0.0.0' }
+
+ subject { create(application_name, :installed, version: version).update_available? }
+
+ context 'version is not the same as VERSION' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'version is the same as VERSION' do
+ let(:application) { build(application_name) }
+ let(:version) { application.class.const_get(:VERSION) }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index 3b8f7f5fe7d..a8fae4a88a3 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -71,21 +71,29 @@ describe 'gitlab:app namespace rake task' do
end.to raise_error(SystemExit)
end
- it 'invokes restoration on match' do
- allow(YAML).to receive(:load_file)
- .and_return({ gitlab_version: gitlab_version })
-
- expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke)
- expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
+ context 'restore with matching gitlab version' do
+ before do
+ allow(YAML).to receive(:load_file)
+ .and_return({ gitlab_version: gitlab_version })
+ expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke)
+ end
+
+ it 'invokes restoration on match' do
+ expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
+ end
+
+ it 'prints timestamps on messages' do
+ expect { run_rake_task('gitlab:backup:restore') }.to output(/.*\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\s[-+]\d{4}\s--\s.*/).to_stdout
+ end
end
end
diff --git a/spec/uploaders/external_diff_uploader_spec.rb b/spec/uploaders/external_diff_uploader_spec.rb
new file mode 100644
index 00000000000..1c959770dc4
--- /dev/null
+++ b/spec/uploaders/external_diff_uploader_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe ExternalDiffUploader do
+ let(:diff) { create(:merge_request).merge_request_diff }
+ let(:path) { Gitlab.config.external_diffs.storage_path }
+
+ subject(:uploader) { described_class.new(diff, :external_diff) }
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[merge_request_diffs/mr-\d+],
+ cache_dir: %r[/external-diffs/tmp/cache],
+ work_dir: %r[/external-diffs/tmp/work]
+
+ context "object store is REMOTE" do
+ before do
+ stub_external_diffs_object_storage
+ end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[merge_request_diffs/mr-\d+]
+ end
+
+ describe 'migration to object storage' do
+ context 'with object storage disabled' do
+ it "is skipped" do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ diff
+ end
+ end
+
+ context 'with object storage enabled' do
+ before do
+ stub_external_diffs_setting(enabled: true)
+ stub_external_diffs_object_storage(background_upload: true)
+ end
+
+ it 'is scheduled to run after creation' do
+ expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with(described_class.name, 'MergeRequestDiff', :external_diff, kind_of(Numeric))
+
+ diff
+ end
+ end
+ end
+
+ describe 'remote file' do
+ context 'with object storage enabled' do
+ before do
+ stub_external_diffs_setting(enabled: true)
+ stub_external_diffs_object_storage
+
+ diff.update!(external_diff_store: described_class::Store::REMOTE)
+ end
+
+ it 'can store file remotely' do
+ allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async)
+
+ diff
+
+ expect(diff.external_diff_store).to eq(described_class::Store::REMOTE)
+ expect(diff.external_diff.path).not_to be_blank
+ end
+ end
+ end
+end
diff --git a/spec/validators/js_regex_validator_spec.rb b/spec/validators/js_regex_validator_spec.rb
index aeb55cdc0e5..4d3bafaf267 100644
--- a/spec/validators/js_regex_validator_spec.rb
+++ b/spec/validators/js_regex_validator_spec.rb
@@ -12,8 +12,6 @@ describe JsRegexValidator do
'' | []
'(?#comment)' | ['Regex Pattern (?#comment) can not be expressed in Javascript']
'(?(a)b|c)' | ['invalid conditional pattern: /(?(a)b|c)/i']
- '[a-z&&[^uo]]' | ["Dropped unsupported set intersection '[a-z&&[^uo]]' at index 0",
- "Dropped unsupported nested negative set data '[^uo]' at index 6"]
end
with_them do
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index 2852aa380b2..d9f05e5f94f 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -57,4 +57,58 @@ describe 'layouts/nav/sidebar/_project' do
expect(rendered).to have_link('Releases', href: project_releases_path(project))
end
end
+
+ describe 'wiki entry tab' do
+ let(:can_read_wiki) { true }
+
+ before do
+ allow(view).to receive(:can?).with(nil, :read_wiki, project).and_return(can_read_wiki)
+ end
+
+ describe 'when wiki is enabled' do
+ it 'shows the wiki tab with the wiki internal link' do
+ render
+
+ expect(rendered).to have_link('Wiki', href: project_wiki_path(project, :home))
+ end
+ end
+
+ describe 'when wiki is disabled' do
+ let(:can_read_wiki) { false }
+
+ it 'does not show the wiki tab' do
+ render
+
+ expect(rendered).not_to have_link('Wiki', href: project_wiki_path(project, :home))
+ end
+ end
+ end
+
+ describe 'external wiki entry tab' do
+ let(:properties) { { 'external_wiki_url' => 'https://gitlab.com' } }
+ let(:service_status) { true }
+
+ before do
+ project.create_external_wiki_service(active: service_status, properties: properties)
+ project.reload
+ end
+
+ context 'when it is active' do
+ it 'shows the external wiki tab with the external wiki service link' do
+ render
+
+ expect(rendered).to have_link('External Wiki', href: properties['external_wiki_url'])
+ end
+ end
+
+ context 'when it is disabled' do
+ let(:service_status) { false }
+
+ it 'does not show the external wiki tab' do
+ render
+
+ expect(rendered).not_to have_link('External Wiki', href: project_wiki_path(project, :home))
+ end
+ end
+ end
end
diff --git a/spec/views/projects/commit/_commit_box.html.haml_spec.rb b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
index 2fdd28a3be4..1086546c10d 100644
--- a/spec/views/projects/commit/_commit_box.html.haml_spec.rb
+++ b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
@@ -9,6 +9,7 @@ describe 'projects/commit/_commit_box.html.haml' do
assign(:commit, project.commit)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:can_collaborate_with_project?).and_return(false)
+ project.add_developer(user)
end
it 'shows the commit SHA' do
@@ -48,7 +49,6 @@ describe 'projects/commit/_commit_box.html.haml' do
context 'viewing a commit' do
context 'as a developer' do
before do
- project.add_developer(user)
allow(view).to receive(:can_collaborate_with_project?).and_return(true)
end
@@ -60,6 +60,10 @@ describe 'projects/commit/_commit_box.html.haml' do
end
context 'as a non-developer' do
+ before do
+ project.add_guest(user)
+ end
+
it 'does not have a link to create a new tag' do
render
diff --git a/spec/views/projects/issues/_related_branches.html.haml_spec.rb b/spec/views/projects/issues/_related_branches.html.haml_spec.rb
index 8c845251765..5cff7694029 100644
--- a/spec/views/projects/issues/_related_branches.html.haml_spec.rb
+++ b/spec/views/projects/issues/_related_branches.html.haml_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
describe 'projects/issues/_related_branches' do
include Devise::Test::ControllerHelpers
+ let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:branch) { project.repository.find_branch('feature') }
let!(:pipeline) { create(:ci_pipeline, project: project, sha: branch.dereferenced_target.id, ref: 'feature') }
@@ -11,6 +12,9 @@ describe 'projects/issues/_related_branches' do
assign(:project, project)
assign(:related_branches, ['feature'])
+ project.add_developer(user)
+ allow(view).to receive(:current_user).and_return(user)
+
render
end
diff --git a/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb b/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb
index cb1b9e6f5fb..2a2539c80b5 100644
--- a/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb
+++ b/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb
@@ -7,56 +7,9 @@ describe 'projects/settings/ci_cd/_autodevops_form' do
assign :project, project
end
- context 'when kubernetes is not active' do
- context 'when auto devops domain is not defined' do
- it 'shows warning message' do
- render
+ it 'shows a warning message about Kubernetes cluster' do
+ render
- expect(rendered).to have_css('.auto-devops-warning-message')
- expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name and a')
- expect(rendered).to have_link('Kubernetes cluster')
- end
- end
-
- context 'when auto devops domain is defined' do
- before do
- project.build_auto_devops(domain: 'example.com')
- end
-
- it 'shows warning message' do
- render
-
- expect(rendered).to have_css('.auto-devops-warning-message')
- expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a')
- expect(rendered).to have_link('Kubernetes cluster')
- end
- end
- end
-
- context 'when kubernetes is active' do
- before do
- create(:kubernetes_service, project: project)
- end
-
- context 'when auto devops domain is not defined' do
- it 'shows warning message' do
- render
-
- expect(rendered).to have_css('.auto-devops-warning-message')
- expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name to work correctly.')
- end
- end
-
- context 'when auto devops domain is defined' do
- before do
- project.build_auto_devops(domain: 'example.com')
- end
-
- it 'does not show warning message' do
- render
-
- expect(rendered).not_to have_css('.auto-devops-warning-message')
- end
- end
+ expect(rendered).to have_text('You must add a Kubernetes cluster integration to this project with a domain in order for your deployment strategy to work correctly.')
end
end
diff --git a/spec/workers/mail_scheduler/notification_service_worker_spec.rb b/spec/workers/mail_scheduler/notification_service_worker_spec.rb
index 1033557ee88..5cfba01850c 100644
--- a/spec/workers/mail_scheduler/notification_service_worker_spec.rb
+++ b/spec/workers/mail_scheduler/notification_service_worker_spec.rb
@@ -9,6 +9,10 @@ describe MailScheduler::NotificationServiceWorker do
ActiveJob::Arguments.serialize(args)
end
+ def deserialize(args)
+ ActiveJob::Arguments.deserialize(args)
+ end
+
describe '#perform' do
it 'deserializes arguments from global IDs' do
expect(worker.notification_service).to receive(method).with(key)
@@ -42,13 +46,48 @@ describe MailScheduler::NotificationServiceWorker do
end
end
- describe '.perform_async' do
+ describe '.perform_async', :sidekiq do
+ around do |example|
+ Sidekiq::Testing.fake! { example.run }
+ end
+
it 'serializes arguments as global IDs when scheduling' do
- Sidekiq::Testing.fake! do
- described_class.perform_async(method, key)
+ described_class.perform_async(method, key)
+
+ expect(described_class.jobs.count).to eq(1)
+ expect(described_class.jobs.first).to include('args' => [method, *serialize(key)])
+ end
+
+ context 'with ActiveController::Parameters' do
+ let(:parameters) { ActionController::Parameters.new(hash) }
+
+ let(:hash) do
+ {
+ "nested" => {
+ "hash" => true
+ }
+ }
+ end
+
+ context 'when permitted' do
+ before do
+ parameters.permit!
+ end
+
+ it 'serializes as a serializable Hash' do
+ described_class.perform_async(method, parameters)
- expect(described_class.jobs.count).to eq(1)
- expect(described_class.jobs.first).to include('args' => [method, *serialize(key)])
+ expect(described_class.jobs.count).to eq(1)
+ expect(deserialize(described_class.jobs.first['args']))
+ .to eq([method, hash])
+ end
+ end
+
+ context 'when not permitted' do
+ it 'fails to serialize' do
+ expect { described_class.perform_async(method, parameters) }
+ .to raise_error(ActionController::UnfilteredParameters)
+ end
end
end
end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index 9176eb12b12..caae46a3175 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -141,11 +141,18 @@ describe PostReceive do
let(:gl_repository) { "wiki-#{project.id}" }
it 'updates project activity' do
- described_class.new.perform(gl_repository, key_id, base64_changes)
+ # Force Project#set_timestamps_for_create to initialize timestamps
+ project
- expect { project.reload }
- .to change(project, :last_activity_at)
- .and change(project, :last_repository_updated_at)
+ # MySQL drops milliseconds in the timestamps, so advance at least
+ # a second to ensure we see changes.
+ Timecop.freeze(1.second.from_now) do
+ expect do
+ described_class.new.perform(gl_repository, key_id, base64_changes)
+ project.reload
+ end.to change(project, :last_activity_at)
+ .and change(project, :last_repository_updated_at)
+ end
end
end
diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb
index 781f91ac9ca..31bfe88d0bd 100644
--- a/spec/workers/repository_fork_worker_spec.rb
+++ b/spec/workers/repository_fork_worker_spec.rb
@@ -24,12 +24,7 @@ describe RepositoryForkWorker do
end
def expect_fork_repository
- expect(shell).to receive(:fork_repository).with(
- 'default',
- project.disk_path,
- forked_project.repository_storage,
- forked_project.disk_path
- )
+ expect(shell).to receive(:fork_repository).with(project, forked_project)
end
describe 'when a worker was reset without cleanup' do
diff --git a/vendor/project_templates/gitbook.tar.gz b/vendor/project_templates/gitbook.tar.gz
new file mode 100644
index 00000000000..73062fca038
--- /dev/null
+++ b/vendor/project_templates/gitbook.tar.gz
Binary files differ
diff --git a/vendor/project_templates/hexo.tar.gz b/vendor/project_templates/hexo.tar.gz
new file mode 100644
index 00000000000..b32c4945366
--- /dev/null
+++ b/vendor/project_templates/hexo.tar.gz
Binary files differ
diff --git a/vendor/project_templates/hugo.tar.gz b/vendor/project_templates/hugo.tar.gz
new file mode 100644
index 00000000000..4bdb03f5b2f
--- /dev/null
+++ b/vendor/project_templates/hugo.tar.gz
Binary files differ
diff --git a/vendor/project_templates/jekyll.tar.gz b/vendor/project_templates/jekyll.tar.gz
new file mode 100644
index 00000000000..ab61ddd03ea
--- /dev/null
+++ b/vendor/project_templates/jekyll.tar.gz
Binary files differ
diff --git a/vendor/project_templates/plainhtml.tar.gz b/vendor/project_templates/plainhtml.tar.gz
new file mode 100644
index 00000000000..6927ae74de8
--- /dev/null
+++ b/vendor/project_templates/plainhtml.tar.gz
Binary files differ
diff --git a/yarn.lock b/yarn.lock
index 5c9139fdbfa..9d0ba6640f0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -653,15 +653,15 @@
eslint-plugin-promise "^4.0.1"
eslint-plugin-vue "^5.0.0"
-"@gitlab/svgs@^1.48.0":
- version "1.48.0"
- resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.48.0.tgz#7b2e20e357d85aa46e905e6ca51b0b4184ae2794"
- integrity sha512-9lRsfqN0W3JxopiXnTzvDY31O465jMTGNKpiOCXy7uAMfwZA6UsRsc7Pp369uKnOLR0duXUGOxOv4NGsK6AeXw==
+"@gitlab/svgs@^1.51.0":
+ version "1.51.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.51.0.tgz#1b608f68dfb74284401b1cbdb823440f6e8b0091"
+ integrity sha512-B1Wdhfy5ZClkHuaaCUUZyOBF8CFxxHqxGGhveRekOowtlMExa3tx+YkqNa5XPsEVMF6Aqnh8evQmmN4b+zrHVQ==
-"@gitlab/ui@^1.22.1":
- version "1.22.1"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.22.1.tgz#92ed77216c5702776049b9ac41eb717c1acd864e"
- integrity sha512-pWbEaXOOcp8Xt2TjJtPas3lXwWVvizrBOf0M8yN0XAn2GgIRCVnRMpjNEN7/oNeBcEM9CrmPYApEM/hZO+maqQ==
+"@gitlab/ui@^2.0.2":
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-2.0.2.tgz#611571c931181fb783f57f712e1c2388059b301b"
+ integrity sha512-rUWVhWmM9EkwIEruYJEjizrQKe7TzNyKArwWY/nfEL4HptDtwbe+xHfR8IJHbpql3oI87cTO3BheMxYF6b2Ebg==
dependencies:
babel-standalone "^6.26.0"
bootstrap-vue "^2.0.0-rc.11"
@@ -3032,10 +3032,10 @@ decamelize@^2.0.0:
dependencies:
xregexp "4.0.0"
-deckar01-task_list@^2.0.1:
- version "2.0.1"
- resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.1.tgz#fdcfb6ab5717055a82f29e863a49990a043a06a9"
- integrity sha512-i5fT8QxJ9iV6dfgy5U0NHW91O5cKsvDc4u8JNMnZ6efQc356bA9vKuXO3732agSry+bO6TolzTmuqSRi4tkkeA==
+deckar01-task_list@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.2.0.tgz#5cc3ea06f01d3d786b1a667064a462eb5d069bd3"
+ integrity sha512-NUfu5ARoD9SC2k+fBT5cBer59iKfEdawPrmfqp5+zAahTECb8z9dsuS1Xnx7jzFAmCCLnEs3z/aYucYXzNrKkQ==
decode-uri-component@^0.2.0:
version "0.2.0"
@@ -3264,10 +3264,12 @@ doctrine@^2.1.0:
dependencies:
esutils "^2.0.2"
-document-register-element@1.3.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/document-register-element/-/document-register-element-1.3.0.tgz#fb3babb523c74662be47be19c6bc33e71990d940"
- integrity sha1-+zurtSPHRmK+R74Zxrwz5xmQ2UA=
+document-register-element@1.13.1:
+ version "1.13.1"
+ resolved "https://registry.yarnpkg.com/document-register-element/-/document-register-element-1.13.1.tgz#dad8cb7be38e04ee3f56842e6cf81af46c1249ba"
+ integrity sha512-92ZyLDKg9j4rOll//NNXj25f+8rAzOkYsGJonhugKwXfeqH7bzs8Ucpvey0WzZ2ZzKdrvW9RnUw3UyOZ/uhBFw==
+ dependencies:
+ lightercollective "^0.1.0"
dom-serialize@^2.2.0:
version "2.2.1"
@@ -3416,6 +3418,11 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"
+emoji-regex@^7.0.3:
+ version "7.0.3"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
+ integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
+
emoji-unicode-version@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/emoji-unicode-version/-/emoji-unicode-version-0.2.1.tgz#0ebf3666b5414097971d34994e299fce75cdbafc"