summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBob Van Landuyt <bob@vanlanduyt.co>2018-08-08 11:27:22 +0200
committerBob Van Landuyt <bob@vanlanduyt.co>2018-08-08 11:27:22 +0200
commit3d6d3152b87b39a98a1bc756fafce71d266bf985 (patch)
tree5993b97c7223bb584b18e826708f2219ea77bcbe
parent2d99f75f69065af34415032a7845df7001dcc94e (diff)
parent3bd4016b96fc4cb6a3c1dc5324ea0bfd12ee1ea1 (diff)
downloadgitlab-ce-3d6d3152b87b39a98a1bc756fafce71d266bf985.tar.gz
Merge branch 'master' into 11-2-stable-prepare-rc4
-rw-r--r--CONTRIBUTING.md9
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--Gemfile.rails5.lock4
-rw-r--r--app/assets/javascripts/api.js12
-rw-r--r--app/assets/javascripts/autosave.js4
-rw-r--r--app/assets/javascripts/awards_handler.js55
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js10
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js15
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js6
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue45
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue6
-rw-r--r--app/assets/javascripts/clusters/constants.js15
-rw-r--r--app/assets/javascripts/commons/polyfills.js1
-rw-r--r--app/assets/javascripts/commons/polyfills/request_idle_callback.js17
-rw-r--r--app/assets/javascripts/create_item_dropdown.js3
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue29
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue31
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue7
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue11
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue8
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue22
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue51
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue14
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue39
-rw-r--r--app/assets/javascripts/diffs/store/getters.js41
-rw-r--r--app/assets/javascripts/diffs/store/utils.js21
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue3
-rw-r--r--app/assets/javascripts/gl_dropdown.js594
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue75
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue21
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue60
-rw-r--r--app/assets/javascripts/ide/components/branches/search_list.vue111
-rw-r--r--app/assets/javascripts/ide/components/ide_project_header.vue37
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue163
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue3
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue21
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/dropdown.vue63
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue18
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue172
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown.vue59
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown_button.vue54
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue40
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue33
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue171
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue147
-rw-r--r--app/assets/javascripts/ide/components/shared/tokened_input.vue121
-rw-r--r--app/assets/javascripts/ide/constants.js3
-rw-r--r--app/assets/javascripts/ide/index.js8
-rw-r--r--app/assets/javascripts/ide/stores/getters.js4
-rw-r--r--app/assets/javascripts/ide/stores/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/branches/actions.js39
-rw-r--r--app/assets/javascripts/ide/stores/modules/branches/index.js10
-rw-r--r--app/assets/javascripts/ide/stores/modules/branches/mutation_types.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/branches/mutations.js21
-rw-r--r--app/assets/javascripts/ide/stores/modules/branches/state.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/actions.js41
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/getters.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js18
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/state.js10
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js9
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js2
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/ide/utils.js7
-rw-r--r--app/assets/javascripts/importer_status.js17
-rw-r--r--app/assets/javascripts/labels_select.js16
-rw-r--r--app/assets/javascripts/lazy_loader.js36
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue28
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue73
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js17
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js29
-rw-r--r--app/assets/javascripts/notes/stores/getters.js85
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js12
-rw-r--r--app/assets/javascripts/pages/profiles/show/emoji_menu.js18
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js49
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js15
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue2
-rw-r--r--app/assets/javascripts/reports/store/mutations.js2
-rw-r--r--app/assets/javascripts/search_autocomplete.js263
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue98
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar/default.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue5
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss10
-rw-r--r--app/assets/stylesheets/framework/avatar.scss1
-rw-r--r--app/assets/stylesheets/framework/common.scss3
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss5
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss3
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss32
-rw-r--r--app/assets/stylesheets/framework/images.scss2
-rw-r--r--app/assets/stylesheets/framework/variables.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss167
-rw-r--r--app/assets/stylesheets/pages/groups.scss52
-rw-r--r--app/assets/stylesheets/pages/issuable.scss1
-rw-r--r--app/assets/stylesheets/pages/labels.scss78
-rw-r--r--app/assets/stylesheets/pages/notes.scss1
-rw-r--r--app/assets/stylesheets/pages/profile.scss20
-rw-r--r--app/assets/stylesheets/pages/search.scss72
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss5
-rw-r--r--app/assets/stylesheets/pages/todos.scss16
-rw-r--r--app/controllers/application_controller.rb13
-rw-r--r--app/controllers/concerns/todos_actions.rb12
-rw-r--r--app/controllers/dashboard/todos_controller.rb2
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb131
-rw-r--r--app/controllers/projects/merge_requests_controller.rb4
-rw-r--r--app/controllers/projects/runners_controller.rb6
-rw-r--r--app/controllers/projects/todos_controller.rb14
-rw-r--r--app/controllers/projects/triggers_controller.rb10
-rw-r--r--app/finders/todos_finder.rb41
-rw-r--r--app/helpers/application_settings_helper.rb3
-rw-r--r--app/helpers/issuables_helper.rb13
-rw-r--r--app/helpers/namespaces_helper.rb38
-rw-r--r--app/helpers/profiles_helper.rb4
-rw-r--r--app/helpers/search_helper.rb20
-rw-r--r--app/helpers/todos_helper.rb10
-rw-r--r--app/models/ci/pipeline.rb4
-rw-r--r--app/models/clusters/applications/helm.rb49
-rw-r--r--app/models/clusters/applications/ingress.rb4
-rw-r--r--app/models/clusters/applications/jupyter.rb4
-rw-r--r--app/models/clusters/applications/prometheus.rb4
-rw-r--r--app/models/clusters/applications/runner.rb4
-rw-r--r--app/models/clusters/concerns/application_data.rb26
-rw-r--r--app/models/concerns/avatarable.rb7
-rw-r--r--app/models/concerns/issuable.rb8
-rw-r--r--app/models/concerns/reactive_caching.rb28
-rw-r--r--app/models/concerns/sortable.rb1
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/list.rb6
-rw-r--r--app/models/merge_request.rb47
-rw-r--r--app/models/milestone.rb35
-rw-r--r--app/models/note.rb4
-rw-r--r--app/models/project.rb6
-rw-r--r--app/models/todo.rb11
-rw-r--r--app/services/ci/compare_test_reports_service.rb43
-rw-r--r--app/services/groups/update_service.rb11
-rw-r--r--app/services/todo_service.rb30
-rw-r--r--app/services/todos/destroy/entity_leave_service.rb90
-rw-r--r--app/services/todos/destroy/group_private_service.rb30
-rw-r--r--app/services/todos/destroy/project_private_service.rb2
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml16
-rw-r--r--app/views/admin/application_settings/show.html.haml23
-rw-r--r--app/views/admin/labels/_label.html.haml14
-rw-r--r--app/views/admin/labels/index.html.haml5
-rw-r--r--app/views/admin/projects/_projects.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml48
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml2
-rw-r--r--app/views/ide/index.html.haml5
-rw-r--r--app/views/import/bitbucket_server/new.html.haml26
-rw-r--r--app/views/import/bitbucket_server/status.html.haml87
-rw-r--r--app/views/layouts/_search.html.haml8
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/profiles/show.html.haml42
-rw-r--r--app/views/projects/_home_panel.html.haml2
-rw-r--r--app/views/projects/_import_project_pane.html.haml8
-rw-r--r--app/views/projects/_new_project_fields.html.haml8
-rw-r--r--app/views/projects/blob/show.html.haml3
-rw-r--r--app/views/projects/edit.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml39
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/show.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--app/views/shared/_label.html.haml6
-rw-r--r--app/views/shared/_label_row.html.haml13
-rw-r--r--app/views/shared/boards/components/_board.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml3
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--app/workers/all_queues.yml1
-rw-r--r--app/workers/todos_destroyer/group_private_worker.rb10
-rw-r--r--changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml5
-rw-r--r--changelogs/unreleased/44127-board-label-edit-drop-down-is-showing-incorrect-selected-labels-summary.yml5
-rw-r--r--changelogs/unreleased/46165-web-ide-branch-picker.yml5
-rw-r--r--changelogs/unreleased/46535-orphaned-uploads.yml5
-rw-r--r--changelogs/unreleased/46703-group-dashboard-line-height-is-too-tall-for-group-names.yml5
-rw-r--r--changelogs/unreleased/47156-improve-auto-devops-settings.yml5
-rw-r--r--changelogs/unreleased/47768-web-ide-redesign-header.yml5
-rw-r--r--changelogs/unreleased/48098-mutual-auth-cluster-applications.yml6
-rw-r--r--changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml5
-rw-r--r--changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml5
-rw-r--r--changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml5
-rw-r--r--changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml5
-rw-r--r--changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml5
-rw-r--r--changelogs/unreleased/49966-improve-junit-fe.yml5
-rw-r--r--changelogs/unreleased/add-homepage-link-to-status-pages.yml5
-rw-r--r--changelogs/unreleased/ce-5666-backport.yml5
-rw-r--r--changelogs/unreleased/fix-prometheus-updated-status.yml5
-rw-r--r--changelogs/unreleased/git-rerere-link-doc-update.yml5
-rw-r--r--changelogs/unreleased/ide-codesandbox-poc.yml5
-rw-r--r--changelogs/unreleased/improve-junit-support-be.yml5
-rw-r--r--changelogs/unreleased/kp-6927-epic-dates-from-milestone.yml5
-rw-r--r--changelogs/unreleased/osw-fix-missing-and-duplicated-milestones-on-list.yml5
-rw-r--r--changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml5
-rw-r--r--changelogs/unreleased/pl-json-gon.yml5
-rw-r--r--changelogs/unreleased/sh-bump-gitaly-0-117.yml5
-rw-r--r--changelogs/unreleased/todos-visibility-migration.yml5
-rw-r--r--changelogs/unreleased/tz-mr-port-memory-fixes.yml5
-rw-r--r--changelogs/unreleased/winh-fix-gpg-regressions.yml5
-rw-r--r--changelogs/unreleased/winh-restyle-user-status.yml5
-rw-r--r--config/dependency_decisions.yml24
-rw-r--r--config/initializers/active_record_verbose_query_logs.rb54
-rw-r--r--config/routes/import.rb7
-rw-r--r--db/migrate/20180608091413_add_group_to_todos.rb36
-rw-r--r--db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb12
-rw-r--r--db/migrate/20180717125853_remove_restricted_todos.rb31
-rw-r--r--db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb20
-rw-r--r--db/schema.rb9
-rw-r--r--doc/administration/gitaly/index.md50
-rw-r--r--doc/administration/operations/fast_ssh_key_lookup.md8
-rw-r--r--doc/api/todos.md1
-rw-r--r--doc/development/automatic_ce_ee_merge.md2
-rw-r--r--doc/raketasks/cleanup.md31
-rw-r--r--doc/user/profile/index.md22
-rw-r--r--doc/user/project/img/labels_project_list_search.pngbin0 -> 175669 bytes
-rw-r--r--doc/user/project/issue_board.md2
-rw-r--r--doc/user/project/labels.md10
-rw-r--r--doc/user/project/web_ide/index.md13
-rw-r--r--doc/user/search/img/issues_mrs_shortcut.pngbin34115 -> 61888 bytes
-rw-r--r--doc/user/search/img/project_search.pngbin41900 -> 89002 bytes
-rw-r--r--doc/workflow/todos.md1
-rw-r--r--lib/api/boards.rb6
-rw-r--r--lib/api/boards_responses.rb16
-rw-r--r--lib/api/branches.rb1
-rw-r--r--lib/api/entities.rb13
-rw-r--r--lib/api/group_boards.rb6
-rw-r--r--lib/bitbucket_server/client.rb71
-rw-r--r--lib/bitbucket_server/collection.rb23
-rw-r--r--lib/bitbucket_server/connection.rb122
-rw-r--r--lib/bitbucket_server/page.rb36
-rw-r--r--lib/bitbucket_server/paginator.rb38
-rw-r--r--lib/bitbucket_server/representation/activity.rb71
-rw-r--r--lib/bitbucket_server/representation/base.rb21
-rw-r--r--lib/bitbucket_server/representation/comment.rb130
-rw-r--r--lib/bitbucket_server/representation/pull_request.rb76
-rw-r--r--lib/bitbucket_server/representation/pull_request_comment.rb122
-rw-r--r--lib/bitbucket_server/representation/repo.rb69
-rw-r--r--lib/gitlab/background_migration/remove_restricted_todos.rb105
-rw-r--r--lib/gitlab/bitbucket_server_import/importer.rb327
-rw-r--r--lib/gitlab/bitbucket_server_import/project_creator.rb36
-rw-r--r--lib/gitlab/checks/lfs_integrity.rb5
-rw-r--r--lib/gitlab/cleanup/remote_uploads.rb80
-rw-r--r--lib/gitlab/git/repository.rb11
-rw-r--r--lib/gitlab/gitaly_client/ref_service.rb17
-rw-r--r--lib/gitlab/import_sources.rb19
-rw-r--r--lib/gitlab/kubernetes/config_map.rb8
-rw-r--r--lib/gitlab/kubernetes/helm/api.rb2
-rw-r--r--lib/gitlab/kubernetes/helm/base_command.rb32
-rw-r--r--lib/gitlab/kubernetes/helm/certificate.rb73
-rw-r--r--lib/gitlab/kubernetes/helm/init_command.rb18
-rw-r--r--lib/gitlab/kubernetes/helm/install_command.rb35
-rw-r--r--lib/gitlab/kubernetes/helm/pod.rb8
-rw-r--r--lib/tasks/gitlab/cleanup.rake10
-rw-r--r--locale/gitlab.pot137
-rw-r--r--package.json2
-rw-r--r--public/404.html4
-rw-r--r--public/422.html4
-rw-r--r--public/500.html4
-rw-r--r--public/502.html4
-rw-r--r--public/503.html4
-rw-r--r--qa/qa/factory/resource/kubernetes_cluster.rb5
-rw-r--r--qa/qa/page/project/new.rb2
-rw-r--r--qa/qa/page/project/operations/kubernetes/show.rb1
-rw-r--r--qa/qa/page/project/settings/ci_cd.rb6
-rw-r--r--spec/controllers/application_controller_spec.rb51
-rw-r--r--spec/controllers/import/bitbucket_server_controller_spec.rb154
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb24
-rw-r--r--spec/controllers/projects/milestones_controller_spec.rb33
-rw-r--r--spec/controllers/projects/todos_controller_spec.rb133
-rw-r--r--spec/factories/clusters/applications/helm.rb16
-rw-r--r--spec/factories/clusters/clusters.rb4
-rw-r--r--spec/factories/todos.rb4
-rw-r--r--spec/features/admin/admin_labels_spec.rb2
-rw-r--r--spec/features/admin/admin_settings_spec.rb2
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb5
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb226
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb75
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb29
-rw-r--r--spec/features/projects/clusters/applications_spec.rb16
-rw-r--r--spec/features/projects/settings/pipelines_settings_spec.rb60
-rw-r--r--spec/features/projects/tree/create_directory_spec.rb4
-rw-r--r--spec/features/projects/tree/create_file_spec.rb2
-rw-r--r--spec/features/projects/tree/tree_show_spec.rb64
-rw-r--r--spec/features/projects_spec.rb43
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb4
-rw-r--r--spec/features/signed_commits_spec.rb19
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_profile_spec.rb2
-rw-r--r--spec/finders/todos_finder_spec.rb38
-rw-r--r--spec/fixtures/importers/bitbucket_server/activities.json1121
-rw-r--r--spec/fixtures/importers/bitbucket_server/pull_request.json146
-rw-r--r--spec/helpers/issuables_helper_spec.rb21
-rw-r--r--spec/helpers/namespaces_helper_spec.rb38
-rw-r--r--spec/javascripts/autosave_spec.js10
-rw-r--r--spec/javascripts/boards/issue_card_spec.js154
-rw-r--r--spec/javascripts/clusters/clusters_bundle_spec.js18
-rw-r--r--spec/javascripts/clusters/components/application_row_spec.js49
-rw-r--r--spec/javascripts/clusters/services/mock_data.js27
-rw-r--r--spec/javascripts/clusters/stores/clusters_store_spec.js4
-rw-r--r--spec/javascripts/diffs/components/diff_line_gutter_content_spec.js6
-rw-r--r--spec/javascripts/diffs/components/diff_line_note_form_spec.js41
-rw-r--r--spec/javascripts/diffs/store/getters_spec.js98
-rw-r--r--spec/javascripts/fixtures/search_autocomplete.html.haml6
-rw-r--r--spec/javascripts/helpers/vuex_action_helper.js2
-rw-r--r--spec/javascripts/ide/components/activity_bar_spec.js20
-rw-r--r--spec/javascripts/ide/components/branches/item_spec.js53
-rw-r--r--spec/javascripts/ide/components/branches/search_list_spec.js79
-rw-r--r--spec/javascripts/ide/components/merge_requests/dropdown_spec.js47
-rw-r--r--spec/javascripts/ide/components/merge_requests/item_spec.js15
-rw-r--r--spec/javascripts/ide/components/merge_requests/list_spec.js112
-rw-r--r--spec/javascripts/ide/components/nav_dropdown_button_spec.js63
-rw-r--r--spec/javascripts/ide/components/nav_dropdown_spec.js50
-rw-r--r--spec/javascripts/ide/components/panes/right_spec.js13
-rw-r--r--spec/javascripts/ide/components/preview/clientside_spec.js362
-rw-r--r--spec/javascripts/ide/components/preview/navigator_spec.js185
-rw-r--r--spec/javascripts/ide/components/shared/tokened_input_spec.js132
-rw-r--r--spec/javascripts/ide/helpers.js2
-rw-r--r--spec/javascripts/ide/mock_data.js30
-rw-r--r--spec/javascripts/ide/stores/getters_spec.js10
-rw-r--r--spec/javascripts/ide/stores/modules/branches/actions_spec.js193
-rw-r--r--spec/javascripts/ide/stores/modules/branches/mutations_spec.js51
-rw-r--r--spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js77
-rw-r--r--spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js19
-rw-r--r--spec/javascripts/notes/components/discussion_counter_spec.js2
-rw-r--r--spec/javascripts/notes/components/noteable_discussion_spec.js56
-rw-r--r--spec/javascripts/notes/mock_data.js84
-rw-r--r--spec/javascripts/notes/stores/getters_spec.js155
-rw-r--r--spec/javascripts/pages/profiles/show/emoji_menu_spec.js117
-rw-r--r--spec/javascripts/pipelines/pipeline_url_spec.js26
-rw-r--r--spec/javascripts/reports/components/grouped_test_reports_app_spec.js36
-rw-r--r--spec/javascripts/reports/mock_data/resolved_failures.json37
-rw-r--r--spec/javascripts/reports/store/mutations_spec.js4
-rw-r--r--spec/javascripts/sidebar/todo_spec.js158
-rw-r--r--spec/javascripts/test_bundle.js2
-rw-r--r--spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js15
-rw-r--r--spec/javascripts/vue_shared/components/icon_spec.js5
-rw-r--r--spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/project_avatar/default_spec.js58
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js30
-rw-r--r--spec/lib/bitbucket_server/client_spec.rb88
-rw-r--r--spec/lib/bitbucket_server/connection_spec.rb68
-rw-r--r--spec/lib/bitbucket_server/page_spec.rb51
-rw-r--r--spec/lib/bitbucket_server/paginator_spec.rb35
-rw-r--r--spec/lib/bitbucket_server/representation/activity_spec.rb38
-rw-r--r--spec/lib/bitbucket_server/representation/comment_spec.rb55
-rw-r--r--spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb48
-rw-r--r--spec/lib/bitbucket_server/representation/pull_request_spec.rb79
-rw-r--r--spec/lib/bitbucket_server/representation/repo_spec.rb80
-rw-r--r--spec/lib/gitlab/background_migration/remove_restricted_todos_spec.rb124
-rw-r--r--spec/lib/gitlab/bitbucket_server_import/importer_spec.rb291
-rw-r--r--spec/lib/gitlab/cleanup/remote_uploads_spec.rb74
-rw-r--r--spec/lib/gitlab/import_sources_spec.rb27
-rw-r--r--spec/lib/gitlab/kubernetes/config_map_spec.rb4
-rw-r--r--spec/lib/gitlab/kubernetes/helm/api_spec.rb2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/base_command_spec.rb28
-rw-r--r--spec/lib/gitlab/kubernetes/helm/certificate_spec.rb28
-rw-r--r--spec/lib/gitlab/kubernetes/helm/init_command_spec.rb4
-rw-r--r--spec/lib/gitlab/kubernetes/helm/install_command_spec.rb69
-rw-r--r--spec/lib/gitlab/kubernetes/helm/pod_spec.rb29
-rw-r--r--spec/models/ci/pipeline_spec.rb43
-rw-r--r--spec/models/clusters/applications/helm_spec.rb26
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb42
-rw-r--r--spec/models/clusters/applications/jupyter_spec.rb46
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb42
-rw-r--r--spec/models/clusters/applications/runner_spec.rb64
-rw-r--r--spec/models/concerns/avatarable_spec.rb4
-rw-r--r--spec/models/concerns/reactive_caching_spec.rb8
-rw-r--r--spec/models/merge_request_spec.rb80
-rw-r--r--spec/models/project_spec.rb46
-rw-r--r--spec/models/todo_spec.rb1
-rw-r--r--spec/requests/api/todos_spec.rb14
-rw-r--r--spec/services/ci/compare_test_reports_service_spec.rb32
-rw-r--r--spec/services/clusters/applications/install_service_spec.rb2
-rw-r--r--spec/services/groups/update_service_spec.rb28
-rw-r--r--spec/services/projects/create_service_spec.rb11
-rw-r--r--spec/services/todos/destroy/confidential_issue_service_spec.rb6
-rw-r--r--spec/services/todos/destroy/entity_leave_service_spec.rb223
-rw-r--r--spec/services/todos/destroy/group_private_service_spec.rb69
-rw-r--r--spec/services/todos/destroy/project_private_service_spec.rb17
-rw-r--r--spec/support/helpers/test_env.rb5
-rw-r--r--spec/support/shared_examples/controllers/todos_shared_examples.rb43
-rw-r--r--spec/workers/todos_destroyer/group_private_worker_spec.rb12
-rw-r--r--vendor/Dockerfile/Node-alpine.Dockerfile9
-rw-r--r--vendor/Dockerfile/Node.Dockerfile9
-rw-r--r--vendor/Dockerfile/Ruby-alpine.Dockerfile11
-rw-r--r--vendor/Dockerfile/Ruby.Dockerfile4
-rw-r--r--vendor/gitignore/Autotools.gitignore2
-rw-r--r--vendor/gitignore/Laravel.gitignore7
-rw-r--r--vendor/gitignore/VisualStudio.gitignore5
-rw-r--r--vendor/gitlab-ci-yml/Maven.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml2
-rw-r--r--vendor/licenses.csv289
-rw-r--r--yarn.lock60
400 files changed, 12413 insertions, 2527 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ad8022e972f..0bf8cba76f3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -197,7 +197,7 @@ The current team labels are:
- ~Plan
- ~Quality
- ~Release
-- ~"Security Products"
+- ~Secure
- ~UX
The descriptions on the [labels page][labels-page] explain what falls under the
@@ -377,13 +377,14 @@ on those issues. Please select someone with relevant experience from the
the commit history for the affected files to find someone.
We also use [GitLab Triage] to automate some triaging policies. This is
-currently setup as a [scheduled pipeline] running on the [`gl-triage`] branch.
+currently setup as a [scheduled pipeline] running on [quality/triage-ops]
+project.
[described in our handbook]: https://about.gitlab.com/handbook/engineering/issue-triage/
[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815
[GitLab Triage]: https://gitlab.com/gitlab-org/gitlab-triage
-[scheduled pipeline]: https://gitlab.com/gitlab-org/gitlab-ce/pipeline_schedules/3732/edit
-[`gl-triage`]: https://gitlab.com/gitlab-org/gitlab-ce/tree/gl-triage
+[scheduled pipeline]: https://gitlab.com/gitlab-org/quality/triage-ops/pipeline_schedules/10512/edit
+[quality/triage-ops]: https://gitlab.com/gitlab-org/quality/triage-ops
### Feature proposals
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index bdc80994dd9..a38b3bd31b1 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-0.115.0
+0.117.0
diff --git a/Gemfile b/Gemfile
index 687039b9afb..d9066081f74 100644
--- a/Gemfile
+++ b/Gemfile
@@ -423,7 +423,7 @@ group :ed25519 do
end
# Gitaly GRPC client
-gem 'gitaly-proto', '~> 0.109.0', require: 'gitaly'
+gem 'gitaly-proto', '~> 0.112.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed
diff --git a/Gemfile.lock b/Gemfile.lock
index d8f878875f3..1537cacaadd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -284,7 +284,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
- gitaly-proto (0.109.0)
+ gitaly-proto (0.112.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
@@ -1048,7 +1048,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly-proto (~> 0.109.0)
+ gitaly-proto (~> 0.112.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock
index a763dcebe2d..39305927c0f 100644
--- a/Gemfile.rails5.lock
+++ b/Gemfile.rails5.lock
@@ -287,7 +287,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
- gitaly-proto (0.109.0)
+ gitaly-proto (0.112.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
@@ -1058,7 +1058,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly-proto (~> 0.109.0)
+ gitaly-proto (~> 0.112.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 422becb7db8..25fe2ae553e 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -244,6 +244,18 @@ const Api = {
});
},
+ branches(id, query = '', options = {}) {
+ const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url, {
+ params: {
+ search: query,
+ per_page: 20,
+ ...options,
+ },
+ });
+ },
+
createBranch(id, { ref, branch }) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index fa00a3cf386..e8c59fab609 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -53,4 +53,8 @@ export default class Autosave {
return window.localStorage.removeItem(this.key);
}
+
+ dispose() {
+ this.field.off('input');
+ }
}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 70f20c5c7cf..e34db893989 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -33,19 +33,24 @@ const categoryLabelMap = {
const IS_VISIBLE = 'is-visible';
const IS_RENDERED = 'is-rendered';
-class AwardsHandler {
+export class AwardsHandler {
constructor(emoji) {
this.emoji = emoji;
this.eventListeners = [];
+ this.toggleButtonSelector = '.js-add-award';
+ this.menuClass = 'js-award-emoji-menu';
+ }
+
+ bindEvents() {
// If the user shows intent let's pre-build the menu
this.registerEventListener(
'one',
$(document),
'mouseenter focus',
- '.js-add-award',
+ this.toggleButtonSelector,
'mouseenter focus',
() => {
- const $menu = $('.emoji-menu');
+ const $menu = $(`.${this.menuClass}`);
if ($menu.length === 0) {
requestAnimationFrame(() => {
this.createEmojiMenu();
@@ -53,7 +58,7 @@ class AwardsHandler {
}
},
);
- this.registerEventListener('on', $(document), 'click', '.js-add-award', e => {
+ this.registerEventListener('on', $(document), 'click', this.toggleButtonSelector, e => {
e.stopPropagation();
e.preventDefault();
this.showEmojiMenu($(e.currentTarget));
@@ -61,15 +66,17 @@ class AwardsHandler {
this.registerEventListener('on', $('html'), 'click', e => {
const $target = $(e.target);
- if (!$target.closest('.emoji-menu').length) {
+ if (!$target.closest(`.${this.menuClass}`).length) {
$('.js-awards-block.current').removeClass('current');
- if ($('.emoji-menu').is(':visible')) {
- $('.js-add-award.is-active').removeClass('is-active');
- this.hideMenuElement($('.emoji-menu'));
+ if ($(`.${this.menuClass}`).is(':visible')) {
+ $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active');
+ this.hideMenuElement($(`.${this.menuClass}`));
}
}
});
- this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', e => {
+
+ const emojiButtonSelector = `.js-awards-block .js-emoji-btn, .${this.menuClass} .js-emoji-btn`;
+ this.registerEventListener('on', $(document), 'click', emojiButtonSelector, e => {
e.preventDefault();
const $target = $(e.currentTarget);
const $glEmojiElement = $target.find('gl-emoji');
@@ -101,7 +108,7 @@ class AwardsHandler {
$addBtn.closest('.js-awards-block').addClass('current');
}
- const $menu = $('.emoji-menu');
+ const $menu = $(`.${this.menuClass}`);
const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
const $userAuthored = this.isUserAuthored($addBtn);
if ($menu.length) {
@@ -118,7 +125,7 @@ class AwardsHandler {
} else {
$addBtn.addClass('is-loading is-active');
this.createEmojiMenu(() => {
- const $createdMenu = $('.emoji-menu');
+ const $createdMenu = $(`.${this.menuClass}`);
$addBtn.removeClass('is-loading');
this.positionMenu($createdMenu, $addBtn);
return setTimeout(() => {
@@ -156,7 +163,7 @@ class AwardsHandler {
}
const emojiMenuMarkup = `
- <div class="emoji-menu">
+ <div class="emoji-menu ${this.menuClass}">
<input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
<div class="emoji-menu-content">
@@ -185,7 +192,7 @@ class AwardsHandler {
// Avoid the jank and render the remaining categories separately
// This will take more time, but makes UI more responsive
- const menu = document.querySelector('.emoji-menu');
+ const menu = document.querySelector(`.${this.menuClass}`);
const emojiContentElement = menu.querySelector('.emoji-menu-content');
const remainingCategories = Object.keys(categoryMap).slice(1);
const allCategoriesAddedPromise = remainingCategories.reduce(
@@ -270,9 +277,9 @@ class AwardsHandler {
if (isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
- this.hideMenuElement($('.emoji-menu'));
+ this.hideMenuElement($(`.${this.menuClass}`));
- $('.js-add-award.is-active').removeClass('is-active');
+ $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active');
const toggleAwardEvent = new CustomEvent('toggleAward', {
detail: {
awardName: emoji,
@@ -291,9 +298,9 @@ class AwardsHandler {
return typeof callback === 'function' ? callback() : undefined;
});
- this.hideMenuElement($('.emoji-menu'));
+ this.hideMenuElement($(`.${this.menuClass}`));
- return $('.js-add-award.is-active').removeClass('is-active');
+ return $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active');
}
addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
@@ -321,7 +328,7 @@ class AwardsHandler {
getVotesBlock() {
if (isInVueNoteablePage()) {
- const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
+ const $el = $(`${this.toggleButtonSelector}.is-active`).closest('.note.timeline-entry');
if ($el.length) {
return $el;
@@ -458,7 +465,7 @@ class AwardsHandler {
}
createEmoji(votesBlock, emoji) {
- if ($('.emoji-menu').length) {
+ if ($(`.${this.menuClass}`).length) {
this.createAwardButtonForVotesBlock(votesBlock, emoji);
}
this.createEmojiMenu(() => {
@@ -538,7 +545,7 @@ class AwardsHandler {
this.searchEmojis(term);
});
- const $menu = $('.emoji-menu');
+ const $menu = $(`.${this.menuClass}`);
this.registerEventListener('on', $menu, transitionEndEventString, e => {
if (e.target === e.currentTarget) {
// Clear the search
@@ -608,7 +615,7 @@ class AwardsHandler {
this.eventListeners.forEach(entry => {
entry.element.off.call(entry.element, ...entry.args);
});
- $('.emoji-menu').remove();
+ $(`.${this.menuClass}`).remove();
}
}
@@ -616,7 +623,11 @@ let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) {
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(
- Emoji => new AwardsHandler(Emoji),
+ Emoji => {
+ const awardsHandler = new AwardsHandler(Emoji);
+ awardsHandler.bindEvents();
+ return awardsHandler;
+ },
);
}
return awardsHandlerPromise;
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 5c7565234d8..3e610a4088c 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -112,12 +112,20 @@ export default {
if (e.target) {
const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
+ const cloneActions = {
+ label: ['milestone', 'assignee'],
+ assignee: ['milestone', 'label'],
+ milestone: ['label', 'assignee'],
+ };
if (toBoardType) {
const fromBoardType = this.list.type;
+ // For each list we check if the destination list is
+ // a the list were we should clone the issue
+ const shouldClone = Object.entries(cloneActions).some(entry => (
+ fromBoardType === entry[0] && entry[1].includes(toBoardType)));
- if ((fromBoardType === 'assignee' && toBoardType === 'label') ||
- (fromBoardType === 'label' && toBoardType === 'assignee')) {
+ if (shouldClone) {
return 'clone';
}
}
@@ -145,7 +153,8 @@ export default {
});
},
onUpdate: (e) => {
- const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
+ const sortedArray = this.sortable.toArray()
+ .filter(id => id !== '-1');
gl.issueBoards.BoardsStore
.moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
},
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 371be109229..a9102743bf9 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -51,6 +51,16 @@ gl.issueBoards.BoardSidebar = Vue.extend({
canRemove() {
return !this.list.preset;
},
+ hasLabels() {
+ return this.issue.labels && this.issue.labels.length;
+ },
+ labelDropdownTitle() {
+ return this.hasLabels ?
+ `${this.issue.labels[0].title} ${this.issue.labels.length - 1}+ more` : 'Label';
+ },
+ selectedLabels() {
+ return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : '';
+ }
},
watch: {
detail: {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 76467564608..957114cf420 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -108,6 +108,16 @@ gl.issueBoards.BoardsStore = {
issue.findAssignee(listTo.assignee)) {
const targetIssue = listTo.findIssue(issue.id);
targetIssue.removeAssignee(listFrom.assignee);
+ } else if (listTo.type === 'milestone') {
+ const currentMilestone = issue.milestone;
+ const currentLists = this.state.lists
+ .filter(list => (list.type === 'milestone' && list.id !== listTo.id))
+ .filter(list => list.issues.some(listIssue => issue.id === listIssue.id));
+
+ issue.removeMilestone(currentMilestone);
+ issue.addMilestone(listTo.milestone);
+ currentLists.forEach(currentList => currentList.removeIssue(issue));
+ listTo.addIssue(issue, listFrom, newIndex);
} else {
// Add to new lists issues if it doesn't already exist
listTo.addIssue(issue, listFrom, newIndex);
@@ -125,6 +135,9 @@ gl.issueBoards.BoardsStore = {
} else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
listFrom.removeIssue(issue);
+ } else if (listTo.type === 'backlog' && listFrom.type === 'milestone') {
+ issue.removeMilestone(listFrom.milestone);
+ listFrom.removeIssue(issue);
} else if (this.shouldRemoveIssue(listFrom, listTo)) {
listFrom.removeIssue(issue);
}
@@ -144,7 +157,7 @@ gl.issueBoards.BoardsStore = {
},
findList (key, val, type = 'label') {
const filteredList = this.state.lists.filter((list) => {
- const byType = type ? (list.type === type) || (list.type === 'assignee') : true;
+ const byType = type ? (list.type === type) || (list.type === 'assignee') || (list.type === 'milestone') : true;
return list[key] === val && byType;
});
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index e565af800d0..0fdf0c7a389 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -6,7 +6,7 @@ import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
import {
- APPLICATION_INSTALLED,
+ APPLICATION_STATUS,
REQUEST_LOADING,
REQUEST_SUCCESS,
REQUEST_FAILURE,
@@ -177,8 +177,8 @@ export default class Clusters {
checkForNewInstalls(prevApplicationMap, newApplicationMap) {
const appTitles = Object.keys(newApplicationMap)
- .filter(appId => newApplicationMap[appId].status === APPLICATION_INSTALLED &&
- prevApplicationMap[appId].status !== APPLICATION_INSTALLED &&
+ .filter(appId => newApplicationMap[appId].status === APPLICATION_STATUS.INSTALLED &&
+ prevApplicationMap[appId].status !== APPLICATION_STATUS.INSTALLED &&
prevApplicationMap[appId].status !== null)
.map(appId => newApplicationMap[appId].title);
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index ec52fdfdf32..651f3b50236 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -4,12 +4,7 @@
import eventHub from '../event_hub';
import loadingButton from '../../vue_shared/components/loading_button.vue';
import {
- APPLICATION_NOT_INSTALLABLE,
- APPLICATION_SCHEDULED,
- APPLICATION_INSTALLABLE,
- APPLICATION_INSTALLING,
- APPLICATION_INSTALLED,
- APPLICATION_ERROR,
+ APPLICATION_STATUS,
REQUEST_LOADING,
REQUEST_SUCCESS,
REQUEST_FAILURE,
@@ -59,49 +54,57 @@
},
},
computed: {
+ isUnknownStatus() {
+ return !this.isKnownStatus && this.status !== null;
+ },
+ isKnownStatus() {
+ return Object.values(APPLICATION_STATUS).includes(this.status);
+ },
rowJsClass() {
return `js-cluster-application-row-${this.id}`;
},
installButtonLoading() {
return !this.status ||
- this.status === APPLICATION_SCHEDULED ||
- this.status === APPLICATION_INSTALLING ||
+ this.status === APPLICATION_STATUS.SCHEDULED ||
+ this.status === APPLICATION_STATUS.INSTALLING ||
this.requestStatus === REQUEST_LOADING;
},
installButtonDisabled() {
- // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but
+ // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but
// we already made a request to install and are just waiting for the real-time
// to sync up.
- return (this.status !== APPLICATION_INSTALLABLE
- && this.status !== APPLICATION_ERROR) ||
+ return ((this.status !== APPLICATION_STATUS.INSTALLABLE
+ && this.status !== APPLICATION_STATUS.ERROR) ||
this.requestStatus === REQUEST_LOADING ||
- this.requestStatus === REQUEST_SUCCESS;
+ this.requestStatus === REQUEST_SUCCESS) && this.isKnownStatus;
},
installButtonLabel() {
let label;
if (
- this.status === APPLICATION_NOT_INSTALLABLE ||
- this.status === APPLICATION_INSTALLABLE ||
- this.status === APPLICATION_ERROR
+ this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
+ this.status === APPLICATION_STATUS.INSTALLABLE ||
+ this.status === APPLICATION_STATUS.ERROR ||
+ this.isUnknownStatus
) {
label = s__('ClusterIntegration|Install');
- } else if (this.status === APPLICATION_SCHEDULED ||
- this.status === APPLICATION_INSTALLING) {
+ } else if (this.status === APPLICATION_STATUS.SCHEDULED ||
+ this.status === APPLICATION_STATUS.INSTALLING) {
label = s__('ClusterIntegration|Installing');
- } else if (this.status === APPLICATION_INSTALLED) {
+ } else if (this.status === APPLICATION_STATUS.INSTALLED ||
+ this.status === APPLICATION_STATUS.UPDATED) {
label = s__('ClusterIntegration|Installed');
}
return label;
},
showManageButton() {
- return this.manageLink && this.status === APPLICATION_INSTALLED;
+ return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED;
},
manageButtonLabel() {
return s__('ClusterIntegration|Manage');
},
hasError() {
- return this.status === APPLICATION_ERROR ||
+ return this.status === APPLICATION_STATUS.ERROR ||
this.requestStatus === REQUEST_FAILURE;
},
generalErrorDescription() {
@@ -182,7 +185,7 @@
</div>
</div>
<div
- v-if="hasError"
+ v-if="hasError || isUnknownStatus"
class="gl-responsive-table-row-layout"
role="row"
>
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 8ee7279e544..d708a9e595a 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -3,7 +3,7 @@ import _ from 'underscore';
import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import { APPLICATION_INSTALLED, INGRESS } from '../constants';
+import { APPLICATION_STATUS, INGRESS } from '../constants';
export default {
components: {
@@ -58,7 +58,7 @@ export default {
return INGRESS;
},
ingressInstalled() {
- return this.applications.ingress.status === APPLICATION_INSTALLED;
+ return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED;
},
ingressExternalIp() {
return this.applications.ingress.externalIp;
@@ -122,7 +122,7 @@ export default {
);
},
jupyterInstalled() {
- return this.applications.jupyter.status === APPLICATION_INSTALLED;
+ return this.applications.jupyter.status === APPLICATION_STATUS.INSTALLED;
},
jupyterHostname() {
return this.applications.jupyter.hostname;
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 371f71fde44..72fc9355d82 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -1,10 +1,13 @@
// These need to match what is returned from the server
-export const APPLICATION_NOT_INSTALLABLE = 'not_installable';
-export const APPLICATION_INSTALLABLE = 'installable';
-export const APPLICATION_SCHEDULED = 'scheduled';
-export const APPLICATION_INSTALLING = 'installing';
-export const APPLICATION_INSTALLED = 'installed';
-export const APPLICATION_ERROR = 'errored';
+export const APPLICATION_STATUS = {
+ NOT_INSTALLABLE: 'not_installable',
+ INSTALLABLE: 'installable',
+ SCHEDULED: 'scheduled',
+ INSTALLING: 'installing',
+ INSTALLED: 'installed',
+ UPDATED: 'updated',
+ ERROR: 'errored',
+};
// These are only used client-side
export const REQUEST_LOADING = 'request-loading';
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index f595f3c3187..589eeee9695 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -19,3 +19,4 @@ import './polyfills/custom_event';
import './polyfills/element';
import './polyfills/event';
import './polyfills/nodelist';
+import './polyfills/request_idle_callback';
diff --git a/app/assets/javascripts/commons/polyfills/request_idle_callback.js b/app/assets/javascripts/commons/polyfills/request_idle_callback.js
new file mode 100644
index 00000000000..2356569d06e
--- /dev/null
+++ b/app/assets/javascripts/commons/polyfills/request_idle_callback.js
@@ -0,0 +1,17 @@
+window.requestIdleCallback =
+ window.requestIdleCallback ||
+ function requestShim(cb) {
+ const start = Date.now();
+ return setTimeout(() => {
+ cb({
+ didTimeout: false,
+ timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
+ });
+ }, 1);
+ };
+
+window.cancelIdleCallback =
+ window.cancelIdleCallback ||
+ function cancelShim(id) {
+ clearTimeout(id);
+ };
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
index 42e9e568170..8ef9aa7f529 100644
--- a/app/assets/javascripts/create_item_dropdown.js
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -12,6 +12,7 @@ export default class CreateItemDropdown {
this.fieldName = options.fieldName;
this.onSelect = options.onSelect || (() => {});
this.getDataOption = options.getData;
+ this.getDataRemote = !!options.filterRemote;
this.createNewItemFromValueOption = options.createNewItemFromValue;
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
@@ -29,7 +30,7 @@ export default class CreateItemDropdown {
this.$dropdown.glDropdown({
data: this.getData.bind(this),
filterable: true,
- remote: false,
+ filterRemote: this.getDataRemote,
search: {
fields: ['text'],
},
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 20483161033..e64d5511d78 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -30,6 +30,7 @@ export default {
:render-header="false"
:render-diff-file="false"
:always-expanded="true"
+ :discussions-by-diff-order="true"
/>
</ul>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
index ad838a32518..8ad1ea34245 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -71,13 +71,23 @@ export default {
required: false,
default: false,
},
+ isHover: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ discussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
...mapState({
diffViewType: state => state.diffs.diffViewType,
diffFiles: state => state.diffs.diffFiles,
}),
- ...mapGetters(['isLoggedIn', 'discussionsByLineCode']),
+ ...mapGetters(['isLoggedIn']),
lineHref() {
return this.lineCode ? `#${this.lineCode}` : '#';
},
@@ -85,26 +95,22 @@ export default {
return (
this.isLoggedIn &&
this.showCommentButton &&
+ this.isHover &&
!this.isMatchLine &&
!this.isContextLine &&
- !this.hasDiscussions &&
- !this.isMetaLine
+ !this.isMetaLine &&
+ !this.hasDiscussions
);
},
- discussions() {
- return this.discussionsByLineCode[this.lineCode] || [];
- },
hasDiscussions() {
return this.discussions.length > 0;
},
shouldShowAvatarsOnGutter() {
- let render = this.hasDiscussions && this.showCommentButton;
-
if (!this.lineType && this.linePosition === LINE_POSITION_RIGHT) {
- render = false;
+ return false;
}
- return render;
+ return this.showCommentButton && this.hasDiscussions;
},
},
methods: {
@@ -176,7 +182,7 @@ export default {
v-else
>
<button
- v-show="shouldShowCommentButton"
+ v-if="shouldShowCommentButton"
type="button"
class="add-diff-note js-add-diff-note-button"
title="Add a comment to this line"
@@ -189,7 +195,6 @@ export default {
</button>
<a
v-if="lineNumber"
- v-once
:data-linenumber="lineNumber"
:href="lineHref"
>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 32f9516d332..cbe4551d06b 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,17 +1,17 @@
<script>
-import $ from 'jquery';
import { mapState, mapGetters, mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import noteForm from '../../notes/components/note_form.vue';
import { getNoteFormData } from '../store/utils';
-import Autosave from '../../autosave';
-import { DIFF_NOTE_TYPE, NOTE_TYPE } from '../constants';
+import autosave from '../../notes/mixins/autosave';
+import { DIFF_NOTE_TYPE } from '../constants';
export default {
components: {
noteForm,
},
+ mixins: [autosave],
props: {
diffFileHash: {
type: String,
@@ -41,28 +41,35 @@ export default {
},
mounted() {
if (this.isLoggedIn) {
- const noteableData = this.getNoteableData;
const keys = [
- NOTE_TYPE,
- this.noteableType,
- noteableData.id,
- noteableData.diff_head_sha,
+ this.noteableData.diff_head_sha,
DIFF_NOTE_TYPE,
- noteableData.source_project_id,
+ this.noteableData.source_project_id,
this.line.lineCode,
];
- this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys);
+ this.initAutoSave(this.noteableData, keys);
}
},
methods: {
...mapActions('diffs', ['cancelCommentForm']),
...mapActions(['saveNote', 'refetchDiscussionById']),
- handleCancelCommentForm() {
- this.autosave.reset();
+ handleCancelCommentForm(shouldConfirm, isDirty) {
+ if (shouldConfirm && isDirty) {
+ const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
+
+ // eslint-disable-next-line no-alert
+ if (!window.confirm(msg)) {
+ return;
+ }
+ }
+
this.cancelCommentForm({
lineCode: this.line.lineCode,
});
+ this.$nextTick(() => {
+ this.resetAutoSave();
+ });
},
handleSaveNote(note) {
const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash);
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index 5962f30d9bb..33bc8d9971e 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -67,6 +67,11 @@ export default {
required: false,
default: false,
},
+ discussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
...mapGetters(['isLoggedIn']),
@@ -132,10 +137,12 @@ export default {
:line-number="lineNumber"
:meta-data="normalizedLine.metaData"
:show-comment-button="showCommentButton"
+ :is-hover="isHover"
:is-bottom="isBottom"
:is-match-line="isMatchLine"
:is-context-line="isContentLine"
:is-meta-line="isMetaLine"
+ :discussions="discussions"
/>
</td>
</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index ca265dd892c..caf84dc9573 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState, mapGetters } from 'vuex';
+import { mapState } from 'vuex';
import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue';
@@ -21,15 +21,16 @@ export default {
type: Number,
required: true,
},
+ discussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
- ...mapGetters(['discussionsByLineCode']),
- discussions() {
- return this.discussionsByLineCode[this.line.lineCode] || [];
- },
className() {
return this.discussions.length ? '' : 'js-temp-notes-holder';
},
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index 0197a510ef1..32d65ff994f 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -33,6 +33,11 @@ export default {
required: false,
default: false,
},
+ discussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -89,6 +94,7 @@ export default {
:is-bottom="isBottom"
:is-hover="isHover"
:show-comment-button="true"
+ :discussions="discussions"
class="diff-line-num old_line"
/>
<diff-table-cell
@@ -98,10 +104,10 @@ export default {
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isHover"
+ :discussions="discussions"
class="diff-line-num new_line"
/>
<td
- v-once
:class="line.type"
class="line_content"
v-html="line.richText"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index 9fd19b74cd7..e7d789734c3 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -20,8 +20,11 @@ export default {
},
},
computed: {
- ...mapGetters('diffs', ['commitId']),
- ...mapGetters(['discussionsByLineCode']),
+ ...mapGetters('diffs', [
+ 'commitId',
+ 'shouldRenderInlineCommentRow',
+ 'singleDiscussionByLineCode',
+ ]),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
@@ -36,15 +39,8 @@ export default {
},
},
methods: {
- shouldRenderCommentRow(line) {
- if (this.diffLineCommentForms[line.lineCode]) return true;
-
- const lineDiscussions = this.discussionsByLineCode[line.lineCode];
- if (lineDiscussions === undefined) {
- return false;
- }
-
- return lineDiscussions.every(discussion => discussion.expanded);
+ discussionsList(line) {
+ return line.lineCode !== undefined ? this.singleDiscussionByLineCode(line.lineCode) : [];
},
},
};
@@ -65,13 +61,15 @@ export default {
:line="line"
:is-bottom="index + 1 === diffLinesLength"
:key="line.lineCode"
+ :discussions="discussionsList(line)"
/>
<inline-diff-comment-row
- v-if="shouldRenderCommentRow(line)"
+ v-if="shouldRenderInlineCommentRow(line)"
:diff-file-hash="diffFile.fileHash"
:line="line"
:line-index="index"
:key="index"
+ :discussions="discussionsList(line)"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index cc5248c25d9..48b8feeb0b4 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState, mapGetters } from 'vuex';
+import { mapState } from 'vuex';
import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue';
@@ -21,30 +21,34 @@ export default {
type: Number,
required: true,
},
+ leftDiscussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ rightDiscussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
- ...mapGetters(['discussionsByLineCode']),
leftLineCode() {
return this.line.left.lineCode;
},
rightLineCode() {
return this.line.right.lineCode;
},
- hasDiscussion() {
- const discussions = this.discussionsByLineCode;
-
- return discussions[this.leftLineCode] || discussions[this.rightLineCode];
- },
hasExpandedDiscussionOnLeft() {
- const discussions = this.discussionsByLineCode[this.leftLineCode];
+ const discussions = this.leftDiscussions;
return discussions ? discussions.every(discussion => discussion.expanded) : false;
},
hasExpandedDiscussionOnRight() {
- const discussions = this.discussionsByLineCode[this.rightLineCode];
+ const discussions = this.rightDiscussions;
return discussions ? discussions.every(discussion => discussion.expanded) : false;
},
@@ -52,17 +56,18 @@ export default {
return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
},
shouldRenderDiscussionsOnLeft() {
- return this.discussionsByLineCode[this.leftLineCode] && this.hasExpandedDiscussionOnLeft;
+ return this.leftDiscussions && this.hasExpandedDiscussionOnLeft;
},
shouldRenderDiscussionsOnRight() {
- return (
- this.discussionsByLineCode[this.rightLineCode] &&
- this.hasExpandedDiscussionOnRight &&
- this.line.right.type
- );
+ return this.rightDiscussions && this.hasExpandedDiscussionOnRight && this.line.right.type;
+ },
+ showRightSideCommentForm() {
+ return this.line.right.type && this.diffLineCommentForms[this.rightLineCode];
},
className() {
- return this.hasDiscussion ? '' : 'js-temp-notes-holder';
+ return this.leftDiscussions.length > 0 || this.rightDiscussions.length > 0
+ ? ''
+ : 'js-temp-notes-holder';
},
},
};
@@ -80,13 +85,12 @@ export default {
class="content"
>
<diff-discussions
- v-if="discussionsByLineCode[leftLineCode].length"
- :discussions="discussionsByLineCode[leftLineCode]"
+ v-if="leftDiscussions.length"
+ :discussions="leftDiscussions"
/>
</div>
<diff-line-note-form
- v-if="diffLineCommentForms[leftLineCode] &&
- diffLineCommentForms[leftLineCode]"
+ v-if="diffLineCommentForms[leftLineCode]"
:diff-file-hash="diffFileHash"
:line="line.left"
:note-target-line="line.left"
@@ -100,13 +104,12 @@ export default {
class="content"
>
<diff-discussions
- v-if="discussionsByLineCode[rightLineCode].length"
- :discussions="discussionsByLineCode[rightLineCode]"
+ v-if="rightDiscussions.length"
+ :discussions="rightDiscussions"
/>
</div>
<diff-line-note-form
- v-if="diffLineCommentForms[rightLineCode] &&
- diffLineCommentForms[rightLineCode] && line.right.type"
+ v-if="showRightSideCommentForm"
:diff-file-hash="diffFileHash"
:line="line.right"
:note-target-line="line.right"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index ee5bb4d8d05..d4e54c2bd00 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -36,6 +36,16 @@ export default {
required: false,
default: false,
},
+ leftDiscussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ rightDiscussions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -116,10 +126,10 @@ export default {
:is-hover="isLeftHover"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
+ :discussions="leftDiscussions"
class="diff-line-num old_line"
/>
<td
- v-once
:id="line.left.lineCode"
:class="parallelViewLeftLineType"
class="line_content parallel left-side"
@@ -137,10 +147,10 @@ export default {
:is-hover="isRightHover"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
+ :discussions="rightDiscussions"
class="diff-line-num new_line"
/>
<td
- v-once
:id="line.right.lineCode"
:class="line.right.type"
class="line_content parallel right-side"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index 32528c9e7ab..24ceb52a04a 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -21,8 +21,11 @@ export default {
},
},
computed: {
- ...mapGetters('diffs', ['commitId']),
- ...mapGetters(['discussionsByLineCode']),
+ ...mapGetters('diffs', [
+ 'commitId',
+ 'singleDiscussionByLineCode',
+ 'shouldRenderParallelCommentRow',
+ ]),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
@@ -53,29 +56,9 @@ export default {
},
},
methods: {
- shouldRenderCommentRow(line) {
- const leftLineCode = line.left.lineCode;
- const rightLineCode = line.right.lineCode;
- const discussions = this.discussionsByLineCode;
- const leftDiscussions = discussions[leftLineCode];
- const rightDiscussions = discussions[rightLineCode];
- const hasDiscussion = leftDiscussions || rightDiscussions;
-
- const hasExpandedDiscussionOnLeft = leftDiscussions
- ? leftDiscussions.every(discussion => discussion.expanded)
- : false;
- const hasExpandedDiscussionOnRight = rightDiscussions
- ? rightDiscussions.every(discussion => discussion.expanded)
- : false;
-
- if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) {
- return true;
- }
-
- const hasCommentFormOnLeft = this.diffLineCommentForms[leftLineCode];
- const hasCommentFormOnRight = this.diffLineCommentForms[rightLineCode];
-
- return hasCommentFormOnLeft || hasCommentFormOnRight;
+ discussionsByLine(line, leftOrRight) {
+ return line[leftOrRight] && line[leftOrRight].lineCode !== undefined ?
+ this.singleDiscussionByLineCode(line[leftOrRight].lineCode) : [];
},
},
};
@@ -98,13 +81,17 @@ export default {
:line="line"
:is-bottom="index + 1 === diffLinesLength"
:key="index"
+ :left-discussions="discussionsByLine(line, 'left')"
+ :right-discussions="discussionsByLine(line, 'right')"
/>
<parallel-diff-comment-row
- v-if="shouldRenderCommentRow(line)"
+ v-if="shouldRenderParallelCommentRow(line)"
:key="`dcr-${index}`"
:line="line"
:diff-file-hash="diffFile.fileHash"
:line-index="index"
+ :left-discussions="discussionsByLine(line, 'left')"
+ :right-discussions="discussionsByLine(line, 'right')"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 9aec117c236..4a47646d7fa 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -64,6 +64,47 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
discussion.diff_discussion && _.isEqual(discussion.diff_file.file_hash, diff.fileHash),
) || [];
+export const singleDiscussionByLineCode = (state, getters, rootState, rootGetters) => lineCode => {
+ if (!lineCode || lineCode === undefined) return [];
+ const discussions = rootGetters.discussionsByLineCode;
+ return discussions[lineCode] || [];
+};
+
+export const shouldRenderParallelCommentRow = (state, getters) => line => {
+ const leftLineCode = line.left.lineCode;
+ const rightLineCode = line.right.lineCode;
+ const leftDiscussions = getters.singleDiscussionByLineCode(leftLineCode);
+ const rightDiscussions = getters.singleDiscussionByLineCode(rightLineCode);
+ const hasDiscussion = leftDiscussions.length || rightDiscussions.length;
+
+ const hasExpandedDiscussionOnLeft = leftDiscussions.length
+ ? leftDiscussions.every(discussion => discussion.expanded)
+ : false;
+ const hasExpandedDiscussionOnRight = rightDiscussions.length
+ ? rightDiscussions.every(discussion => discussion.expanded)
+ : false;
+
+ if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) {
+ return true;
+ }
+
+ const hasCommentFormOnLeft = state.diffLineCommentForms[leftLineCode];
+ const hasCommentFormOnRight = state.diffLineCommentForms[rightLineCode];
+
+ return hasCommentFormOnLeft || hasCommentFormOnRight;
+};
+
+export const shouldRenderInlineCommentRow = (state, getters) => line => {
+ if (state.diffLineCommentForms[line.lineCode]) return true;
+
+ const lineDiscussions = getters.singleDiscussionByLineCode(line.lineCode);
+ if (lineDiscussions.length === 0) {
+ return false;
+ }
+
+ return lineDiscussions.every(discussion => discussion.expanded);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma∂ tests
export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.fileHash === fileHash);
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index d9589baa76e..82082ac508a 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -173,3 +173,24 @@ export function trimFirstCharOfLineContent(line = {}) {
return parsedLine;
}
+
+export function getDiffRefsByLineCode(diffFiles) {
+ return diffFiles.reduce((acc, diffFile) => {
+ const { baseSha, headSha, startSha } = diffFile.diffRefs;
+ const { newPath, oldPath } = diffFile;
+
+ // We can only use highlightedDiffLines to create the map of diff lines because
+ // highlightedDiffLines will also include every parallel diff line in it.
+ if (diffFile.highlightedDiffLines) {
+ diffFile.highlightedDiffLines.forEach(line => {
+ const { lineCode, oldLine, newLine } = line;
+
+ if (lineCode) {
+ acc[lineCode] = { baseSha, headSha, startSha, newPath, oldPath, oldLine, newLine };
+ }
+ });
+ }
+
+ return acc;
+ }, {});
+}
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 21cf92d1bc5..11e3b781e5a 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -116,7 +116,8 @@ export default {
this.model &&
this.hasLastDeploymentKey &&
this.model.last_deployment &&
- this.model.last_deployment.deployable
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.retry_path
);
},
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 8d231e6c405..c3959ef3e9e 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, one-var-declaration-per-line, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */
+/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, one-var-declaration-per-line, max-len, vars-on-top, wrap-iife, no-unused-vars, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */
/* global fuzzaldrinPlus */
import $ from 'jquery';
@@ -19,32 +19,42 @@ GitLabDropdownInput = (function() {
this.fieldName = this.options.fieldName || 'field-name';
$inputContainer = this.input.parent();
$clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on('click', (function(_this) {
- // Clear click
- return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.input.val('').trigger('input').focus();
- };
- })(this));
+ $clearButton.on(
+ 'click',
+ (function(_this) {
+ // Clear click
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.input
+ .val('')
+ .trigger('input')
+ .focus();
+ };
+ })(this),
+ );
this.input
- .on('keydown', function (e) {
- var keyCode = e.which;
- if (keyCode === 13 && !options.elIsInput) {
- e.preventDefault();
- }
- })
- .on('input', function(e) {
- var val = e.currentTarget.value || _this.options.inputFieldName;
- val = val.split(' ').join('-') // replaces space with dash
- .replace(/[^a-zA-Z0-9 -]/g, '').toLowerCase() // replace non alphanumeric
- .replace(/(-)\1+/g, '-'); // replace repeated dashes
- _this.cb(_this.options.fieldName, val, {}, true);
- _this.input.closest('.dropdown')
- .find('.dropdown-toggle-text')
- .text(val);
- });
+ .on('keydown', function(e) {
+ var keyCode = e.which;
+ if (keyCode === 13 && !options.elIsInput) {
+ e.preventDefault();
+ }
+ })
+ .on('input', function(e) {
+ var val = e.currentTarget.value || _this.options.inputFieldName;
+ val = val
+ .split(' ')
+ .join('-') // replaces space with dash
+ .replace(/[^a-zA-Z0-9 -]/g, '')
+ .toLowerCase() // replace non alphanumeric
+ .replace(/(-)\1+/g, '-'); // replace repeated dashes
+ _this.cb(_this.options.fieldName, val, {}, true);
+ _this.input
+ .closest('.dropdown')
+ .find('.dropdown-toggle-text')
+ .text(val);
+ });
}
GitLabDropdownInput.prototype.onInput = function(cb) {
@@ -61,7 +71,7 @@ GitLabDropdownFilter = (function() {
ARROW_KEY_CODES = [38, 40];
- HAS_VALUE_CLASS = "has-value";
+ HAS_VALUE_CLASS = 'has-value';
function GitLabDropdownFilter(input, options) {
var $clearButton, $inputContainer, ref, timeout;
@@ -70,44 +80,59 @@ GitLabDropdownFilter = (function() {
this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
$inputContainer = this.input.parent();
$clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on('click', (function(_this) {
- // Clear click
- return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.input.val('').trigger('input').focus();
- };
- })(this));
+ $clearButton.on(
+ 'click',
+ (function(_this) {
+ // Clear click
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.input
+ .val('')
+ .trigger('input')
+ .focus();
+ };
+ })(this),
+ );
// Key events
- timeout = "";
+ timeout = '';
this.input
- .on('keydown', function (e) {
+ .on('keydown', function(e) {
var keyCode = e.which;
if (keyCode === 13 && !options.elIsInput) {
e.preventDefault();
}
})
- .on('input', function() {
- if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.addClass(HAS_VALUE_CLASS);
- } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.removeClass(HAS_VALUE_CLASS);
- }
- // Only filter asynchronously only if option remote is set
- if (this.options.remote) {
- clearTimeout(timeout);
- return timeout = setTimeout(function() {
- $inputContainer.parent().addClass('is-loading');
-
- return this.options.query(this.input.val(), function(data) {
- $inputContainer.parent().removeClass('is-loading');
- return this.options.callback(data);
- }.bind(this));
- }.bind(this), 250);
- } else {
- return this.filter(this.input.val());
- }
- }.bind(this));
+ .on(
+ 'input',
+ function() {
+ if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.addClass(HAS_VALUE_CLASS);
+ } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.removeClass(HAS_VALUE_CLASS);
+ }
+ // Only filter asynchronously only if option remote is set
+ if (this.options.remote) {
+ clearTimeout(timeout);
+ return (timeout = setTimeout(
+ function() {
+ $inputContainer.parent().addClass('is-loading');
+
+ return this.options.query(
+ this.input.val(),
+ function(data) {
+ $inputContainer.parent().removeClass('is-loading');
+ return this.options.callback(data);
+ }.bind(this),
+ );
+ }.bind(this),
+ 250,
+ ));
+ } else {
+ return this.filter(this.input.val());
+ }
+ }.bind(this),
+ );
}
GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
@@ -120,7 +145,7 @@ GitLabDropdownFilter = (function() {
this.options.onFilter(search_text);
}
data = this.options.data();
- if ((data != null) && !this.options.filterByText) {
+ if (data != null && !this.options.filterByText) {
results = data;
if (search_text !== '') {
// When data is an array of objects therefore [object Array] e.g.
@@ -130,7 +155,7 @@ GitLabDropdownFilter = (function() {
// ]
if (_.isArray(data)) {
results = fuzzaldrinPlus.filter(data, search_text, {
- key: this.options.keys
+ key: this.options.keys,
});
} else {
// If data is grouped therefore an [object Object]. e.g.
@@ -149,7 +174,7 @@ GitLabDropdownFilter = (function() {
for (key in data) {
group = data[key];
tmp = fuzzaldrinPlus.filter(group, search_text, {
- key: this.options.keys
+ key: this.options.keys,
});
if (tmp.length) {
results[key] = tmp.map(function(item) {
@@ -180,7 +205,10 @@ GitLabDropdownFilter = (function() {
elements.show().removeClass('option-hidden');
}
- elements.parent().find('.dropdown-menu-empty-item').toggleClass('hidden', elements.is(':visible'));
+ elements
+ .parent()
+ .find('.dropdown-menu-empty-item')
+ .toggleClass('hidden', elements.is(':visible'));
}
};
@@ -194,23 +222,26 @@ GitLabDropdownRemote = (function() {
}
GitLabDropdownRemote.prototype.execute = function() {
- if (typeof this.dataEndpoint === "string") {
+ if (typeof this.dataEndpoint === 'string') {
return this.fetchData();
- } else if (typeof this.dataEndpoint === "function") {
+ } else if (typeof this.dataEndpoint === 'function') {
if (this.options.beforeSend) {
this.options.beforeSend();
}
- return this.dataEndpoint("", (function(_this) {
- // Fetch the data by calling the data funcfion
- return function(data) {
- if (_this.options.success) {
- _this.options.success(data);
- }
- if (_this.options.beforeSend) {
- return _this.options.beforeSend();
- }
- };
- })(this));
+ return this.dataEndpoint(
+ '',
+ (function(_this) {
+ // Fetch the data by calling the data funcfion
+ return function(data) {
+ if (_this.options.success) {
+ _this.options.success(data);
+ }
+ if (_this.options.beforeSend) {
+ return _this.options.beforeSend();
+ }
+ };
+ })(this),
+ );
}
};
@@ -220,33 +251,41 @@ GitLabDropdownRemote = (function() {
}
// Fetch the data through ajax if the data is a string
- return axios.get(this.dataEndpoint)
- .then(({ data }) => {
- if (this.options.success) {
- return this.options.success(data);
- }
- });
+ return axios.get(this.dataEndpoint).then(({ data }) => {
+ if (this.options.success) {
+ return this.options.success(data);
+ }
+ });
};
return GitLabDropdownRemote;
})();
GitLabDropdown = (function() {
- var ACTIVE_CLASS, FILTER_INPUT, NO_FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex;
+ var ACTIVE_CLASS,
+ FILTER_INPUT,
+ NO_FILTER_INPUT,
+ INDETERMINATE_CLASS,
+ LOADING_CLASS,
+ PAGE_TWO_CLASS,
+ NON_SELECTABLE_CLASSES,
+ SELECTABLE_CLASSES,
+ CURSOR_SELECT_SCROLL_PADDING,
+ currentIndex;
- LOADING_CLASS = "is-loading";
+ LOADING_CLASS = 'is-loading';
- PAGE_TWO_CLASS = "is-page-two";
+ PAGE_TWO_CLASS = 'is-page-two';
- ACTIVE_CLASS = "is-active";
+ ACTIVE_CLASS = 'is-active';
- INDETERMINATE_CLASS = "is-indeterminate";
+ INDETERMINATE_CLASS = 'is-indeterminate';
currentIndex = -1;
NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
- SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ", .option-hidden)";
+ SELECTABLE_CLASSES = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ', .option-hidden)';
CURSOR_SELECT_SCROLL_PADDING = 5;
@@ -263,15 +302,15 @@ GitLabDropdown = (function() {
this.opened = this.opened.bind(this);
this.shouldPropagate = this.shouldPropagate.bind(this);
self = this;
- selector = $(this.el).data("target");
+ selector = $(this.el).data('target');
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
// Set Defaults
this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT);
this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT);
this.highlight = !!this.options.highlight;
- this.filterInputBlur = this.options.filterInputBlur != null
- ? this.options.filterInputBlur
- : true;
+ this.icon = !!this.options.icon;
+ this.filterInputBlur =
+ this.options.filterInputBlur != null ? this.options.filterInputBlur : true;
// If no input is passed create a default one
self = this;
// If selector was passed
@@ -296,11 +335,17 @@ GitLabDropdown = (function() {
_this.fullData = data;
_this.parseData(_this.fullData);
_this.focusTextInput();
- if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') {
+ if (
+ _this.options.filterable &&
+ _this.filter &&
+ _this.filter.input &&
+ _this.filter.input.val() &&
+ _this.filter.input.val().trim() !== ''
+ ) {
return _this.filter.input.trigger('input');
}
};
- // Remote data
+ // Remote data
})(this),
instance: this,
});
@@ -325,7 +370,7 @@ GitLabDropdown = (function() {
return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = ".dropdown-page-one " + selector;
+ selector = '.dropdown-page-one ' + selector;
}
return $(selector, this.instance.dropdown);
};
@@ -341,80 +386,97 @@ GitLabDropdown = (function() {
if (_this.filterInput.val() !== '') {
selector = SELECTABLE_CLASSES;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = ".dropdown-page-one " + selector;
+ selector = '.dropdown-page-one ' + selector;
}
if ($(_this.el).is('input')) {
currentIndex = -1;
} else {
- $(selector, _this.dropdown).first().find('a').addClass('is-focused');
+ $(selector, _this.dropdown)
+ .first()
+ .find('a')
+ .addClass('is-focused');
currentIndex = 0;
}
}
};
- })(this)
+ })(this),
});
}
// Event listeners
- this.dropdown.on("shown.bs.dropdown", this.opened);
- this.dropdown.on("hidden.bs.dropdown", this.hidden);
- $(this.el).on("update.label", this.updateLabel);
- this.dropdown.on("click", ".dropdown-menu, .dropdown-menu-close", this.shouldPropagate);
- this.dropdown.on('keyup', (function(_this) {
- return function(e) {
- // Escape key
- if (e.which === 27) {
- return $('.dropdown-menu-close', _this.dropdown).trigger('click');
- }
- };
- })(this));
- this.dropdown.on('blur', 'a', (function(_this) {
- return function(e) {
- var $dropdownMenu, $relatedTarget;
- if (e.relatedTarget != null) {
- $relatedTarget = $(e.relatedTarget);
- $dropdownMenu = $relatedTarget.closest('.dropdown-menu');
- if ($dropdownMenu.length === 0) {
- return _this.dropdown.removeClass('show');
+ this.dropdown.on('shown.bs.dropdown', this.opened);
+ this.dropdown.on('hidden.bs.dropdown', this.hidden);
+ $(this.el).on('update.label', this.updateLabel);
+ this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate);
+ this.dropdown.on(
+ 'keyup',
+ (function(_this) {
+ return function(e) {
+ // Escape key
+ if (e.which === 27) {
+ return $('.dropdown-menu-close', _this.dropdown).trigger('click');
}
- }
- };
- })(this));
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- this.dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on("click", (function(_this) {
+ };
+ })(this),
+ );
+ this.dropdown.on(
+ 'blur',
+ 'a',
+ (function(_this) {
return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.togglePage();
+ var $dropdownMenu, $relatedTarget;
+ if (e.relatedTarget != null) {
+ $relatedTarget = $(e.relatedTarget);
+ $dropdownMenu = $relatedTarget.closest('.dropdown-menu');
+ if ($dropdownMenu.length === 0) {
+ return _this.dropdown.removeClass('show');
+ }
+ }
};
- })(this));
+ })(this),
+ );
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on(
+ 'click',
+ (function(_this) {
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.togglePage();
+ };
+ })(this),
+ );
}
if (this.options.selectable) {
- selector = ".dropdown-content a";
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one .dropdown-content a";
+ selector = '.dropdown-content a';
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = '.dropdown-page-one .dropdown-content a';
}
- this.dropdown.on("click", selector, function(e) {
- var $el, selected, selectedObj, isMarking;
- $el = $(e.currentTarget);
- selected = self.rowClicked($el);
- selectedObj = selected ? selected[0] : null;
- isMarking = selected ? selected[1] : null;
- if (this.options.clicked) {
- this.options.clicked.call(this, {
- selectedObj,
- $el,
- e,
- isMarking,
- });
- }
+ this.dropdown.on(
+ 'click',
+ selector,
+ function(e) {
+ var $el, selected, selectedObj, isMarking;
+ $el = $(e.currentTarget);
+ selected = self.rowClicked($el);
+ selectedObj = selected ? selected[0] : null;
+ isMarking = selected ? selected[1] : null;
+ if (this.options.clicked) {
+ this.options.clicked.call(this, {
+ selectedObj,
+ $el,
+ e,
+ isMarking,
+ });
+ }
- // Update label right after all modifications in dropdown has been done
- if (this.options.toggleLabel) {
- this.updateLabel(selectedObj, $el, this);
- }
+ // Update label right after all modifications in dropdown has been done
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
+ }
- $el.trigger('blur');
- }.bind(this));
+ $el.trigger('blur');
+ }.bind(this),
+ );
}
}
@@ -452,10 +514,15 @@ GitLabDropdown = (function() {
html = [];
for (name in data) {
groupData = data[name];
- html.push(this.renderItem({
- header: name
- // Add header for each group
- }, name));
+ html.push(
+ this.renderItem(
+ {
+ header: name,
+ // Add header for each group
+ },
+ name,
+ ),
+ );
this.renderData(groupData, name).map(function(item) {
return html.push(item);
});
@@ -474,20 +541,25 @@ GitLabDropdown = (function() {
if (group == null) {
group = false;
}
- return data.map((function(_this) {
- return function(obj, index) {
- return _this.renderItem(obj, group, index);
- };
- })(this));
+ return data.map(
+ (function(_this) {
+ return function(obj, index) {
+ return _this.renderItem(obj, group, index);
+ };
+ })(this),
+ );
};
GitLabDropdown.prototype.shouldPropagate = function(e) {
var $target;
if (this.options.multiSelect || this.options.shouldPropagate === false) {
$target = $(e.target);
- if ($target && !$target.hasClass('dropdown-menu-close') &&
- !$target.hasClass('dropdown-menu-close-icon') &&
- !$target.data('isLink')) {
+ if (
+ $target &&
+ !$target.hasClass('dropdown-menu-close') &&
+ !$target.hasClass('dropdown-menu-close-icon') &&
+ !$target.data('isLink')
+ ) {
e.stopPropagation();
return false;
} else {
@@ -497,9 +569,11 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.filteredFullData = function() {
- return this.fullData.filter(r => typeof r === 'object'
- && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
- && !Object.prototype.hasOwnProperty.call(r, 'header')
+ return this.fullData.filter(
+ r =>
+ typeof r === 'object' &&
+ !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') &&
+ !Object.prototype.hasOwnProperty.call(r, 'header'),
);
};
@@ -522,11 +596,16 @@ GitLabDropdown = (function() {
// matches the correct layout
const inputValue = this.filterInput.val();
if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
- this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
+ this.options.processData.call(
+ this.options,
+ inputValue,
+ this.filteredFullData(),
+ this.parseData.bind(this),
+ );
}
contentHtml = $('.dropdown-content', this.dropdown).html();
- if (this.remote && contentHtml === "") {
+ if (this.remote && contentHtml === '') {
this.remote.execute();
} else {
this.focusTextInput();
@@ -537,7 +616,11 @@ GitLabDropdown = (function() {
}
if (this.options.opened) {
- this.options.opened.call(this, e);
+ if (this.options.preserveContext) {
+ this.options.opened(e);
+ } else {
+ this.options.opened.call(this, e);
+ }
}
return this.dropdown.trigger('shown.gl.dropdown');
@@ -555,11 +638,11 @@ GitLabDropdown = (function() {
var $input;
this.resetRows();
this.removeArrayKeyEvent();
- $input = this.dropdown.find(".dropdown-input-field");
+ $input = this.dropdown.find('.dropdown-input-field');
if (this.options.filterable) {
$input.blur();
}
- if (this.dropdown.find(".dropdown-toggle-page").length) {
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
$('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS);
}
if (this.options.hidden) {
@@ -601,7 +684,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.clearMenu = function() {
var selector;
selector = '.dropdown-content';
- if (this.dropdown.find(".dropdown-toggle-page").length) {
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
if (this.options.containerSelector) {
selector = this.options.containerSelector;
} else {
@@ -619,7 +702,7 @@ GitLabDropdown = (function() {
value = this.options.id ? this.options.id(data) : data.id;
if (value) {
- value = value.toString().replace(/'/g, '\\\'');
+ value = value.toString().replace(/'/g, "\\'");
}
}
@@ -676,21 +759,27 @@ GitLabDropdown = (function() {
text = data.text != null ? data.text : '';
}
if (this.highlight) {
- text = this.highlightTextMatches(text, this.filterInput.val());
+ text = data.template
+ ? this.highlightTemplate(text, data.template)
+ : this.highlightTextMatches(text, this.filterInput.val());
}
// Create the list item & the link
var link = document.createElement('a');
link.href = url;
- if (this.highlight) {
+ if (this.icon) {
+ text = `<span>${text}</span>`;
+ link.classList.add('d-flex', 'align-items-center');
+ link.innerHTML = data.icon ? data.icon + text : text;
+ } else if (this.highlight) {
link.innerHTML = text;
} else {
link.textContent = text;
}
if (selected) {
- link.className = 'is-active';
+ link.classList.add('is-active');
}
if (group) {
@@ -703,17 +792,24 @@ GitLabDropdown = (function() {
return html;
};
+ GitLabDropdown.prototype.highlightTemplate = function(text, template) {
+ return `"<b>${_.escape(text)}</b>" ${template}`;
+ };
+
GitLabDropdown.prototype.highlightTextMatches = function(text, term) {
const occurrences = fuzzaldrinPlus.match(text, term);
const { indexOf } = [];
- return text.split('').map(function(character, i) {
- if (indexOf.call(occurrences, i) !== -1) {
- return "<b>" + character + "</b>";
- } else {
- return character;
- }
- }).join('');
+ return text
+ .split('')
+ .map(function(character, i) {
+ if (indexOf.call(occurrences, i) !== -1) {
+ return '<b>' + character + '</b>';
+ } else {
+ return character;
+ }
+ })
+ .join('');
};
GitLabDropdown.prototype.noResults = function() {
@@ -748,13 +844,15 @@ GitLabDropdown = (function() {
}
field = [];
- value = this.options.id
- ? this.options.id(selectedObject, el)
- : selectedObject.id;
+ value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id;
if (isInput) {
field = $(this.el);
} else if (value != null) {
- field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']");
+ field = this.dropdown
+ .parent()
+ .find(
+ "input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, "\\'") + "']",
+ );
}
if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
@@ -780,9 +878,12 @@ GitLabDropdown = (function() {
} else {
isMarking = true;
if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) {
- this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
+ this.dropdown.find('.' + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
if (!isInput) {
- this.dropdown.parent().find("input[name='" + fieldName + "']").remove();
+ this.dropdown
+ .parent()
+ .find("input[name='" + fieldName + "']")
+ .remove();
}
}
if (field && field.length && value == null) {
@@ -823,13 +924,16 @@ GitLabDropdown = (function() {
$('input[name="' + fieldName + '"]').remove();
}
- $input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value);
+ $input = $('<input>')
+ .attr('type', 'hidden')
+ .attr('name', fieldName)
+ .val(value);
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
if (this.options.multiSelect) {
- Object.keys(selectedObject).forEach((attribute) => {
+ Object.keys(selectedObject).forEach(attribute => {
$input.attr(`data-${attribute}`, selectedObject[attribute]);
});
}
@@ -844,13 +948,13 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.selectRowAtIndex = function(index) {
var $el, selector;
// If we pass an option index
- if (typeof index !== "undefined") {
- selector = SELECTABLE_CLASSES + ":eq(" + index + ") a";
+ if (typeof index !== 'undefined') {
+ selector = SELECTABLE_CLASSES + ':eq(' + index + ') a';
} else {
- selector = ".dropdown-content .is-focused";
+ selector = '.dropdown-content .is-focused';
}
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one " + selector;
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = '.dropdown-page-one ' + selector;
}
// simulate a click on the first link
$el = $(selector, this.dropdown);
@@ -867,44 +971,47 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.addArrowKeyEvent = function() {
var $input, ARROW_KEY_CODES, selector;
ARROW_KEY_CODES = [38, 40];
- $input = this.dropdown.find(".dropdown-input-field");
+ $input = this.dropdown.find('.dropdown-input-field');
selector = SELECTABLE_CLASSES;
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one " + selector;
- }
- return $('body').on('keydown', (function(_this) {
- return function(e) {
- var $listItems, PREV_INDEX, currentKeyCode;
- currentKeyCode = e.which;
- if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) {
- e.preventDefault();
- e.stopImmediatePropagation();
- PREV_INDEX = currentIndex;
- $listItems = $(selector, _this.dropdown);
- // if @options.filterable
- // $input.blur()
- if (currentKeyCode === 40) {
- // Move down
- if (currentIndex < ($listItems.length - 1)) {
- currentIndex += 1;
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = '.dropdown-page-one ' + selector;
+ }
+ return $('body').on(
+ 'keydown',
+ (function(_this) {
+ return function(e) {
+ var $listItems, PREV_INDEX, currentKeyCode;
+ currentKeyCode = e.which;
+ if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ PREV_INDEX = currentIndex;
+ $listItems = $(selector, _this.dropdown);
+ // if @options.filterable
+ // $input.blur()
+ if (currentKeyCode === 40) {
+ // Move down
+ if (currentIndex < $listItems.length - 1) {
+ currentIndex += 1;
+ }
+ } else if (currentKeyCode === 38) {
+ // Move up
+ if (currentIndex > 0) {
+ currentIndex -= 1;
+ }
}
- } else if (currentKeyCode === 38) {
- // Move up
- if (currentIndex > 0) {
- currentIndex -= 1;
+ if (currentIndex !== PREV_INDEX) {
+ _this.highlightRowAtIndex($listItems, currentIndex);
}
+ return false;
}
- if (currentIndex !== PREV_INDEX) {
- _this.highlightRowAtIndex($listItems, currentIndex);
+ if (currentKeyCode === 13 && currentIndex !== -1) {
+ e.preventDefault();
+ _this.selectRowAtIndex();
}
- return false;
- }
- if (currentKeyCode === 13 && currentIndex !== -1) {
- e.preventDefault();
- _this.selectRowAtIndex();
- }
- };
- })(this));
+ };
+ })(this),
+ );
};
GitLabDropdown.prototype.removeArrayKeyEvent = function() {
@@ -917,12 +1024,25 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) {
- var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop;
+ var $dropdownContent,
+ $listItem,
+ dropdownContentBottom,
+ dropdownContentHeight,
+ dropdownContentTop,
+ dropdownScrollTop,
+ listItemBottom,
+ listItemHeight,
+ listItemTop;
+
+ if (!$listItems) {
+ $listItems = $(SELECTABLE_CLASSES, this.dropdown);
+ }
+
// Remove the class for the previously focused row
$('.is-focused', this.dropdown).removeClass('is-focused');
// Update the class for the row at the specific index
$listItem = $listItems.eq(index);
- $listItem.find('a:first-child').addClass("is-focused");
+ $listItem.find('a:first-child').addClass('is-focused');
// Dropdown content scroll area
$dropdownContent = $listItem.closest('.dropdown-content');
dropdownScrollTop = $dropdownContent.scrollTop();
@@ -936,15 +1056,19 @@ GitLabDropdown = (function() {
if (!index) {
// Scroll the dropdown content to the top
$dropdownContent.scrollTop(0);
- } else if (index === ($listItems.length - 1)) {
+ } else if (index === $listItems.length - 1) {
// Scroll the dropdown content to the bottom
$dropdownContent.scrollTop($dropdownContent.prop('scrollHeight'));
- } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) {
+ } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) {
// Scroll the dropdown content down
- $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING);
- } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) {
+ $dropdownContent.scrollTop(
+ listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING,
+ );
+ } else if (listItemTop < dropdownContentTop + dropdownScrollTop) {
// Scroll the dropdown content up
- return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING);
+ return $dropdownContent.scrollTop(
+ listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING,
+ );
}
};
@@ -965,7 +1089,9 @@ GitLabDropdown = (function() {
toggleText = this.options.updateLabel;
}
- return $(this.el).find(".dropdown-toggle-text").text(toggleText);
+ return $(this.el)
+ .find('.dropdown-toggle-text')
+ .text(toggleText);
};
GitLabDropdown.prototype.clearField = function(field, isInput) {
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index efbf2e3a295..2b9e2a929fc 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -78,17 +78,10 @@ export default {
>
<div
:class="{ 'project-row-contents': !isGroup }"
- class="group-row-contents">
- <item-actions
- v-if="isGroup"
- :group="group"
- :parent-group="parentGroup"
- />
- <item-stats
- :item="group"
- />
+ class="group-row-contents d-flex justify-content-end align-items-center"
+ >
<div
- class="folder-toggle-wrap"
+ class="folder-toggle-wrap append-right-4 d-flex align-items-center"
>
<item-caret
:is-group-open="group.isOpen"
@@ -100,7 +93,7 @@ export default {
</div>
<div
:class="{ 'content-loading': group.isChildrenLoading }"
- class="avatar-container prepend-top-8 prepend-left-5 s24 d-none d-sm-block"
+ class="avatar-container s24 d-none d-sm-block"
>
<a
:href="group.relativePath"
@@ -120,32 +113,46 @@ export default {
</a>
</div>
<div
- class="title namespace-title"
+ class="group-text flex-grow"
>
- <a
- v-tooltip
- :href="group.relativePath"
- :title="group.fullName"
- class="no-expand"
- data-placement="bottom"
- >{{
- // ending bracket must be by closing tag to prevent
- // link hover text-decoration from over-extending
- group.name
- }}</a>
- <span
- v-if="group.permission"
- class="user-access-role"
+ <div
+ class="title namespace-title append-right-8"
>
- {{ group.permission }}
- </span>
- </div>
- <div
- v-if="group.description"
- class="description">
- <span v-html="group.description">
- </span>
+ <a
+ v-tooltip
+ :href="group.relativePath"
+ :title="group.fullName"
+ class="no-expand"
+ data-placement="bottom"
+ >{{
+ // ending bracket must be by closing tag to prevent
+ // link hover text-decoration from over-extending
+ group.name
+ }}</a>
+ <span
+ v-if="group.permission"
+ class="user-access-role"
+ >
+ {{ group.permission }}
+ </span>
+ </div>
+ <div
+ v-if="group.description"
+ class="description"
+ >
+ <span v-html="group.description">
+ </span>
+ </div>
</div>
+ <item-stats
+ :item="group"
+ class="group-stats prepend-top-2"
+ />
+ <item-actions
+ v-if="isGroup"
+ :group="group"
+ :parent-group="parentGroup"
+ />
</div>
<group-folder
v-if="group.isOpen && hasChildren"
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 62697e0ecc3..2cebacc1c4c 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -13,11 +13,8 @@ export default {
tooltip,
},
computed: {
- ...mapGetters(['currentProject', 'hasChanges']),
+ ...mapGetters(['hasChanges']),
...mapState(['currentActivityView']),
- goBackUrl() {
- return document.referrer || this.currentProject.web_url;
- },
},
methods: {
...mapActions(['updateActivityBarView']),
@@ -36,22 +33,6 @@ export default {
<template>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
- <li v-once>
- <a
- v-tooltip
- :href="goBackUrl"
- :title="s__('IDE|Go back')"
- :aria-label="s__('IDE|Go back')"
- data-container="body"
- data-placement="right"
- class="ide-sidebar-link"
- >
- <icon
- :size="16"
- name="go-back"
- />
- </a>
- </li>
<li>
<button
v-tooltip
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
new file mode 100644
index 00000000000..cc3e84e3f77
--- /dev/null
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -0,0 +1,60 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import router from '../../ide_router';
+
+export default {
+ components: {
+ Icon,
+ Timeago,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ isActive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ branchHref() {
+ return router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href;
+ },
+ },
+};
+</script>
+
+<template>
+ <a
+ :href="branchHref"
+ class="btn-link d-flex align-items-center"
+ >
+ <span class="d-flex append-right-default ide-search-list-current-icon">
+ <icon
+ v-if="isActive"
+ :size="18"
+ name="mobile-issue-close"
+ />
+ </span>
+ <span>
+ <strong>
+ {{ item.name }}
+ </strong>
+ <span
+ class="ide-merge-request-project-path d-block mt-1"
+ >
+ Updated
+ <timeago
+ :time="item.committedDate || ''"
+ />
+ </span>
+ </span>
+ </a>
+</template>
diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue
new file mode 100644
index 00000000000..6db7b9d6b0e
--- /dev/null
+++ b/app/assets/javascripts/ide/components/branches/search_list.vue
@@ -0,0 +1,111 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import _ from 'underscore';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import Item from './item.vue';
+
+export default {
+ components: {
+ LoadingIcon,
+ Item,
+ Icon,
+ },
+ data() {
+ return {
+ search: '',
+ };
+ },
+ computed: {
+ ...mapState('branches', ['branches', 'isLoading']),
+ ...mapState(['currentBranchId', 'currentProjectId']),
+ hasBranches() {
+ return this.branches.length !== 0;
+ },
+ hasNoSearchResults() {
+ return this.search !== '' && !this.hasBranches;
+ },
+ },
+ watch: {
+ isLoading: {
+ handler: 'focusSearch',
+ },
+ },
+ mounted() {
+ this.loadBranches();
+ },
+ methods: {
+ ...mapActions('branches', ['fetchBranches']),
+ loadBranches() {
+ this.fetchBranches({ search: this.search });
+ },
+ searchBranches: _.debounce(function debounceSearch() {
+ this.loadBranches();
+ }, 250),
+ focusSearch() {
+ if (!this.isLoading) {
+ this.$nextTick(() => {
+ this.$refs.searchInput.focus();
+ });
+ }
+ },
+ isActiveBranch(item) {
+ return item.name === this.currentBranchId;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
+ <div class="position-relative">
+ <input
+ ref="searchInput"
+ :placeholder="__('Search branches')"
+ v-model="search"
+ type="search"
+ class="form-control dropdown-input-field"
+ @input="searchBranches"
+ />
+ <icon
+ :size="18"
+ name="search"
+ class="input-icon"
+ />
+ </div>
+ </div>
+ <div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
+ <loading-icon
+ v-if="isLoading"
+ class="mt-3 mb-3 align-self-center ml-auto mr-auto"
+ size="2"
+ />
+ <ul
+ v-else
+ class="mb-3 w-100"
+ >
+ <template v-if="hasBranches">
+ <li
+ v-for="item in branches"
+ :key="item.name"
+ >
+ <item
+ :item="item"
+ :project-id="currentProjectId"
+ :is-active="isActiveBranch(item)"
+ />
+ </li>
+ </template>
+ <li
+ v-else
+ class="ide-search-list-empty d-flex align-items-center justify-content-center"
+ >
+ <template v-if="hasNoSearchResults">
+ {{ __('No branches found') }}
+ </template>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue
new file mode 100644
index 00000000000..6cf190288e8
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_header.vue
@@ -0,0 +1,37 @@
+<script>
+import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue';
+
+export default {
+ components: {
+ ProjectAvatarDefault,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="context-header ide-context-header">
+ <a
+ :href="project.web_url"
+ :title="s__('IDE|Go to project')"
+ >
+ <project-avatar-default
+ :project="project"
+ :size="48"
+ />
+ <span class="ide-sidebar-project-title">
+ <span class="sidebar-context-title">
+ {{ project.name }}
+ </span>
+ <span class="sidebar-context-title text-secondary">
+ {{ project.path_with_namespace }}
+ </span>
+ </span>
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 21906674c4b..4771c58a11d 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -1,12 +1,6 @@
<script>
-import $ from 'jquery';
import { mapState, mapGetters } from 'vuex';
-import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
-import Icon from '~/vue_shared/components/icon.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
-import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
-import Identicon from '../../vue_shared/components/identicon.vue';
import IdeTree from './ide_tree.vue';
import ResizablePanel from './resizable_panel.vue';
import ActivityBar from './activity_bar.vue';
@@ -14,43 +8,28 @@ import CommitSection from './repo_commit_section.vue';
import CommitForm from './commit_sidebar/form.vue';
import IdeReview from './ide_review.vue';
import SuccessMessage from './commit_sidebar/success_message.vue';
-import MergeRequestDropdown from './merge_requests/dropdown.vue';
+import IdeProjectHeader from './ide_project_header.vue';
import { activityBarViews } from '../constants';
export default {
- directives: {
- tooltip,
- },
components: {
- Icon,
- PanelResizer,
SkeletonLoadingContainer,
ResizablePanel,
ActivityBar,
- ProjectAvatarImage,
- Identicon,
CommitSection,
IdeTree,
CommitForm,
IdeReview,
SuccessMessage,
- MergeRequestDropdown,
- },
- data() {
- return {
- showTooltip: false,
- showMergeRequestsDropdown: false,
- };
+ IdeProjectHeader,
},
computed: {
...mapState([
'loading',
- 'currentBranchId',
'currentActivityView',
'changedFiles',
'stagedFiles',
'lastCommitMsg',
- 'currentMergeRequestId',
]),
...mapGetters(['currentProject', 'someUncommitedChanges']),
showSuccessMessage() {
@@ -59,46 +38,6 @@ export default {
(this.lastCommitMsg && !this.someUncommitedChanges)
);
},
- branchTooltipTitle() {
- return this.showTooltip ? this.currentBranchId : undefined;
- },
- },
- watch: {
- currentBranchId() {
- this.$nextTick(() => {
- if (!this.$refs.branchId) return;
-
- this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth;
- });
- },
- loading() {
- this.$nextTick(() => {
- this.addDropdownListeners();
- });
- },
- },
- mounted() {
- this.addDropdownListeners();
- },
- beforeDestroy() {
- $(this.$refs.mergeRequestDropdown)
- .off('show.bs.dropdown')
- .off('hide.bs.dropdown');
- },
- methods: {
- addDropdownListeners() {
- if (!this.$refs.mergeRequestDropdown) return;
-
- $(this.$refs.mergeRequestDropdown)
- .on('show.bs.dropdown', () => {
- this.toggleMergeRequestDropdown();
- }).on('hide.bs.dropdown', () => {
- this.toggleMergeRequestDropdown();
- });
- },
- toggleMergeRequestDropdown() {
- this.showMergeRequestsDropdown = !this.showMergeRequestsDropdown;
- },
},
};
</script>
@@ -108,12 +47,10 @@ export default {
:collapsible="false"
:initial-width="340"
side="left"
+ class="flex-column"
>
- <activity-bar
- v-if="!loading"
- />
- <div class="multi-file-commit-panel-inner">
- <template v-if="loading">
+ <template v-if="loading">
+ <div class="multi-file-commit-panel-inner">
<div
v-for="n in 3"
:key="n"
@@ -121,81 +58,23 @@ export default {
>
<skeleton-loading-container />
</div>
- </template>
- <template v-else>
- <div
- ref="mergeRequestDropdown"
- class="context-header ide-context-header dropdown"
- >
- <button
- type="button"
- data-toggle="dropdown"
- >
- <div
- v-if="currentProject.avatar_url"
- class="avatar-container s40 project-avatar"
- >
- <project-avatar-image
- :link-href="currentProject.path"
- :img-src="currentProject.avatar_url"
- :img-alt="currentProject.name"
- :img-size="40"
- class="avatar-container project-avatar"
- />
- </div>
- <identicon
- v-else
- :entity-id="currentProject.id"
- :entity-name="currentProject.name"
- size-class="s40"
+ </div>
+ </template>
+ <template v-else>
+ <ide-project-header
+ :project="currentProject"
+ />
+ <div class="ide-context-body d-flex flex-fill">
+ <activity-bar />
+ <div class="multi-file-commit-panel-inner">
+ <div class="multi-file-commit-panel-inner-content">
+ <component
+ :is="currentActivityView"
/>
- <div class="ide-sidebar-project-title">
- <div class="sidebar-context-title">
- {{ currentProject.name }}
- </div>
- <div class="d-flex">
- <div
- v-tooltip
- v-if="currentBranchId"
- ref="branchId"
- :title="branchTooltipTitle"
- class="sidebar-context-title ide-sidebar-branch-title"
- >
- <icon
- name="branch"
- css-classes="append-right-5"
- />{{ currentBranchId }}
- </div>
- <div
- v-if="currentMergeRequestId"
- :class="{
- 'prepend-left-8': currentBranchId
- }"
- class="sidebar-context-title ide-sidebar-branch-title"
- >
- <icon
- name="git-merge"
- css-classes="append-right-5"
- />!{{ currentMergeRequestId }}
- </div>
- </div>
- </div>
- <icon
- class="ml-auto"
- name="chevron-down"
- />
- </button>
- <merge-request-dropdown
- :show="showMergeRequestsDropdown"
- />
- </div>
- <div class="multi-file-commit-panel-inner-scroll">
- <component
- :is="currentActivityView"
- />
+ </div>
+ <commit-form />
</div>
- <commit-form />
- </template>
- </div>
+ </div>
+ </template>
</resizable-panel>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index e996dd9059e..39d46a91731 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -35,14 +35,13 @@ export default {
<template>
<ide-tree-list
- header-class="d-flex w-100"
viewer-type="editor"
>
<template
slot="header"
>
{{ __('Edit') }}
- <div class="ml-auto d-flex">
+ <div class="ide-tree-actions ml-auto d-flex">
<new-entry-button
:label="__('New file')"
:show-label="false"
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 2e7226b727c..5611b37be7c 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -3,14 +3,14 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import RepoFile from './repo_file.vue';
-import NewDropdown from './new_dropdown/index.vue';
+import NavDropdown from './nav_dropdown.vue';
export default {
components: {
Icon,
RepoFile,
SkeletonLoadingContainer,
- NewDropdown,
+ NavDropdown,
},
props: {
viewerType: {
@@ -57,14 +57,19 @@ export default {
:class="headerClass"
class="ide-tree-header"
>
+ <nav-dropdown />
<slot name="header"></slot>
</header>
- <repo-file
- v-for="file in currentTree.tree"
- :key="file.key"
- :file="file"
- :level="0"
- />
+ <div
+ class="ide-tree-body"
+ >
+ <repo-file
+ v-for="file in currentTree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ />
+ </div>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/merge_requests/dropdown.vue b/app/assets/javascripts/ide/components/merge_requests/dropdown.vue
deleted file mode 100644
index 4b9824bf04b..00000000000
--- a/app/assets/javascripts/ide/components/merge_requests/dropdown.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-<script>
-import { mapGetters } from 'vuex';
-import Tabs from '../../../vue_shared/components/tabs/tabs';
-import Tab from '../../../vue_shared/components/tabs/tab.vue';
-import List from './list.vue';
-
-export default {
- components: {
- Tabs,
- Tab,
- List,
- },
- props: {
- show: {
- type: Boolean,
- required: true,
- },
- },
- computed: {
- ...mapGetters('mergeRequests', ['assignedData', 'createdData']),
- createdMergeRequestLength() {
- return this.createdData.mergeRequests.length;
- },
- assignedMergeRequestLength() {
- return this.assignedData.mergeRequests.length;
- },
- },
-};
-</script>
-
-<template>
- <div class="dropdown-menu ide-merge-requests-dropdown p-0">
- <tabs
- v-if="show"
- stop-propagation
- >
- <tab active>
- <template slot="title">
- {{ __('Created by me') }}
- <span class="badge badge-pill">
- {{ createdMergeRequestLength }}
- </span>
- </template>
- <list
- :empty-text="__('You have not created any merge requests')"
- type="created"
- />
- </tab>
- <tab>
- <template slot="title">
- {{ __('Assigned to me') }}
- <span class="badge badge-pill">
- {{ assignedMergeRequestLength }}
- </span>
- </template>
- <list
- :empty-text="__('You do not have any assigned merge requests')"
- type="assigned"
- />
- </tab>
- </tabs>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue
index 4e18376bd48..0c4ea80ba08 100644
--- a/app/assets/javascripts/ide/components/merge_requests/item.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/item.vue
@@ -1,5 +1,6 @@
<script>
import Icon from '../../../vue_shared/components/icon.vue';
+import router from '../../ide_router';
export default {
components: {
@@ -29,22 +30,21 @@ export default {
pathWithID() {
return `${this.item.projectPathWithNamespace}!${this.item.iid}`;
},
- },
- methods: {
- clickItem() {
- this.$emit('click', this.item);
+ mergeRequestHref() {
+ const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`;
+
+ return router.resolve(path).href;
},
},
};
</script>
<template>
- <button
- type="button"
+ <a
+ :href="mergeRequestHref"
class="btn-link d-flex align-items-center"
- @click="clickItem"
>
- <span class="d-flex append-right-default ide-merge-request-current-icon">
+ <span class="d-flex append-right-default ide-search-list-current-icon">
<icon
v-if="isActive"
:size="18"
@@ -59,5 +59,5 @@ export default {
{{ pathWithID }}
</span>
</span>
- </button>
+ </a>
</template>
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index 19d3e48ee10..fc612956688 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -1,96 +1,101 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
-import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Item from './item.vue';
+import TokenedInput from '../shared/tokened_input.vue';
+
+const SEARCH_TYPES = [
+ { type: 'created', label: __('Created by me') },
+ { type: 'assigned', label: __('Assigned to me') },
+];
export default {
components: {
LoadingIcon,
+ TokenedInput,
Item,
- },
- props: {
- type: {
- type: String,
- required: true,
- },
- emptyText: {
- type: String,
- required: true,
- },
+ Icon,
},
data() {
return {
search: '',
+ currentSearchType: null,
+ hasSearchFocus: false,
};
},
computed: {
- ...mapGetters('mergeRequests', ['getData']),
+ ...mapState('mergeRequests', ['mergeRequests', 'isLoading']),
...mapState(['currentMergeRequestId', 'currentProjectId']),
- data() {
- return this.getData(this.type);
- },
- isLoading() {
- return this.data.isLoading;
- },
- mergeRequests() {
- return this.data.mergeRequests;
- },
hasMergeRequests() {
return this.mergeRequests.length !== 0;
},
hasNoSearchResults() {
return this.search !== '' && !this.hasMergeRequests;
},
+ showSearchTypes() {
+ return this.hasSearchFocus && !this.search && !this.currentSearchType;
+ },
+ type() {
+ return this.currentSearchType
+ ? this.currentSearchType.type
+ : '';
+ },
+ searchTokens() {
+ return this.currentSearchType
+ ? [this.currentSearchType]
+ : [];
+ },
},
watch: {
- isLoading: {
- handler: 'focusSearch',
+ search() {
+ // When the search is updated, let's turn off this flag to hide the search types
+ this.hasSearchFocus = false;
},
},
mounted() {
this.loadMergeRequests();
},
methods: {
- ...mapActions('mergeRequests', ['fetchMergeRequests', 'openMergeRequest']),
+ ...mapActions('mergeRequests', ['fetchMergeRequests']),
loadMergeRequests() {
this.fetchMergeRequests({ type: this.type, search: this.search });
},
- viewMergeRequest(item) {
- this.openMergeRequest({
- projectPath: item.projectPathWithNamespace,
- id: item.iid,
- });
- },
searchMergeRequests: _.debounce(function debounceSearch() {
this.loadMergeRequests();
}, 250),
- focusSearch() {
- if (!this.isLoading) {
- this.$nextTick(() => {
- this.$refs.searchInput.focus();
- });
- }
+ onSearchFocus() {
+ this.hasSearchFocus = true;
+ },
+ setSearchType(searchType) {
+ this.currentSearchType = searchType;
+ this.loadMergeRequests();
},
},
+ searchTypes: SEARCH_TYPES,
};
</script>
<template>
<div>
<div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
- <input
- ref="searchInput"
- :placeholder="__('Search merge requests')"
- v-model="search"
- type="search"
- class="dropdown-input-field"
- @input="searchMergeRequests"
- />
- <i
- aria-hidden="true"
- class="fa fa-search dropdown-input-search"
- ></i>
+ <div class="position-relative">
+ <tokened-input
+ v-model="search"
+ :tokens="searchTokens"
+ :placeholder="__('Search merge requests')"
+ @focus="onSearchFocus"
+ @input="searchMergeRequests"
+ @removeToken="setSearchType(null)"
+ />
+ <icon
+ :size="18"
+ name="search"
+ class="input-icon"
+ />
+ </div>
</div>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<loading-icon
@@ -98,35 +103,52 @@ export default {
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
size="2"
/>
- <ul
- v-else
- class="mb-3 w-100"
- >
- <template v-if="hasMergeRequests">
- <li
- v-for="item in mergeRequests"
- :key="item.id"
- >
- <item
- :item="item"
- :current-id="currentMergeRequestId"
- :current-project-id="currentProjectId"
- @click="viewMergeRequest"
- />
- </li>
- </template>
- <li
- v-else
- class="ide-merge-requests-empty d-flex align-items-center justify-content-center"
+ <template v-else>
+ <ul
+ class="mb-3 w-100"
>
- <template v-if="hasNoSearchResults">
- {{ __('No merge requests found') }}
+ <template v-if="showSearchTypes">
+ <li
+ v-for="searchType in $options.searchTypes"
+ :key="searchType.type"
+ >
+ <button
+ type="button"
+ class="btn-link d-flex align-items-center"
+ @click.stop="setSearchType(searchType)"
+ >
+ <span class="d-flex append-right-default ide-search-list-current-icon">
+ <icon
+ :size="18"
+ name="search"
+ />
+ </span>
+ <span>
+ {{ searchType.label }}
+ </span>
+ </button>
+ </li>
</template>
- <template v-else>
- {{ emptyText }}
+ <template v-else-if="hasMergeRequests">
+ <li
+ v-for="item in mergeRequests"
+ :key="item.id"
+ >
+ <item
+ :item="item"
+ :current-id="currentMergeRequestId"
+ :current-project-id="currentProjectId"
+ />
+ </li>
</template>
- </li>
- </ul>
+ <li
+ v-else
+ class="ide-search-list-empty d-flex align-items-center justify-content-center"
+ >
+ {{ __('No merge requests found') }}
+ </li>
+ </ul>
+ </template>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue
new file mode 100644
index 00000000000..db36779c395
--- /dev/null
+++ b/app/assets/javascripts/ide/components/nav_dropdown.vue
@@ -0,0 +1,59 @@
+<script>
+import $ from 'jquery';
+import Icon from '~/vue_shared/components/icon.vue';
+import NavForm from './nav_form.vue';
+import NavDropdownButton from './nav_dropdown_button.vue';
+
+export default {
+ components: {
+ Icon,
+ NavDropdownButton,
+ NavForm,
+ },
+ data() {
+ return {
+ isVisibleDropdown: false,
+ };
+ },
+ mounted() {
+ this.addDropdownListeners();
+ },
+ beforeDestroy() {
+ this.removeDropdownListeners();
+ },
+ methods: {
+ addDropdownListeners() {
+ $(this.$refs.dropdown)
+ .on('show.bs.dropdown', () => this.showDropdown())
+ .on('hide.bs.dropdown', () => this.hideDropdown());
+ },
+ removeDropdownListeners() {
+ $(this.$refs.dropdown)
+ .off('show.bs.dropdown')
+ .off('hide.bs.dropdown');
+ },
+ showDropdown() {
+ this.isVisibleDropdown = true;
+ },
+ hideDropdown() {
+ this.isVisibleDropdown = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="dropdown"
+ class="btn-group ide-nav-dropdown dropdown"
+ >
+ <nav-dropdown-button />
+ <div
+ class="dropdown-menu dropdown-menu-left p-0"
+ >
+ <nav-form
+ v-if="isVisibleDropdown"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
new file mode 100644
index 00000000000..7f98769d484
--- /dev/null
+++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
@@ -0,0 +1,54 @@
+<script>
+import { mapState } from 'vuex';
+import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+const EMPTY_LABEL = '-';
+
+export default {
+ components: {
+ Icon,
+ DropdownButton,
+ },
+ computed: {
+ ...mapState(['currentBranchId', 'currentMergeRequestId']),
+ mergeRequestLabel() {
+ return this.currentMergeRequestId
+ ? `!${this.currentMergeRequestId}`
+ : EMPTY_LABEL;
+ },
+ branchLabel() {
+ return this.currentBranchId || EMPTY_LABEL;
+ },
+ },
+};
+</script>
+
+<template>
+ <dropdown-button>
+ <span
+ class="row"
+ >
+ <span
+ class="col-7 text-truncate"
+ >
+ <icon
+ :size="16"
+ :aria-label="__('Current Branch')"
+ name="branch"
+ />
+ {{ branchLabel }}
+ </span>
+ <span
+ class="col-5 pl-0 text-truncate"
+ >
+ <icon
+ :size="16"
+ :aria-label="__('Merge Request')"
+ name="merge-request"
+ />
+ {{ mergeRequestLabel }}
+ </span>
+ </span>
+ </dropdown-button>
+</template>
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
new file mode 100644
index 00000000000..718b836e11c
--- /dev/null
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -0,0 +1,40 @@
+<script>
+import Tabs from '~/vue_shared/components/tabs/tabs';
+import Tab from '~/vue_shared/components/tabs/tab.vue';
+import BranchesSearchList from './branches/search_list.vue';
+import MergeRequestSearchList from './merge_requests/list.vue';
+
+export default {
+ components: {
+ Tabs,
+ Tab,
+ BranchesSearchList,
+ MergeRequestSearchList,
+ },
+};
+</script>
+
+<template>
+ <div
+ class="ide-nav-form p-0"
+ >
+ <tabs
+ stop-propagation
+ >
+ <tab
+ active
+ >
+ <template slot="title">
+ {{ __('Merge Requests') }}
+ </template>
+ <merge-request-search-list />
+ </tab>
+ <tab>
+ <template slot="title">
+ {{ __('Branches') }}
+ </template>
+ <branches-search-list />
+ </tab>
+ </tabs>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index e4a5fcc67c4..79df225c432 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants';
@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue';
+import Clientside from '../preview/clientside.vue';
export default {
directives: {
@@ -18,15 +19,20 @@ export default {
JobsDetail,
ResizablePanel,
MergeRequestInfo,
+ Clientside,
},
computed: {
- ...mapState(['rightPane', 'currentMergeRequestId']),
+ ...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']),
+ ...mapGetters(['packageJson']),
pipelinesActive() {
return (
this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail
);
},
+ showLivePreview() {
+ return this.packageJson && this.clientsidePreviewEnabled;
+ },
},
methods: {
...mapActions(['setRightPane']),
@@ -49,8 +55,9 @@ export default {
:collapsible="false"
:initial-width="350"
:min-size="350"
- class="multi-file-commit-panel-inner"
+ :class="`ide-right-sidebar-${rightPane}`"
side="right"
+ class="multi-file-commit-panel-inner"
>
<component :is="rightPane" />
</resizable-panel>
@@ -98,6 +105,26 @@ export default {
/>
</button>
</li>
+ <li v-if="showLivePreview">
+ <button
+ v-tooltip
+ :title="__('Live preview')"
+ :aria-label="__('Live preview')"
+ :class="{
+ active: rightPane === $options.rightSidebarViews.clientSidePreview
+ }"
+ data-container="body"
+ data-placement="left"
+ class="ide-sidebar-link is-right"
+ type="button"
+ @click="clickTab($event, $options.rightSidebarViews.clientSidePreview)"
+ >
+ <icon
+ :size="16"
+ name="live-preview"
+ />
+ </button>
+ </li>
</ul>
</nav>
</div>
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
new file mode 100644
index 00000000000..fef36eae7b1
--- /dev/null
+++ b/app/assets/javascripts/ide/components/preview/clientside.vue
@@ -0,0 +1,171 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import _ from 'underscore';
+import { Manager } from 'smooshpack';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import Navigator from './navigator.vue';
+import { packageJsonPath } from '../../constants';
+import { createPathWithExt } from '../../utils';
+
+export default {
+ components: {
+ LoadingIcon,
+ Navigator,
+ },
+ data() {
+ return {
+ manager: {},
+ loading: false,
+ };
+ },
+ computed: {
+ ...mapState(['entries', 'promotionSvgPath', 'links']),
+ ...mapGetters(['packageJson', 'currentProject']),
+ normalizedEntries() {
+ return Object.keys(this.entries).reduce((acc, path) => {
+ const file = this.entries[path];
+
+ if (file.type === 'tree' || !(file.raw || file.content)) return acc;
+
+ return {
+ ...acc,
+ [`/${path}`]: {
+ code: file.content || file.raw,
+ },
+ };
+ }, {});
+ },
+ mainEntry() {
+ if (!this.packageJson.raw) return false;
+
+ const parsedPackage = JSON.parse(this.packageJson.raw);
+
+ return parsedPackage.main;
+ },
+ showPreview() {
+ return this.mainEntry && !this.loading;
+ },
+ showEmptyState() {
+ return !this.mainEntry && !this.loading;
+ },
+ showOpenInCodeSandbox() {
+ return this.currentProject && this.currentProject.visibility === 'public';
+ },
+ sandboxOpts() {
+ return {
+ files: { ...this.normalizedEntries },
+ entry: `/${this.mainEntry}`,
+ showOpenInCodeSandbox: this.showOpenInCodeSandbox,
+ };
+ },
+ },
+ watch: {
+ entries: {
+ deep: true,
+ handler: 'update',
+ },
+ },
+ mounted() {
+ this.loading = true;
+
+ return this.loadFileContent(packageJsonPath)
+ .then(() => {
+ this.loading = false;
+ })
+ .then(() => this.$nextTick())
+ .then(() => this.initPreview());
+ },
+ beforeDestroy() {
+ if (!_.isEmpty(this.manager)) {
+ this.manager.listener();
+ }
+ this.manager = {};
+
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ },
+ methods: {
+ ...mapActions(['getFileData', 'getRawFileData']),
+ loadFileContent(path) {
+ return this.getFileData({ path, makeFileActive: false }).then(() =>
+ this.getRawFileData({ path }),
+ );
+ },
+ initPreview() {
+ if (!this.mainEntry) return null;
+
+ return this.loadFileContent(this.mainEntry)
+ .then(() => this.$nextTick())
+ .then(() =>
+ this.initManager('#ide-preview', this.sandboxOpts, {
+ fileResolver: {
+ isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]),
+ readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content),
+ },
+ }),
+ );
+ },
+ update() {
+ if (this.timeout) return;
+
+ this.timeout = setTimeout(() => {
+ if (_.isEmpty(this.manager)) {
+ this.initPreview();
+
+ return;
+ }
+
+ this.manager.updatePreview(this.sandboxOpts);
+
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }, 500);
+ },
+ initManager(el, opts, resolver) {
+ this.manager = new Manager(el, opts, resolver);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="preview h-100 w-100 d-flex flex-column">
+ <template v-if="showPreview">
+ <navigator
+ :manager="manager"
+ />
+ <div id="ide-preview"></div>
+ </template>
+ <div
+ v-else-if="showEmptyState"
+ v-once
+ class="d-flex h-100 flex-column align-items-center justify-content-center svg-content"
+ >
+ <img
+ :src="promotionSvgPath"
+ :alt="s__('IDE|Live Preview')"
+ width="130"
+ height="100"
+ />
+ <h3>
+ {{ s__('IDE|Live Preview') }}
+ </h3>
+ <p class="text-center">
+ {{ s__('IDE|Preview your web application using Web IDE client-side evaluation.') }}
+ </p>
+ <a
+ :href="links.webIDEHelpPagePath"
+ class="btn btn-primary"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ s__('IDE|Get started with Live Preview') }}
+ </a>
+ </div>
+ <loading-icon
+ v-else
+ size="2"
+ class="align-self-center mt-auto mb-auto"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
new file mode 100644
index 00000000000..4bf346946b6
--- /dev/null
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -0,0 +1,147 @@
+<script>
+import { listen } from 'codesandbox-api';
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+
+export default {
+ components: {
+ Icon,
+ LoadingIcon,
+ },
+ props: {
+ manager: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentBrowsingIndex: null,
+ navigationStack: [],
+ forwardNavigationStack: [],
+ path: '',
+ loading: true,
+ };
+ },
+ computed: {
+ backButtonDisabled() {
+ return this.navigationStack.length <= 1;
+ },
+ forwardButtonDisabled() {
+ return !this.forwardNavigationStack.length;
+ },
+ },
+ mounted() {
+ this.listener = listen(e => {
+ switch (e.type) {
+ case 'urlchange':
+ this.onUrlChange(e);
+ break;
+ case 'done':
+ this.loading = false;
+ break;
+ default:
+ break;
+ }
+ });
+ },
+ beforeDestroy() {
+ this.listener();
+ },
+ methods: {
+ onUrlChange(e) {
+ const lastPath = this.path;
+
+ this.path = e.url.replace(this.manager.bundlerURL, '') || '/';
+
+ if (lastPath !== this.path) {
+ this.currentBrowsingIndex =
+ this.currentBrowsingIndex === null ? 0 : this.currentBrowsingIndex + 1;
+ this.navigationStack.push(this.path);
+ }
+ },
+ back() {
+ const lastPath = this.path;
+
+ this.visitPath(this.navigationStack[this.currentBrowsingIndex - 1]);
+
+ this.forwardNavigationStack.push(lastPath);
+
+ if (this.currentBrowsingIndex === 1) {
+ this.currentBrowsingIndex = null;
+ this.navigationStack = [];
+ }
+ },
+ forward() {
+ this.visitPath(this.forwardNavigationStack.splice(0, 1)[0]);
+ },
+ refresh() {
+ this.visitPath(this.path);
+ },
+ visitPath(path) {
+ this.manager.iframe.src = `${this.manager.bundlerURL}${path}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <header class="ide-preview-header d-flex align-items-center">
+ <button
+ :aria-label="s__('IDE|Back')"
+ :disabled="backButtonDisabled"
+ :class="{
+ 'disabled-content': backButtonDisabled
+ }"
+ type="button"
+ class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
+ @click="back"
+ >
+ <icon
+ :size="24"
+ name="chevron-left"
+ class="m-auto"
+ />
+ </button>
+ <button
+ :aria-label="s__('IDE|Back')"
+ :disabled="forwardButtonDisabled"
+ :class="{
+ 'disabled-content': forwardButtonDisabled
+ }"
+ type="button"
+ class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
+ @click="forward"
+ >
+ <icon
+ :size="24"
+ name="chevron-right"
+ class="m-auto"
+ />
+ </button>
+ <button
+ :aria-label="s__('IDE|Refresh preview')"
+ type="button"
+ class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
+ @click="refresh"
+ >
+ <icon
+ :size="18"
+ name="retry"
+ class="m-auto"
+ />
+ </button>
+ <div class="position-relative w-100 prepend-left-4">
+ <input
+ :value="path || '/'"
+ type="text"
+ class="ide-navigator-location form-control bg-white"
+ readonly
+ />
+ <loading-icon
+ v-if="loading"
+ class="position-absolute ide-preview-loading-icon"
+ />
+ </div>
+ </header>
+</template>
diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue
new file mode 100644
index 00000000000..a7a12f6785d
--- /dev/null
+++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue
@@ -0,0 +1,121 @@
+<script>
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ placeholder: {
+ type: String,
+ required: false,
+ default: __('Search'),
+ },
+ tokens: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ backspaceCount: 0,
+ };
+ },
+ computed: {
+ placeholderText() {
+ return this.tokens.length
+ ? ''
+ : this.placeholder;
+ },
+ },
+ watch: {
+ tokens() {
+ this.$refs.input.focus();
+ },
+ },
+ methods: {
+ onFocus() {
+ this.$emit('focus');
+ },
+ onBlur() {
+ this.$emit('blur');
+ },
+ onInput(evt) {
+ this.$emit('input', evt.target.value);
+ },
+ onBackspace() {
+ if (!this.value && this.tokens.length) {
+ this.backspaceCount += 1;
+ } else {
+ this.backspaceCount = 0;
+ return;
+ }
+
+ if (this.backspaceCount > 1) {
+ this.removeToken(this.tokens[this.tokens.length - 1]);
+ this.backspaceCount = 0;
+ }
+ },
+ removeToken(token) {
+ this.$emit('removeToken', token);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="filtered-search-wrapper">
+ <div class="filtered-search-box">
+ <div class="tokens-container list-unstyled">
+ <div
+ v-for="token in tokens"
+ :key="token.label"
+ class="filtered-search-token"
+ >
+ <button
+ class="selectable btn-blank"
+ type="button"
+ @click.stop="removeToken(token)"
+ @keyup.delete="removeToken(token)"
+ >
+ <div
+ class="value-container rounded"
+ >
+ <div
+ class="value"
+ >{{ token.label }}</div>
+ <div
+ class="remove-token inverted"
+ >
+ <icon
+ :size="10"
+ name="close"
+ />
+ </div>
+ </div>
+ </button>
+ </div>
+ <div class="input-token">
+ <input
+ ref="input"
+ :placeholder="placeholderText"
+ :value="value"
+ type="search"
+ class="form-control filtered-search"
+ @input="onInput"
+ @focus="onFocus"
+ @blur="onBlur"
+ @keyup.delete="onBackspace"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index d3ac57471c9..8caa5b86a9b 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -32,6 +32,7 @@ export const rightSidebarViews = {
pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info',
+ clientSidePreview: 'clientside',
};
export const stageKeys = {
@@ -58,3 +59,5 @@ export const modalTypes = {
rename: 'rename',
tree: 'tree',
};
+
+export const packageJsonPath = 'package.json';
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 2d74192e6b3..79e38ae911e 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue';
import store from './stores';
import router from './ide_router';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
Vue.use(Translate);
@@ -23,13 +24,18 @@ export function initIde(el) {
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
+ promotionSvgPath: el.dataset.promotionSvgPath,
});
this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath,
+ webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
+ });
+ this.setInitialData({
+ clientsidePreviewEnabled: convertPermissionToBoolean(el.dataset.clientsidePreviewEnabled),
});
},
methods: {
- ...mapActions(['setEmptyStateSvgs', 'setLinks']),
+ ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
},
render(createElement) {
return createElement('ide');
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 79cdb494e5a..709748fb530 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -1,5 +1,5 @@
import { getChangesCountForFiles, filePathMatches } from './utils';
-import { activityBarViews } from '../constants';
+import { activityBarViews, packageJsonPath } from '../constants';
export const activeFile = state => state.openFiles.find(file => file.active) || null;
@@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => {
export const currentBranch = (state, getters) =>
getters.currentProject && getters.currentProject.branches[state.currentBranchId];
+export const packageJson = state => state.entries[packageJsonPath];
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index f8ce8a67ec0..a601dc8f5a0 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -7,6 +7,7 @@ import mutations from './mutations';
import commitModule from './modules/commit';
import pipelines from './modules/pipelines';
import mergeRequests from './modules/merge_requests';
+import branches from './modules/branches';
Vue.use(Vuex);
@@ -20,6 +21,7 @@ export const createStore = () =>
commit: commitModule,
pipelines,
mergeRequests,
+ branches,
},
});
diff --git a/app/assets/javascripts/ide/stores/modules/branches/actions.js b/app/assets/javascripts/ide/stores/modules/branches/actions.js
new file mode 100644
index 00000000000..74aa98ef9f9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/branches/actions.js
@@ -0,0 +1,39 @@
+import { __ } from '~/locale';
+import Api from '~/api';
+import * as types from './mutation_types';
+
+export const requestBranches = ({ commit }) => commit(types.REQUEST_BRANCHES);
+export const receiveBranchesError = ({ commit, dispatch }, { search }) => {
+ dispatch(
+ 'setErrorMessage',
+ {
+ text: __('Error loading branches.'),
+ action: payload =>
+ dispatch('fetchBranches', payload).then(() =>
+ dispatch('setErrorMessage', null, { root: true }),
+ ),
+ actionText: __('Please try again'),
+ actionPayload: { search },
+ },
+ { root: true },
+ );
+ commit(types.RECEIVE_BRANCHES_ERROR);
+};
+export const receiveBranchesSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_BRANCHES_SUCCESS, data);
+
+export const fetchBranches = ({ dispatch, rootGetters }, { search = '' }) => {
+ dispatch('requestBranches');
+ dispatch('resetBranches');
+
+ return Api.branches(rootGetters.currentProject.id, search, { sort: 'updated_desc' })
+ .then(({ data }) => dispatch('receiveBranchesSuccess', data))
+ .catch(() => dispatch('receiveBranchesError', { search }));
+};
+
+export const resetBranches = ({ commit }) => commit(types.RESET_BRANCHES);
+
+export const openBranch = ({ rootState, dispatch }, id) =>
+ dispatch('goToRoute', `/project/${rootState.currentProjectId}/edit/${id}`, { root: true });
+
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/branches/index.js b/app/assets/javascripts/ide/stores/modules/branches/index.js
new file mode 100644
index 00000000000..04e7e0f08f1
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/branches/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ state: state(),
+ actions,
+ mutations,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/branches/mutation_types.js b/app/assets/javascripts/ide/stores/modules/branches/mutation_types.js
new file mode 100644
index 00000000000..2272f7b9531
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/branches/mutation_types.js
@@ -0,0 +1,5 @@
+export const REQUEST_BRANCHES = 'REQUEST_BRANCHES';
+export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR';
+export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
+
+export const RESET_BRANCHES = 'RESET_BRANCHES';
diff --git a/app/assets/javascripts/ide/stores/modules/branches/mutations.js b/app/assets/javascripts/ide/stores/modules/branches/mutations.js
new file mode 100644
index 00000000000..081ec2d4c28
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/branches/mutations.js
@@ -0,0 +1,21 @@
+/* eslint-disable no-param-reassign */
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_BRANCHES](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_BRANCHES_ERROR](state) {
+ state.isLoading = false;
+ },
+ [types.RECEIVE_BRANCHES_SUCCESS](state, data) {
+ state.isLoading = false;
+ state.branches = data.map(branch => ({
+ name: branch.name,
+ committedDate: branch.commit.committed_date,
+ }));
+ },
+ [types.RESET_BRANCHES](state) {
+ state.branches = [];
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/branches/state.js b/app/assets/javascripts/ide/stores/modules/branches/state.js
new file mode 100644
index 00000000000..89bf220c45f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/branches/state.js
@@ -0,0 +1,4 @@
+export default () => ({
+ isLoading: false,
+ branches: [],
+});
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
index 6ef938b0ae2..baa2497ec5b 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
@@ -1,12 +1,10 @@
import { __ } from '../../../../locale';
import Api from '../../../../api';
-import router from '../../../ide_router';
import { scopes } from './constants';
import * as types from './mutation_types';
-import * as rootTypes from '../../mutation_types';
-export const requestMergeRequests = ({ commit }, type) =>
- commit(types.REQUEST_MERGE_REQUESTS, type);
+export const requestMergeRequests = ({ commit }) =>
+ commit(types.REQUEST_MERGE_REQUESTS);
export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }) => {
dispatch(
'setErrorMessage',
@@ -21,39 +19,22 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }
},
{ root: true },
);
- commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type);
+ commit(types.RECEIVE_MERGE_REQUESTS_ERROR);
};
-export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) =>
- commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, { type, data });
+export const receiveMergeRequestsSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data);
export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => {
- const scope = scopes[type];
- dispatch('requestMergeRequests', type);
- dispatch('resetMergeRequests', type);
+ dispatch('requestMergeRequests');
+ dispatch('resetMergeRequests');
+
+ const scope = type ? scopes[type] : 'all';
return Api.mergeRequests({ scope, state, search })
- .then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data }))
+ .then(({ data }) => dispatch('receiveMergeRequestsSuccess', data))
.catch(() => dispatch('receiveMergeRequestsError', { type, search }));
};
-export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type);
-
-export const openMergeRequest = ({ commit, dispatch }, { projectPath, id }) => {
- commit(rootTypes.CLEAR_PROJECTS, null, { root: true });
- commit(rootTypes.SET_CURRENT_MERGE_REQUEST, `${id}`, { root: true });
- commit(rootTypes.RESET_OPEN_FILES, null, { root: true });
- dispatch('setCurrentBranchId', '', { root: true });
- dispatch('pipelines/stopPipelinePolling', null, { root: true })
- .then(() => {
- dispatch('pipelines/resetLatestPipeline', null, { root: true });
- dispatch('pipelines/clearEtagPoll', null, { root: true });
- })
- .catch(e => {
- throw e;
- });
- dispatch('setRightPane', null, { root: true });
-
- router.push(`/project/${projectPath}/merge_requests/${id}`);
-};
+export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS);
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js b/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js
deleted file mode 100644
index 8e2b234be8d..00000000000
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const getData = state => type => state[type];
-
-export const assignedData = state => state.assigned;
-export const createdData = state => state.created;
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/index.js b/app/assets/javascripts/ide/stores/modules/merge_requests/index.js
index 2e6dfb420f4..04e7e0f08f1 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/index.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/index.js
@@ -1,6 +1,5 @@
import state from './state';
import * as actions from './actions';
-import * as getters from './getters';
import mutations from './mutations';
export default {
@@ -8,5 +7,4 @@ export default {
state: state(),
actions,
mutations,
- getters,
};
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js
index 971da0806bd..98102a68e08 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js
@@ -2,15 +2,15 @@
import * as types from './mutation_types';
export default {
- [types.REQUEST_MERGE_REQUESTS](state, type) {
- state[type].isLoading = true;
+ [types.REQUEST_MERGE_REQUESTS](state) {
+ state.isLoading = true;
},
- [types.RECEIVE_MERGE_REQUESTS_ERROR](state, type) {
- state[type].isLoading = false;
+ [types.RECEIVE_MERGE_REQUESTS_ERROR](state) {
+ state.isLoading = false;
},
- [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { type, data }) {
- state[type].isLoading = false;
- state[type].mergeRequests = data.map(mergeRequest => ({
+ [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, data) {
+ state.isLoading = false;
+ state.mergeRequests = data.map(mergeRequest => ({
id: mergeRequest.id,
iid: mergeRequest.iid,
title: mergeRequest.title,
@@ -20,7 +20,7 @@ export default {
.replace(`/merge_requests/${mergeRequest.iid}`, ''),
}));
},
- [types.RESET_MERGE_REQUESTS](state, type) {
- state[type].mergeRequests = [];
+ [types.RESET_MERGE_REQUESTS](state) {
+ state.mergeRequests = [];
},
};
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
index 57eb6b04283..4748ccfa2e6 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
@@ -1,13 +1,7 @@
import { states } from './constants';
export default () => ({
- created: {
- isLoading: false,
- mergeRequests: [],
- },
- assigned: {
- isLoading: false,
- mergeRequests: [],
- },
+ isLoading: false,
+ mergeRequests: [],
state: states.opened,
});
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index d0bf847dbde..1eda5768709 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -115,13 +115,20 @@ export default {
},
[types.SET_EMPTY_STATE_SVGS](
state,
- { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath },
+ {
+ emptyStateSvgPath,
+ noChangesStateSvgPath,
+ committedStateSvgPath,
+ pipelinesEmptyStateSvgPath,
+ promotionSvgPath,
+ },
) {
Object.assign(state, {
emptyStateSvgPath,
noChangesStateSvgPath,
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
+ promotionSvgPath,
});
},
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index c75add39bcd..a937fb157f8 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -44,7 +44,7 @@ export default {
rawPath: data.raw_path,
binary: data.binary,
renderError: data.render_error,
- raw: null,
+ raw: (state.entries[file.path] && state.entries[file.path].raw) || null,
baseRaw: null,
html: data.html,
size: data.size,
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 2371b201f8c..46b52fa00fc 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -31,4 +31,5 @@ export default () => ({
path: '',
entry: {},
},
+ clientsidePreviewEnabled: false,
});
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 92b15cf232d..d895eca7af0 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -1,6 +1,5 @@
import { commitItemIconMap } from './constants';
-// eslint-disable-next-line import/prefer-default-export
export const getCommitIconMap = file => {
if (file.deleted) {
return commitItemIconMap.deleted;
@@ -10,3 +9,9 @@ export const getCommitIconMap = file => {
return commitItemIconMap.modified;
};
+
+export const createPathWithExt = p => {
+ const ext = p.lastIndexOf('.') >= 0 ? p.substring(p.lastIndexOf('.') + 1) : '';
+
+ return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`;
+};
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index f9ff0722c01..0035d809062 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -36,6 +36,8 @@ class ImporterStatus {
const $targetField = $tr.find('.import-target');
const $namespaceInput = $targetField.find('.js-select-namespace option:selected');
const id = $tr.attr('id').replace('repo_', '');
+ const repoData = $tr.data();
+
let targetNamespace;
let newName;
if ($namespaceInput.length > 0) {
@@ -45,12 +47,20 @@ class ImporterStatus {
}
$btn.disable().addClass('is-loading');
- return axios.post(this.importUrl, {
+ this.id = id;
+
+ let attributes = {
repo_id: id,
target_namespace: targetNamespace,
new_name: newName,
ci_cd_only: this.ciCdOnly,
- })
+ };
+
+ if (repoData) {
+ attributes = Object.assign(repoData, attributes);
+ }
+
+ return axios.post(this.importUrl, attributes)
.then(({ data }) => {
const job = $(`tr#repo_${id}`);
job.attr('id', `project_${data.id}`);
@@ -70,6 +80,9 @@ class ImporterStatus {
.catch((error) => {
let details = error;
+ const $statusField = $(`#repo_${this.id} .job-status`);
+ $statusField.text(__('Failed'));
+
if (error.response && error.response.data && error.response.data.errors) {
details = error.response.data.errors;
}
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 37a45d1d1a2..cb851ff6745 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -39,7 +39,7 @@ export default class LabelsSelect {
showNo = $dropdown.data('showNo');
showAny = $dropdown.data('showAny');
showMenuAbove = $dropdown.data('showMenuAbove');
- defaultLabel = $dropdown.data('defaultLabel');
+ defaultLabel = $dropdown.data('defaultLabel') || 'Label';
abilityName = $dropdown.data('abilityName');
$selectbox = $dropdown.closest('.selectbox');
$block = $selectbox.closest('.block');
@@ -244,21 +244,21 @@ export default class LabelsSelect {
var $dropdownInputField = $dropdownParent.find('.dropdown-input-field');
var isSelected = el !== null ? el.hasClass('is-active') : false;
- var { title } = selected;
+ var title = selected ? selected.title : null;
var selectedLabels = this.selected;
if ($dropdownInputField.length && $dropdownInputField.val().length) {
$dropdownParent.find('.dropdown-input-clear').trigger('click');
}
- if (selected.id === 0) {
+ if (selected && selected.id === 0) {
this.selected = [];
return 'No Label';
}
else if (isSelected) {
this.selected.push(title);
}
- else {
+ else if (!isSelected && title) {
var index = this.selected.indexOf(title);
this.selected.splice(index, 1);
}
@@ -409,6 +409,14 @@ export default class LabelsSelect {
}
}
},
+ opened: function(e) {
+ if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ const previousSelection = $dropdown.attr('data-selected');
+ this.selected = previousSelection ? previousSelection.split(',') : [];
+ $dropdown.data('glDropdown').updateLabel();
+ }
+ },
+ preserveContext: true,
});
// Set dropdown data
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index 9482d131344..bd2212edec7 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
-export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
+export const placeholderImage =
+ 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const SCROLL_THRESHOLD = 300;
export default class LazyLoader {
@@ -18,11 +19,17 @@ export default class LazyLoader {
scrollContainer.addEventListener('load', () => this.loadCheck());
}
searchLazyImages() {
- this.lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
+ const that = this;
+ requestIdleCallback(
+ () => {
+ that.lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
- if (this.lazyImages.length) {
- this.checkElementsInView();
- }
+ if (that.lazyImages.length) {
+ that.checkElementsInView();
+ }
+ },
+ { timeout: 500 },
+ );
}
startContentObserver() {
const contentNode = document.querySelector(this.observerNode) || document.querySelector('body');
@@ -48,14 +55,16 @@ export default class LazyLoader {
const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD;
// Loading Images which are in the current viewport or close to them
- this.lazyImages = this.lazyImages.filter((selectedImage) => {
+ this.lazyImages = this.lazyImages.filter(selectedImage => {
if (selectedImage.getAttribute('data-src')) {
const imgBoundRect = selectedImage.getBoundingClientRect();
const imgTop = scrollTop + imgBoundRect.top;
const imgBound = imgTop + imgBoundRect.height;
if (scrollTop < imgBound && visHeight > imgTop) {
- LazyLoader.loadImage(selectedImage);
+ requestAnimationFrame(() => {
+ LazyLoader.loadImage(selectedImage);
+ });
return false;
}
@@ -66,7 +75,18 @@ export default class LazyLoader {
}
static loadImage(img) {
if (img.getAttribute('data-src')) {
- img.setAttribute('src', img.getAttribute('data-src'));
+ let imgUrl = img.getAttribute('data-src');
+ // Only adding width + height for avatars for now
+ if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
+ let targetWidth = null;
+ if (img.getAttribute('width')) {
+ targetWidth = img.getAttribute('width');
+ } else {
+ targetWidth = img.width;
+ }
+ if (targetWidth) imgUrl += `?width=${targetWidth}`;
+ }
+ img.setAttribute('src', imgUrl);
img.removeAttribute('data-src');
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 17a6d5bcd2a..6afaefc56f8 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -147,6 +147,7 @@ export default {
}
this.showEmptyState = false;
})
+ .then(this.resize)
.catch(() => {
this.state = 'unableToConnect';
});
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 6385b75e557..ad6e7cf501d 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -5,19 +5,20 @@ import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility';
-import { scrollToElement } from '../../lib/utils/common_utils';
+import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
+ mixins: [discussionNavigation],
computed: {
...mapGetters([
'getUserData',
'getNoteableData',
'discussionCount',
- 'unresolvedDiscussions',
+ 'firstUnresolvedDiscussionId',
'resolvedDiscussionCount',
]),
isLoggedIn() {
@@ -35,11 +36,6 @@ export default {
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
- firstUnresolvedDiscussionId() {
- const item = this.unresolvedDiscussions[0] || {};
-
- return item.id;
- },
},
created() {
this.resolveSvg = resolveSvg;
@@ -50,22 +46,10 @@ export default {
methods: {
...mapActions(['expandDiscussion']),
jumpToFirstUnresolvedDiscussion() {
- const discussionId = this.firstUnresolvedDiscussionId;
- if (!discussionId) {
- return;
- }
-
- const el = document.querySelector(`[data-discussion-id="${discussionId}"]`);
- const activeTab = window.mrTabs.currentAction;
-
- if (activeTab === 'commits' || activeTab === 'pipelines') {
- window.mrTabs.activateTab('show');
- }
+ const diffTab = window.mrTabs.currentAction === 'diffs';
+ const discussionId = this.firstUnresolvedDiscussionId(diffTab);
- if (el) {
- this.expandDiscussion({ discussionId });
- scrollToElement(el);
- }
+ this.jumpToDiscussion(discussionId);
},
},
};
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 26482a02e00..abcd4422d7c 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -7,7 +7,7 @@ import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
export default {
- name: 'IssueNoteForm',
+ name: 'NoteForm',
components: {
issueWarning,
markdownField,
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index bee635398b3..0fe1c16854a 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,11 +1,11 @@
<script>
-import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionsSvg from 'icons/_next_discussion.svg';
-import { convertObjectPropsToCamelCase, scrollToElement } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
import systemNote from '~/vue_shared/components/notes/system_note.vue';
+import { s__ } from '~/locale';
import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -20,6 +20,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder
import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
+import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
@@ -39,7 +40,7 @@ export default {
directives: {
tooltip,
},
- mixins: [autosave, noteable, resolvable],
+ mixins: [autosave, noteable, resolvable, discussionNavigation],
props: {
discussion: {
type: Object,
@@ -60,6 +61,11 @@ export default {
required: false,
default: false,
},
+ discussionsByDiffOrder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -74,7 +80,12 @@ export default {
'discussionCount',
'resolvedDiscussionCount',
'allDiscussions',
+ 'unresolvedDiscussionsIdsByDiff',
+ 'unresolvedDiscussionsIdsByDate',
'unresolvedDiscussions',
+ 'unresolvedDiscussionsIdsOrdered',
+ 'nextUnresolvedDiscussionId',
+ 'isLastUnresolvedDiscussion',
]),
transformedDiscussion() {
return {
@@ -125,6 +136,10 @@ export default {
hasMultipleUnresolvedDiscussions() {
return this.unresolvedDiscussions.length > 1;
},
+ showJumpToNextDiscussion() {
+ return this.hasMultipleUnresolvedDiscussions &&
+ !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder);
+ },
shouldRenderDiffs() {
const { diffDiscussion, diffFile } = this.transformedDiscussion;
@@ -144,19 +159,17 @@ export default {
return this.isDiffDiscussion ? '' : 'card discussion-wrapper';
},
},
- mounted() {
- if (this.isReplying) {
- this.initAutoSave(this.transformedDiscussion);
- }
- },
- updated() {
- if (this.isReplying) {
- if (!this.autosave) {
- this.initAutoSave(this.transformedDiscussion);
+ watch: {
+ isReplying() {
+ if (this.isReplying) {
+ this.$nextTick(() => {
+ // Pass an extra key to separate reply and note edit forms
+ this.initAutoSave(this.transformedDiscussion, ['Reply']);
+ });
} else {
- this.setAutoSave();
+ this.disposeAutoSave();
}
- }
+ },
},
created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg;
@@ -194,16 +207,18 @@ export default {
showReplyForm() {
this.isReplying = true;
},
- cancelReplyForm(shouldConfirm) {
- if (shouldConfirm && this.$refs.noteForm.isDirty) {
+ cancelReplyForm(shouldConfirm, isDirty) {
+ if (shouldConfirm && isDirty) {
+ const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
+
// eslint-disable-next-line no-alert
- if (!window.confirm('Are you sure you want to cancel creating this comment?')) {
+ if (!window.confirm(msg)) {
return;
}
}
- this.resetAutoSave();
this.isReplying = false;
+ this.resetAutoSave();
},
saveReply(noteText, form, callback) {
const postData = {
@@ -241,21 +256,10 @@ Please check your network connection and try again.`;
});
},
jumpToNextDiscussion() {
- const discussionIds = this.allDiscussions.map(d => d.id);
- const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
- const currentIndex = discussionIds.indexOf(this.discussion.id);
- const remainingAfterCurrent = discussionIds.slice(currentIndex + 1);
- const nextIndex = _.findIndex(remainingAfterCurrent, id => unresolvedIds.indexOf(id) > -1);
-
- if (nextIndex > -1) {
- const nextId = remainingAfterCurrent[nextIndex];
- const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
+ const nextId =
+ this.nextUnresolvedDiscussionId(this.discussion.id, this.discussionsByDiffOrder);
- if (el) {
- this.expandDiscussion({ discussionId: nextId });
- scrollToElement(el);
- }
- }
+ this.jumpToDiscussion(nextId);
},
},
};
@@ -397,7 +401,7 @@ Please check your network connection and try again.`;
</a>
</div>
<div
- v-if="hasMultipleUnresolvedDiscussions"
+ v-if="showJumpToNextDiscussion"
class="btn-group"
role="group">
<button
@@ -420,7 +424,8 @@ Please check your network connection and try again.`;
:is-editing="false"
save-button-title="Comment"
@handleFormUpdate="saveReply"
- @cancelForm="cancelReplyForm" />
+ @cancelForm="cancelReplyForm"
+ />
<note-signed-out-widget v-if="!canReply" />
</div>
</div>
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
index 36cc8d5d056..4f45f912479 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -4,12 +4,18 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default {
methods: {
- initAutoSave(noteable) {
- this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [
+ initAutoSave(noteable, extraKeys = []) {
+ let keys = [
'Note',
- capitalizeFirstCharacter(noteable.noteable_type),
+ capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
noteable.id,
- ]);
+ ];
+
+ if (extraKeys) {
+ keys = keys.concat(extraKeys);
+ }
+
+ this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys);
},
resetAutoSave() {
this.autosave.reset();
@@ -17,5 +23,8 @@ export default {
setAutoSave() {
this.autosave.save();
},
+ disposeAutoSave() {
+ this.autosave.dispose();
+ },
},
};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
new file mode 100644
index 00000000000..f7c4deee1f8
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -0,0 +1,29 @@
+import { scrollToElement } from '~/lib/utils/common_utils';
+
+export default {
+ methods: {
+ jumpToDiscussion(id) {
+ if (id) {
+ const activeTab = window.mrTabs.currentAction;
+ const selector =
+ activeTab === 'diffs'
+ ? `ul.notes[data-discussion-id="${id}"]`
+ : `div.discussion[data-discussion-id="${id}"]`;
+ const el = document.querySelector(selector);
+
+ if (activeTab === 'commits' || activeTab === 'pipelines') {
+ window.mrTabs.activateTab('show');
+ }
+
+ if (el) {
+ this.expandDiscussion({ discussionId: id });
+
+ scrollToElement(el);
+ return true;
+ }
+ }
+
+ return false;
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 5c65e1c3bb5..5b3b9f8776f 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -82,6 +82,9 @@ export const allDiscussions = (state, getters) => {
return Object.values(resolved).concat(unresolved);
};
+export const allResolvableDiscussions = (state, getters) =>
+ getters.allDiscussions.filter(d => !d.individual_note && d.resolvable);
+
export const resolvedDiscussionsById = state => {
const map = {};
@@ -98,6 +101,51 @@ export const resolvedDiscussionsById = state => {
return map;
};
+// Gets Discussions IDs ordered by the date of their initial note
+export const unresolvedDiscussionsIdsByDate = (state, getters) =>
+ getters.allResolvableDiscussions
+ .filter(d => !d.resolved)
+ .sort((a, b) => {
+ const aDate = new Date(a.notes[0].created_at);
+ const bDate = new Date(b.notes[0].created_at);
+
+ if (aDate < bDate) {
+ return -1;
+ }
+
+ return aDate === bDate ? 0 : 1;
+ })
+ .map(d => d.id);
+
+// Gets Discussions IDs ordered by their position in the diff
+//
+// Sorts the array of resolvable yet unresolved discussions by
+// comparing file names first. If file names are the same, compares
+// line numbers.
+export const unresolvedDiscussionsIdsByDiff = (state, getters) =>
+ getters.allResolvableDiscussions
+ .filter(d => !d.resolved)
+ .sort((a, b) => {
+ if (!a.diff_file || !b.diff_file) {
+ return 0;
+ }
+
+ // Get file names comparison result
+ const filenameComparison = a.diff_file.file_path.localeCompare(b.diff_file.file_path);
+
+ // Get the line numbers, to compare within the same file
+ const aLines = [a.position.formatter.new_line, a.position.formatter.old_line];
+ const bLines = [b.position.formatter.new_line, b.position.formatter.old_line];
+
+ return filenameComparison < 0 ||
+ (filenameComparison === 0 &&
+ // .max() because one of them might be zero (if removed/added)
+ Math.max(aLines[0], aLines[1]) < Math.max(bLines[0], bLines[1]))
+ ? -1
+ : 1;
+ })
+ .map(d => d.id);
+
export const resolvedDiscussionCount = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById;
@@ -114,5 +162,42 @@ export const discussionTabCounter = state => {
return all.length;
};
+// Returns the list of discussion IDs ordered according to given parameter
+// @param {Boolean} diffOrder - is ordered by diff?
+export const unresolvedDiscussionsIdsOrdered = (state, getters) => diffOrder => {
+ if (diffOrder) {
+ return getters.unresolvedDiscussionsIdsByDiff;
+ }
+ return getters.unresolvedDiscussionsIdsByDate;
+};
+
+// Checks if a given discussion is the last in the current order (diff or date)
+// @param {Boolean} discussionId - id of the discussion
+// @param {Boolean} diffOrder - is ordered by diff?
+export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, diffOrder) => {
+ const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
+ const lastDiscussionId = idsOrdered[idsOrdered.length - 1];
+
+ return lastDiscussionId === discussionId;
+};
+
+// Gets the ID of the discussion following the one provided, respecting order (diff or date)
+// @param {Boolean} discussionId - id of the current discussion
+// @param {Boolean} diffOrder - is ordered by diff?
+export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => {
+ const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
+ const currentIndex = idsOrdered.indexOf(discussionId);
+
+ return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0];
+};
+
+// @param {Boolean} diffOrder - is ordered by diff?
+export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
+ if (diffOrder) {
+ return getters.unresolvedDiscussionsIdsByDiff[0];
+ }
+ return getters.unresolvedDiscussionsIdsByDate[0];
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index ff19b9a9c30..9aa83ce6269 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -39,6 +39,7 @@ export default class Todos {
}
initFilters() {
+ this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']);
this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
this.initFilterDropdown($('.js-type-search'), 'type');
this.initFilterDropdown($('.js-action-search'), 'action_id');
@@ -53,7 +54,16 @@ export default class Todos {
filterable: searchFields ? true : false,
search: { fields: searchFields },
data: $dropdown.data('data'),
- clicked: () => $dropdown.closest('form.filter-form').submit(),
+ clicked: () => {
+ const $formEl = $dropdown.closest('form.filter-form');
+ const mutexDropdowns = {
+ group_id: 'project_id',
+ project_id: 'group_id',
+ };
+
+ $formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove();
+ $formEl.submit();
+ },
});
}
diff --git a/app/assets/javascripts/pages/profiles/show/emoji_menu.js b/app/assets/javascripts/pages/profiles/show/emoji_menu.js
new file mode 100644
index 00000000000..094837b40e0
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/show/emoji_menu.js
@@ -0,0 +1,18 @@
+import { AwardsHandler } from '~/awards_handler';
+
+class EmojiMenu extends AwardsHandler {
+ constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback) {
+ super(emoji);
+
+ this.selectEmojiCallback = selectEmojiCallback;
+ this.toggleButtonSelector = toggleButtonSelector;
+ this.menuClass = menuClass;
+ }
+
+ postEmoji($emojiButton, awardUrl, selectedEmoji, callback) {
+ this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji));
+ callback();
+ }
+}
+
+export default EmojiMenu;
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
new file mode 100644
index 00000000000..949219a0837
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -0,0 +1,49 @@
+import $ from 'jquery';
+import createFlash from '~/flash';
+import GfmAutoComplete from '~/gfm_auto_complete';
+import EmojiMenu from './emoji_menu';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
+ const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
+ const statusEmojiField = document.getElementById('js-status-emoji-field');
+ const statusMessageField = document.getElementById('js-status-message-field');
+ const findNoEmojiPlaceholder = () => document.getElementById('js-no-emoji-placeholder');
+
+ const removeStatusEmoji = () => {
+ const statusEmoji = toggleEmojiMenuButton.querySelector('gl-emoji');
+ if (statusEmoji) {
+ statusEmoji.remove();
+ }
+ };
+
+ const selectEmojiCallback = (emoji, emojiTag) => {
+ statusEmojiField.value = emoji;
+ findNoEmojiPlaceholder().classList.add('hidden');
+ removeStatusEmoji();
+ toggleEmojiMenuButton.innerHTML += emojiTag;
+ };
+
+ const clearEmojiButton = document.getElementById('js-clear-user-status-button');
+ clearEmojiButton.addEventListener('click', () => {
+ statusEmojiField.value = '';
+ statusMessageField.value = '';
+ removeStatusEmoji();
+ findNoEmojiPlaceholder().classList.remove('hidden');
+ });
+
+ const emojiAutocomplete = new GfmAutoComplete();
+ emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+
+ import(/* webpackChunkName: 'emoji' */ '~/emoji')
+ .then(Emoji => {
+ const emojiMenu = new EmojiMenu(
+ Emoji,
+ toggleEmojiMenuButtonSelector,
+ 'js-status-emoji-menu',
+ selectEmojiCallback,
+ );
+ emojiMenu.bindEvents();
+ })
+ .catch(() => createFlash('Failed to load emoji list!'));
+});
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 1faa59fb45b..8f5ac3d8082 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -23,17 +23,12 @@ document.addEventListener('DOMContentLoaded', () => {
saveEndpoint: variableListEl.dataset.saveEndpoint,
});
- // hide extra auto devops settings based on data-attributes
- const autoDevOpsSettings = document.querySelector('.js-auto-devops-settings');
+ // hide extra auto devops settings based checkbox state
const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
-
- autoDevOpsSettings.addEventListener('click', event => {
+ const instanceDefaultBadge = document.querySelector('.js-instance-default-badge');
+ document.querySelector('.js-toggle-extra-settings').addEventListener('click', event => {
const { target } = event;
- if (target.classList.contains('js-toggle-extra-settings')) {
- autoDevOpsExtraSettings.classList.toggle(
- 'hidden',
- !!(target.dataset && target.dataset.hideExtraSettings),
- );
- }
+ if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none';
+ autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked);
});
});
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
index 5bc3c2c4d21..140475b4dfa 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -69,7 +69,7 @@
return (
report.existing_failures.length > 0 ||
report.new_failures.length > 0 ||
- report.resolved_failures > 0
+ report.resolved_failures.length > 0
);
},
},
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
index e806d120b51..1983a8c9e56 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -9,6 +9,8 @@ export default {
state.isLoading = true;
},
[types.RECEIVE_REPORTS_SUCCESS](state, response) {
+ // Make sure to clean previous state in case it was an error
+ state.hasError = false;
state.isLoading = false;
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 72a2c7ca101..aec09b8bc0a 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,9 +1,18 @@
-/* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, consistent-return, object-shorthand, prefer-template, quotes, class-methods-use-this, no-lonely-if, no-else-return, vars-on-top, max-len */
+/* eslint-disable no-return-assign, one-var, no-var, one-var-declaration-per-line, no-unused-vars, consistent-return, object-shorthand, prefer-template, class-methods-use-this, no-lonely-if, vars-on-top, max-len */
import $ from 'jquery';
+import { escape, throttle } from 'underscore';
+import { s__, sprintf } from '~/locale';
+import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
import DropdownUtils from './filtered_search/dropdown_utils';
-import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils';
+import {
+ isInGroupsPage,
+ isInProjectPage,
+ getGroupSlug,
+ getProjectSlug,
+ spriteIcon,
+} from './lib/utils/common_utils';
/**
* Search input in top navigation bar.
@@ -52,6 +61,7 @@ function setSearchOptions() {
if ($dashboardOptionsDataEl.length) {
gl.dashboardOptions = {
+ name: s__('SearchAutocomplete|All GitLab'),
issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
mrPath: $dashboardOptionsDataEl.data('mrPath'),
};
@@ -69,8 +79,8 @@ export default class SearchAutocomplete {
this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || '');
this.dropdown = this.wrap.find('.dropdown');
this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
+ this.dropdownMenu = this.dropdown.find('.dropdown-menu');
this.dropdownContent = this.dropdown.find('.dropdown-content');
- this.locationBadgeEl = this.getElement('.location-badge');
this.scopeInputEl = this.getElement('#scope');
this.searchInput = this.getElement('.search-input');
this.projectInputEl = this.getElement('#search_project_id');
@@ -78,6 +88,7 @@ export default class SearchAutocomplete {
this.searchCodeInputEl = this.getElement('#search_code');
this.repositoryInputEl = this.getElement('#repository_ref');
this.clearInput = this.getElement('.js-clear-input');
+ this.scrollFadeInitialized = false;
this.saveOriginalState();
// Only when user is logged in
@@ -98,17 +109,18 @@ export default class SearchAutocomplete {
this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this);
+ this.setScrollFade = this.setScrollFade.bind(this);
}
getElement(selector) {
return this.wrap.find(selector);
}
saveOriginalState() {
- return this.originalState = this.serializeState();
+ return (this.originalState = this.serializeState());
}
saveTextLength() {
- return this.lastTextLength = this.searchInput.val().length;
+ return (this.lastTextLength = this.searchInput.val().length);
}
createAutocomplete() {
@@ -117,6 +129,7 @@ export default class SearchAutocomplete {
filterable: true,
filterRemote: true,
highlight: true,
+ icon: true,
enterCallback: false,
filterInput: 'input#search',
search: {
@@ -154,60 +167,87 @@ export default class SearchAutocomplete {
this.loadingSuggestions = true;
- return axios.get(this.autocompletePath, {
- params: {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term: term,
- },
- }).then((response) => {
- // Hide dropdown menu if no suggestions returns
- if (!response.data.length) {
- this.disableAutocomplete();
- return;
- }
+ return axios
+ .get(this.autocompletePath, {
+ params: {
+ project_id: this.projectId,
+ project_ref: this.projectRef,
+ term: term,
+ },
+ })
+ .then(response => {
+ // Hide dropdown menu if no suggestions returns
+ if (!response.data.length) {
+ this.disableAutocomplete();
+ return;
+ }
- const data = [];
- // List results
- let firstCategory = true;
- let lastCategory;
- for (let i = 0, len = response.data.length; i < len; i += 1) {
- const suggestion = response.data[i];
- // Add group header before list each group
- if (lastCategory !== suggestion.category) {
- if (!firstCategory) {
- data.push('separator');
- }
- if (firstCategory) {
- firstCategory = false;
+ const data = [];
+ // List results
+ let firstCategory = true;
+ let lastCategory;
+ for (let i = 0, len = response.data.length; i < len; i += 1) {
+ const suggestion = response.data[i];
+ // Add group header before list each group
+ if (lastCategory !== suggestion.category) {
+ if (!firstCategory) {
+ data.push('separator');
+ }
+ if (firstCategory) {
+ firstCategory = false;
+ }
+ data.push({
+ header: suggestion.category,
+ });
+ lastCategory = suggestion.category;
}
data.push({
- header: suggestion.category,
+ id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
+ icon: this.getAvatar(suggestion),
+ category: suggestion.category,
+ text: suggestion.label,
+ url: suggestion.url,
});
- lastCategory = suggestion.category;
}
- data.push({
- id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
- category: suggestion.category,
- text: suggestion.label,
- url: suggestion.url,
- });
- }
- // Add option to proceed with the search
- if (data.length) {
- data.push('separator');
- data.push({
- text: `Result name contains "${term}"`,
- url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
- });
- }
+ // Add option to proceed with the search
+ if (data.length) {
+ const icon = spriteIcon('search', 's16 inline-search-icon');
+ let template;
- callback(data);
+ if (this.projectInputEl.val()) {
+ template = s__('SearchAutocomplete|in this project');
+ }
+ if (this.groupInputEl.val()) {
+ template = s__('SearchAutocomplete|in this group');
+ }
- this.loadingSuggestions = false;
- }).catch(() => {
- this.loadingSuggestions = false;
- });
+ data.unshift('separator');
+ data.unshift({
+ icon,
+ text: term,
+ template: s__('SearchAutocomplete|in all GitLab'),
+ url: `/search?search=${term}`,
+ });
+
+ if (template) {
+ data.unshift({
+ icon,
+ text: term,
+ template,
+ url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
+ });
+ }
+ }
+
+ callback(data);
+
+ this.loadingSuggestions = false;
+ this.highlightFirstRow();
+ this.setScrollFade();
+ })
+ .catch(() => {
+ this.loadingSuggestions = false;
+ });
}
getCategoryContents() {
@@ -236,21 +276,21 @@ export default class SearchAutocomplete {
const issueItems = [
{
- text: 'Issues assigned to me',
+ text: s__('SearchAutocomplete|Issues assigned to me'),
url: `${issuesPath}/?assignee_id=${userId}`,
},
{
- text: "Issues I've created",
+ text: s__("SearchAutocomplete|Issues I've created"),
url: `${issuesPath}/?author_id=${userId}`,
},
];
const mergeRequestItems = [
{
- text: 'Merge requests assigned to me',
+ text: s__('SearchAutocomplete|Merge requests assigned to me'),
url: `${mrPath}/?assignee_id=${userId}`,
},
{
- text: "Merge requests I've created",
+ text: s__("SearchAutocomplete|Merge requests I've created"),
url: `${mrPath}/?author_id=${userId}`,
},
];
@@ -259,7 +299,7 @@ export default class SearchAutocomplete {
if (issuesDisabled) {
items = baseItems.concat(mergeRequestItems);
} else {
- items = baseItems.concat(...issueItems, 'separator', ...mergeRequestItems);
+ items = baseItems.concat(...issueItems, ...mergeRequestItems);
}
return items;
}
@@ -272,8 +312,6 @@ export default class SearchAutocomplete {
search_code: this.searchCodeInputEl.val(),
repository_ref: this.repositoryInputEl.val(),
scope: this.scopeInputEl.val(),
- // Location badge
- _location: this.locationBadgeEl.text(),
};
}
@@ -283,10 +321,12 @@ export default class SearchAutocomplete {
this.searchInput.on('focus', this.onSearchInputFocus);
this.searchInput.on('blur', this.onSearchInputBlur);
this.clearInput.on('click', this.onClearInputClick);
- this.locationBadgeEl.on('click', () => this.searchInput.focus());
+ this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250));
}
enableAutocomplete() {
+ this.setScrollFade();
+
// No need to enable anything if user is not logged in
if (!gon.current_user_id) {
return;
@@ -308,10 +348,6 @@ export default class SearchAutocomplete {
onSearchInputKeyUp(e) {
switch (e.keyCode) {
case KEYCODE.BACKSPACE:
- // when trying to remove the location badge
- if (this.lastTextLength === 0 && this.badgePresent()) {
- this.removeLocationBadge();
- }
// When removing the last character and no badge is present
if (this.lastTextLength === 1) {
this.disableAutocomplete();
@@ -372,37 +408,13 @@ export default class SearchAutocomplete {
}
}
- addLocationBadge(item) {
- var badgeText, category, value;
- category = item.category != null ? item.category + ": " : '';
- value = item.value != null ? item.value : '';
- badgeText = "" + category + value;
- this.locationBadgeEl.text(badgeText).show();
- return this.wrap.addClass('has-location-badge');
- }
-
- hasLocationBadge() {
- return this.wrap.is('.has-location-badge');
- }
-
restoreOriginalState() {
var i, input, inputs, len;
inputs = Object.keys(this.originalState);
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- this.getElement("#" + input).val(this.originalState[input]);
+ this.getElement('#' + input).val(this.originalState[input]);
}
- if (this.originalState._location === '') {
- return this.locationBadgeEl.hide();
- } else {
- return this.addLocationBadge({
- value: this.originalState._location,
- });
- }
- }
-
- badgePresent() {
- return this.locationBadgeEl.length;
}
resetSearchState() {
@@ -411,22 +423,11 @@ export default class SearchAutocomplete {
results = [];
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- // _location isnt a input
- if (input === '_location') {
- break;
- }
- results.push(this.getElement("#" + input).val(''));
+ results.push(this.getElement('#' + input).val(''));
}
return results;
}
- removeLocationBadge() {
- this.locationBadgeEl.hide();
- this.resetSearchState();
- this.wrap.removeClass('has-location-badge');
- return this.disableAutocomplete();
- }
-
disableAutocomplete() {
if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('disabled');
@@ -444,23 +445,57 @@ export default class SearchAutocomplete {
onClick(item, $el, e) {
if (window.location.pathname.indexOf(item.url) !== -1) {
if (!e.metaKey) e.preventDefault();
- if (!this.badgePresent) {
- if (item.category === 'Projects') {
- this.projectInputEl.val(item.id);
- this.addLocationBadge({
- value: 'This project',
- });
- }
- if (item.category === 'Groups') {
- this.groupInputEl.val(item.id);
- this.addLocationBadge({
- value: 'This group',
- });
- }
+ if (item.category === 'Projects') {
+ this.projectInputEl.val(item.id);
+ }
+ if (item.category === 'Groups') {
+ this.groupInputEl.val(item.id);
}
$el.removeClass('is-active');
this.disableAutocomplete();
return this.searchInput.val('').focus();
}
}
+
+ highlightFirstRow() {
+ this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
+ }
+
+ getAvatar(item) {
+ if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
+ return false;
+ }
+
+ const { label, id } = item;
+ const avatarUrl = item.avatar_url;
+ const avatar = avatarUrl
+ ? `<img class="search-item-avatar" src="${avatarUrl}" />`
+ : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
+ escape(label),
+ )}</div>`;
+
+ return avatar;
+ }
+
+ isScrolledUp() {
+ const el = this.dropdownContent[0];
+ const currentPosition = this.contentClientHeight + el.scrollTop;
+
+ return currentPosition < this.maxPosition;
+ }
+
+ initScrollFade() {
+ const el = this.dropdownContent[0];
+ this.scrollFadeInitialized = true;
+
+ this.contentClientHeight = el.clientHeight;
+ this.maxPosition = el.scrollHeight;
+ this.dropdownMenu.addClass('dropdown-content-faded-mask');
+ }
+
+ setScrollFade() {
+ this.initScrollFade();
+
+ this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp());
+ }
}
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
new file mode 100644
index 00000000000..ffaed9c7193
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -0,0 +1,98 @@
+<script>
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+
+const MARK_TEXT = __('Mark todo as done');
+const TODO_TEXT = __('Add todo');
+
+export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ Icon,
+ LoadingIcon,
+ },
+ props: {
+ issuableId: {
+ type: Number,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ isTodo: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ isActionActive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ buttonClasses() {
+ return this.collapsed ?
+ 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state' :
+ 'btn btn-default btn-todo issuable-header-btn float-right';
+ },
+ buttonLabel() {
+ return this.isTodo ? MARK_TEXT : TODO_TEXT;
+ },
+ collapsedButtonIconClasses() {
+ return this.isTodo ? 'todo-undone' : '';
+ },
+ collapsedButtonIcon() {
+ return this.isTodo ? 'todo-done' : 'todo-add';
+ },
+ },
+ methods: {
+ handleButtonClick() {
+ this.$emit('toggleTodo');
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ v-tooltip
+ :class="buttonClasses"
+ :title="buttonLabel"
+ :aria-label="buttonLabel"
+ :data-issuable-id="issuableId"
+ :data-issuable-type="issuableType"
+ type="button"
+ data-container="body"
+ data-placement="left"
+ data-boundary="viewport"
+ @click="handleButtonClick"
+ >
+ <icon
+ v-show="collapsed"
+ :css-classes="collapsedButtonIconClasses"
+ :name="collapsedButtonIcon"
+ />
+ <span
+ v-show="!collapsed"
+ class="issuable-todo-inner"
+ >
+ {{ buttonLabel }}
+ </span>
+ <loading-icon
+ v-show="isActionActive"
+ :inline="true"
+ />
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
index 133bdbb54f7..8163947cd0c 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
@@ -42,6 +42,9 @@ export default {
},
methods: {
onImgLoad() {
+ requestIdleCallback(this.calculateImgSize, { timeout: 1000 });
+ },
+ calculateImgSize() {
const { contentImg } = this.$refs;
if (contentImg) {
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index 3cba0c5e633..af5ebcdc40a 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -38,9 +38,17 @@ export default {
v-show="isLoading"
:inline="true"
/>
- <span class="dropdown-toggle-text">
- {{ toggleText }}
- </span>
+ <template>
+ <slot
+ v-if="$slots.default"
+ ></slot>
+ <span
+ v-else
+ class="dropdown-toggle-text"
+ >
+ {{ toggleText }}
+ </span>
+ </template>
<span
v-show="!isLoading"
class="dropdown-toggle-icon"
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index e7ff76c8218..5e0e7315e99 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -1,7 +1,7 @@
<script>
// only allow classes in images.scss e.g. s12
-const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
+const validSizes = [8, 10, 12, 16, 18, 24, 32, 48, 72];
let iconValidator = () => true;
/*
@@ -75,6 +75,12 @@ export default {
required: false,
default: null,
},
+
+ tabIndex: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
@@ -98,6 +104,7 @@ export default {
:height="height"
:x="x"
:y="y"
+ :tabindex="tabIndex"
>
<use v-bind="{ 'xlink:href':spriteHref }"/>
</svg>
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue
new file mode 100644
index 00000000000..17927fabbcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue
@@ -0,0 +1,47 @@
+<script>
+import Identicon from '../identicon.vue';
+import ProjectAvatarImage from './image.vue';
+
+export default {
+ components: {
+ Identicon,
+ ProjectAvatarImage,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ },
+ size: {
+ type: Number,
+ default: 40,
+ },
+ },
+ computed: {
+ sizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ :class="sizeClass"
+ class="avatar-container project-avatar"
+ >
+ <project-avatar-image
+ v-if="project.avatar_url"
+ :link-href="project.path"
+ :img-src="project.avatar_url"
+ :img-alt="project.name"
+ :img-size="size"
+ />
+ <identicon
+ v-else
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :size-class="sizeClass"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index ac2e99abe77..80dc7d3557c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -12,6 +12,11 @@ export default {
type: Boolean,
required: true,
},
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
tooltipLabel() {
@@ -30,10 +35,12 @@ export default {
<button
v-tooltip
:title="tooltipLabel"
+ :class="cssClasses"
type="button"
class="btn btn-blank gutter-toggle btn-sidebar-action"
data-container="body"
data-placement="left"
+ data-boundary="viewport"
@click="toggle"
>
<i
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index 3a413c74410..7737b9f2697 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -1,5 +1,4 @@
<script>
-
/* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component.
@@ -67,7 +66,9 @@ export default {
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
- return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`;
+ return baseSrc;
},
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index d28ad407734..c20738a20c3 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -339,3 +339,13 @@ input[type=color].form-control {
vertical-align: unset;
}
}
+
+// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
+.input-group-btn:first-child {
+ @extend .input-group-prepend;
+}
+
+// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
+.input-group-btn:last-child {
+ @extend .input-group-append;
+}
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index dddd07c798c..369556dc24e 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -78,6 +78,7 @@
&.s26 { font-size: 20px; line-height: 1.33; }
&.s32 { font-size: 20px; line-height: 30px; }
&.s40 { font-size: 16px; line-height: 38px; }
+ &.s48 { font-size: 20px; line-height: 46px; }
&.s60 { font-size: 32px; line-height: 58px; }
&.s70 { font-size: 34px; line-height: 70px; }
&.s90 { font-size: 36px; line-height: 88px; }
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index c9865610b78..af17210f341 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -454,6 +454,7 @@ img.emoji {
.prepend-left-10 { margin-left: 10px; }
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
+.append-right-4 { margin-right: 4px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
@@ -470,3 +471,5 @@ img.emoji {
.center { text-align: center; }
.vertical-align-middle { vertical-align: middle; }
.flex-align-self-center { align-self: center; }
+.flex-grow { flex-grow: 1; }
+.flex-no-shrink { flex-shrink: 0; }
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index ea4cb9a0b75..e2bbcc67a67 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -55,6 +55,11 @@
.sidebar-context-title {
overflow: hidden;
text-overflow: ellipsis;
+
+ &.text-secondary {
+ font-weight: normal;
+ font-size: 0.8em;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index ec4a0f378d0..eebce8b9011 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -571,7 +571,8 @@
margin-bottom: 10px;
padding: 0 10px;
- .fa {
+ .fa,
+ .input-icon {
position: absolute;
top: 10px;
right: 20px;
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index dff6bce370f..50ebc6d0dd1 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -3,7 +3,6 @@
*/
@mixin gitlab-theme(
- $location-badge-color,
$search-and-nav-links,
$active-tab-border,
$border-and-box-shadow,
@@ -119,12 +118,6 @@
}
}
- .location-badge {
- color: $location-badge-color;
- background-color: rgba($search-and-nav-links, 0.1);
- border-right: 1px solid $sidebar-text;
- }
-
.search-input::placeholder {
color: rgba($search-and-nav-links, 0.8);
}
@@ -141,10 +134,6 @@
background-color: $white-light;
}
- .location-badge {
- color: $gl-text-color;
- }
-
.search-input-wrap {
.search-icon {
fill: rgba($search-and-nav-links, 0.8);
@@ -200,7 +189,6 @@
body {
&.ui-indigo {
@include gitlab-theme(
- $indigo-100,
$indigo-200,
$indigo-500,
$indigo-700,
@@ -212,7 +200,6 @@ body {
&.ui-light-indigo {
@include gitlab-theme(
- $indigo-100,
$indigo-200,
$indigo-500,
$indigo-500,
@@ -224,7 +211,6 @@ body {
&.ui-blue {
@include gitlab-theme(
- $theme-blue-100,
$theme-blue-200,
$theme-blue-500,
$theme-blue-700,
@@ -236,7 +222,6 @@ body {
&.ui-light-blue {
@include gitlab-theme(
- $theme-light-blue-100,
$theme-light-blue-200,
$theme-light-blue-500,
$theme-light-blue-500,
@@ -248,7 +233,6 @@ body {
&.ui-green {
@include gitlab-theme(
- $theme-green-100,
$theme-green-200,
$theme-green-500,
$theme-green-700,
@@ -260,7 +244,6 @@ body {
&.ui-light-green {
@include gitlab-theme(
- $theme-green-100,
$theme-green-200,
$theme-green-500,
$theme-green-500,
@@ -272,7 +255,6 @@ body {
&.ui-red {
@include gitlab-theme(
- $theme-red-100,
$theme-red-200,
$theme-red-500,
$theme-red-700,
@@ -284,7 +266,6 @@ body {
&.ui-light-red {
@include gitlab-theme(
- $theme-light-red-100,
$theme-light-red-200,
$theme-light-red-500,
$theme-light-red-500,
@@ -296,7 +277,6 @@ body {
&.ui-dark {
@include gitlab-theme(
- $theme-gray-100,
$theme-gray-200,
$theme-gray-500,
$theme-gray-700,
@@ -308,7 +288,6 @@ body {
&.ui-light {
@include gitlab-theme(
- $theme-gray-900,
$theme-gray-700,
$theme-gray-800,
$theme-gray-700,
@@ -357,10 +336,6 @@ body {
&:hover {
background-color: $white-light;
box-shadow: inset 0 0 0 1px $blue-200;
-
- .location-badge {
- box-shadow: inset 0 0 0 1px $blue-200;
- }
}
}
@@ -373,13 +348,6 @@ body {
color: $gl-text-color;
}
}
-
- .location-badge {
- color: $theme-gray-700;
- box-shadow: inset 0 0 0 1px $border-color;
- background-color: $nav-badge-bg;
- border-right: 0;
- }
}
.nav-sidebar li.active {
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index ab3cceceae9..f878ec1ca91 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -39,7 +39,7 @@
svg {
fill: currentColor;
- $svg-sizes: 8 12 16 18 24 32 48 72;
+ $svg-sizes: 8 10 12 16 18 24 32 48 72;
@each $svg-size in $svg-sizes {
&.s#{$svg-size} {
@include svg-size(#{$svg-size}px);
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 56940a7564a..4db9efff6ee 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -467,7 +467,8 @@ $award-emoji-positive-add-lines: #bb9c13;
*/
$search-input-border-color: rgba($blue-400, 0.8);
$search-input-focus-shadow-color: $dropdown-input-focus-shadow;
-$search-input-width: 220px;
+$search-input-width: 240px;
+$search-input-active-width: 320px;
$location-badge-active-bg: $blue-500;
$location-icon-color: #e7e9ed;
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 58ed5bf6455..2b8163b8c68 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -1,6 +1,13 @@
@import 'framework/variables';
@import 'framework/mixins';
+$search-list-icon-width: 18px;
+$ide-activity-bar-width: 60px;
+$ide-context-header-padding: 10px;
+$ide-project-avatar-end: $ide-context-header-padding + 48px;
+$ide-tree-padding: $gl-padding;
+$ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
+
.project-refs-form,
.project-refs-target-form {
display: inline-block;
@@ -24,7 +31,6 @@
display: flex;
height: calc(100vh - #{$header-height});
margin-top: 0;
- border-top: 1px solid $white-dark;
padding-bottom: $ide-statusbar-height;
color: $gl-text-color;
@@ -41,10 +47,10 @@
}
.ide-file-list {
+ display: flex;
+ flex-direction: column;
flex: 1;
- padding-left: $gl-padding;
- padding-right: $gl-padding;
- padding-bottom: $grid-size;
+ min-height: 0;
.file {
height: 32px;
@@ -517,35 +523,30 @@
> a,
> button {
- height: 60px;
+ text-decoration: none;
+ padding-top: $gl-padding-8;
+ padding-bottom: $gl-padding-8;
}
}
- .projects-sidebar {
- min-height: 0;
- display: flex;
- flex-direction: column;
- flex: 1;
- }
-
.multi-file-commit-panel-inner {
position: relative;
display: flex;
flex-direction: column;
- height: 100%;
+ min-height: 100%;
min-width: 0;
width: 100%;
}
- .multi-file-commit-panel-inner-scroll {
+ .multi-file-commit-panel-inner-content {
display: flex;
flex: 1;
flex-direction: column;
- overflow: auto;
background-color: $white-light;
border-left: 1px solid $white-dark;
border-top: 1px solid $white-dark;
border-top-left-radius: $border-radius-small;
+ min-height: 0;
}
}
@@ -803,12 +804,6 @@
height: calc(100vh - #{$header-height + $flash-height});
}
}
-
- .projects-sidebar {
- .multi-file-commit-panel-inner-scroll {
- flex: 1;
- }
- }
}
}
@@ -964,7 +959,7 @@
.ide-activity-bar {
position: relative;
- flex: 0 0 60px;
+ flex: 0 0 $ide-activity-bar-width;
z-index: 1;
}
@@ -1060,21 +1055,56 @@
}
.ide-tree-header {
+ flex: 0 0 auto;
display: flex;
align-items: center;
- margin-bottom: 8px;
+ flex-wrap: wrap;
padding: 12px 0;
+ margin-left: $ide-tree-padding;
+ margin-right: $ide-tree-padding;
border-bottom: 1px solid $white-dark;
.ide-new-btn {
margin-left: auto;
}
+ .ide-nav-dropdown {
+ width: 100%;
+ margin-bottom: 12px;
+
+ .dropdown-menu {
+ width: 385px;
+ max-height: initial;
+ }
+
+ .dropdown-menu-toggle {
+ svg {
+ vertical-align: middle;
+ }
+
+ &:hover {
+ background-color: $white-normal;
+ }
+ }
+
+ &.show {
+ .dropdown-menu-toggle {
+ background-color: $white-dark;
+ }
+ }
+ }
+
button {
color: $gl-text-color;
}
}
+.ide-tree-body {
+ overflow: auto;
+ padding-left: $ide-tree-padding;
+ padding-right: $ide-tree-padding;
+}
+
.ide-sidebar-branch-title {
font-weight: $gl-font-weight-normal;
@@ -1163,14 +1193,23 @@
}
.ide-context-header {
- .avatar {
- flex: 0 0 38px;
- }
-
.ide-merge-requests-dropdown.dropdown-menu {
width: 385px;
max-height: initial;
}
+
+ .avatar-container {
+ flex: initial;
+ margin-right: 0;
+ }
+
+ .ide-sidebar-project-title {
+ margin-left: $ide-tree-text-start - $ide-project-avatar-end;
+ }
+}
+
+.ide-context-body {
+ min-height: 0;
}
.ide-sidebar-project-title {
@@ -1178,10 +1217,11 @@
.sidebar-context-title {
white-space: nowrap;
- }
+ display: block;
- .ide-sidebar-branch-title {
- min-width: 50px;
+ &.text-secondary {
+ font-weight: normal;
+ }
}
}
@@ -1217,6 +1257,10 @@
background-color: $white-light;
border-left: 1px solid $white-dark;
}
+
+ .ide-right-sidebar-clientside {
+ padding: 0;
+ }
}
.ide-pipeline {
@@ -1315,7 +1359,7 @@
min-height: 60px;
}
-.ide-merge-requests-dropdown {
+.ide-nav-form {
.nav-links li {
width: 50%;
padding-left: 0;
@@ -1334,22 +1378,36 @@
padding-left: $gl-padding;
padding-right: $gl-padding;
- .fa {
- right: 26px;
+ .input-icon {
+ right: auto;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
}
}
+ .dropdown-input-field {
+ padding-left: $search-list-icon-width + $gl-padding;
+ padding-top: 2px;
+ padding-bottom: 2px;
+ }
+
+ .tokens-container {
+ padding-left: $search-list-icon-width + $gl-padding;
+ overflow-x: hidden;
+ }
+
.btn-link {
padding-top: $gl-padding;
padding-bottom: $gl-padding;
}
}
-.ide-merge-request-current-icon {
- min-width: 18px;
+.ide-search-list-current-icon {
+ min-width: $search-list-icon-width;
}
-.ide-merge-requests-empty {
+.ide-search-list-empty {
height: 230px;
}
@@ -1400,3 +1458,40 @@
color: $white-normal;
background-color: $blue-500;
}
+
+.ide-preview-header {
+ padding: 0 $grid-size;
+ border-bottom: 1px solid $white-dark;
+ background-color: $gray-light;
+ min-height: 44px;
+}
+
+.ide-navigator-btn {
+ height: 24px;
+ min-width: 24px;
+ max-width: 24px;
+ padding: 0;
+ margin: 0 ($grid-size / 2);
+ color: $gl-gray-light;
+
+ &:first-child {
+ margin-left: 0;
+ }
+}
+
+.ide-navigator-location {
+ padding-top: ($grid-size / 2);
+ padding-bottom: ($grid-size / 2);
+
+ &:focus {
+ outline: 0;
+ box-shadow: none;
+ border-color: $theme-gray-200;
+ }
+}
+
+.ide-preview-loading-icon {
+ right: $grid-size;
+ top: 50%;
+ transform: translateY(-50%);
+}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 05bf5596fb3..1587aebfe1d 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -290,9 +290,8 @@
}
.folder-toggle-wrap {
- float: left;
- line-height: $list-text-height;
font-size: 0;
+ flex-shrink: 0;
span {
font-size: $gl-font-size;
@@ -308,7 +307,7 @@
width: 15px;
svg {
- margin-bottom: 2px;
+ margin-bottom: 1px;
}
}
@@ -391,9 +390,17 @@
cursor: pointer;
}
- .avatar-container > a {
- width: 100%;
- text-decoration: none;
+ .group-text {
+ min-width: 0; // allows for truncated text within flex children
+ }
+
+ .avatar-container {
+ flex-shrink: 0;
+
+ > a {
+ width: 100%;
+ text-decoration: none;
+ }
}
&.has-more-items {
@@ -401,9 +408,18 @@
padding: 20px 10px;
}
+ .description {
+ p {
+ @include str-truncated;
+
+ max-width: none;
+ }
+ }
+
.stats {
position: relative;
- line-height: 46px;
+ line-height: normal;
+ flex-shrink: 0;
> span {
display: inline-flex;
@@ -422,14 +438,20 @@
}
.controls {
- margin-left: 5px;
+ flex-shrink: 0;
> .btn {
- margin-right: $btn-margin-5;
+ margin: 0 0 0 $btn-margin-5;
}
}
}
+ @include media-breakpoint-down(xs) {
+ .group-stats {
+ display: none;
+ }
+ }
+
.project-row-contents .stats {
line-height: inherit;
@@ -451,18 +473,6 @@
}
}
-ul.group-list-tree {
- li.group-row {
- > .group-row-contents .title {
- line-height: $list-text-height;
- }
-
- &.has-description > .group-row-contents .title {
- line-height: inherit;
- }
- }
-}
-
.js-groups-list-holder {
.groups-list-loading {
font-size: 34px;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index d5ae2b673d9..8e78d9f65eb 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -449,6 +449,7 @@
.todo-undone {
color: $gl-link-color;
+ fill: $gl-link-color;
}
.author {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 391dfea0703..2b40404971c 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -72,6 +72,9 @@
}
.manage-labels-list {
+ padding: 0;
+ margin-bottom: 0;
+
> li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
margin-bottom: 5px;
@@ -81,6 +84,10 @@
border-radius: $border-radius-default;
border: 1px solid $theme-gray-100;
+ &:last-child {
+ margin-bottom: 0;
+ }
+
&.sortable-ghost {
opacity: 0.3;
}
@@ -243,7 +250,10 @@
.label-actions-list {
list-style: none;
flex-shrink: 0;
+ text-align: right;
padding: 0;
+ position: relative;
+ top: -3px;
}
.label-badge {
@@ -272,6 +282,16 @@
padding: 0;
}
+.label-description {
+ .description-text {
+ margin-bottom: 10px;
+
+ .admin-labels & {
+ margin-bottom: 0;
+ }
+ }
+}
+
.label-list-item {
.content-list &::before,
.content-list &::after {
@@ -319,6 +339,64 @@
fill: $blue-600;
}
}
+
+ &.remove-row {
+ &:hover {
+ color: $gl-text-red;
+
+ svg {
+ fill: $gl-text-red;
+ }
+ }
+ }
+ }
+}
+
+@media (max-width: map-get($grid-breakpoints, md)-1) {
+ .manage-labels-list {
+ > li:not(.empty-message):not(.is-not-draggable) {
+ flex-wrap: wrap;
+ }
+
+ .label-name {
+ order: 1;
+ flex-grow: 1;
+ width: auto;
+ max-width: 100%;
+ }
+
+ .label-actions-list {
+ order: 2;
+ flex-shrink: 1;
+ text-align: left;
+ }
+
+ .label-links {
+ white-space: normal;
+ }
+
+ .label-description {
+ order: 3;
+ width: 100%;
+
+ > .append-right-default.prepend-left-default {
+ margin-left: 0;
+ margin-right: 0;
+ }
+ }
+ }
+}
+
+@media (max-width: 910px) {
+ .priority-badge {
+ display: block;
+ width: 100%;
+ margin-left: 0;
+ margin-top: $gl-padding;
+
+ .label-badge {
+ display: inline-block;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 7fc2936c5e6..c369d89d63c 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -546,6 +546,7 @@ ul.notes {
svg {
@include btn-svg;
+ margin: 0;
}
.award-control-icon-positive,
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 5d0d59e12f2..b45e305897c 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -418,3 +418,23 @@ table.u2f-registrations {
}
}
}
+
+.edit-user {
+ .clear-user-status {
+ svg {
+ fill: $gl-text-color-secondary;
+ }
+ }
+
+ .emoji-menu-toggle-button {
+ @extend .note-action-button;
+
+ .no-emoji-placeholder {
+ position: relative;
+
+ svg {
+ fill: $gl-text-color-secondary;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 2d66f336076..60b280fd12e 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -1,3 +1,6 @@
+$search-dropdown-max-height: 400px;
+$search-avatar-size: 16px;
+
.search-results {
.search-result-row {
border-bottom: 1px solid $border-color;
@@ -24,8 +27,9 @@
box-shadow: 0 0 4px lighten($search-input-focus-shadow-color, 20%);
}
-input[type="checkbox"]:hover {
- box-shadow: 0 0 2px 2px lighten($search-input-focus-shadow-color, 20%), 0 0 0 1px lighten($search-input-focus-shadow-color, 20%);
+input[type='checkbox']:hover {
+ box-shadow: 0 0 2px 2px lighten($search-input-focus-shadow-color, 20%),
+ 0 0 0 1px lighten($search-input-focus-shadow-color, 20%);
}
.search {
@@ -40,24 +44,15 @@ input[type="checkbox"]:hover {
height: 32px;
border: 0;
border-radius: $border-radius-default;
- transition: border-color ease-in-out $default-transition-duration, background-color ease-in-out $default-transition-duration;
+ transition: border-color ease-in-out $default-transition-duration,
+ background-color ease-in-out $default-transition-duration,
+ width ease-in-out $default-transition-duration;
&:hover {
box-shadow: none;
}
}
- .location-badge {
- white-space: nowrap;
- height: 32px;
- font-size: 12px;
- margin: -4px 4px -4px -4px;
- line-height: 25px;
- padding: 4px 8px;
- border-radius: $border-radius-default 0 0 $border-radius-default;
- transition: border-color ease-in-out $default-transition-duration;
- }
-
.search-input {
border: 0;
font-size: 14px;
@@ -104,17 +99,28 @@ input[type="checkbox"]:hover {
}
.dropdown-header {
- text-transform: uppercase;
- font-size: 11px;
+ // Necessary because glDropdown doesn't support a second style of headers
+ font-weight: $gl-font-weight-bold;
+ // .dropdown-menu li has 1px side padding
+ padding: $gl-padding-8 17px;
+ color: $gl-text-color;
+ font-size: $gl-font-size;
+ line-height: 16px;
}
// Custom dropdown positioning
.dropdown-menu {
left: -5px;
+ max-height: $search-dropdown-max-height;
+ overflow: auto;
+
+ @include media-breakpoint-up(xl) {
+ width: $search-input-active-width;
+ }
}
.dropdown-content {
- max-height: none;
+ max-height: $search-dropdown-max-height - 18px;
}
}
@@ -124,6 +130,10 @@ input[type="checkbox"]:hover {
border-color: $dropdown-input-focus-border;
box-shadow: none;
+ @include media-breakpoint-up(xl) {
+ width: $search-input-active-width;
+ }
+
.search-input-wrap {
.search-icon,
.clear-icon {
@@ -141,12 +151,6 @@ input[type="checkbox"]:hover {
color: $gl-text-color-tertiary;
}
}
-
- .location-badge {
- transition: all $default-transition-duration;
- background-color: $nav-badge-bg;
- border-color: $border-color;
- }
}
&.has-value {
@@ -160,10 +164,24 @@ input[type="checkbox"]:hover {
}
}
- &.has-location-badge {
- .search-input-wrap {
- width: 68%;
- }
+ .inline-search-icon {
+ position: relative;
+ margin-right: 4px;
+ color: $gl-text-color-secondary;
+ }
+
+ .identicon,
+ .search-item-avatar {
+ flex-basis: $search-avatar-size;
+ flex-shrink: 0;
+ margin-right: 4px;
+ }
+
+ .search-item-avatar {
+ width: $search-avatar-size;
+ height: $search-avatar-size;
+ border-radius: 50%;
+ border: 1px solid $avatar-border;
}
}
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
index 777fdb3581e..239123fc3ab 100644
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ b/app/assets/stylesheets/pages/settings_ci_cd.scss
@@ -19,9 +19,4 @@
.auto-devops-card {
margin-bottom: $gl-vert-padding;
-
- > .card-body {
- border-radius: $card-border-radius;
- padding: $gl-padding $gl-padding-24;
- }
}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index e5d7dd13915..010a2c05a1c 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -174,6 +174,18 @@
}
}
+@include media-breakpoint-down(lg) {
+ .todos-filters {
+ .filter-categories {
+ width: 75%;
+
+ .filter-item {
+ margin-bottom: 10px;
+ }
+ }
+ }
+}
+
@include media-breakpoint-down(xs) {
.todo {
.avatar {
@@ -199,6 +211,10 @@
}
.todos-filters {
+ .filter-categories {
+ width: auto;
+ }
+
.dropdown-menu-toggle {
width: 100%;
}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 783831748a7..05ed3669a41 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -20,13 +20,13 @@ class ApplicationController < ActionController::Base
before_action :ldap_security_check
before_action :sentry_context
before_action :default_headers
- before_action :add_gon_variables, unless: :peek_request?
+ before_action :add_gon_variables, unless: [:peek_request?, :json_request?]
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
around_action :set_locale
- after_action :set_page_title_header, if: -> { request.format == :json }
+ after_action :set_page_title_header, if: :json_request?
protect_from_forgery with: :exception, prepend: true
@@ -35,6 +35,7 @@ class ApplicationController < ActionController::Base
:gitea_import_enabled?, :github_import_configured?,
:gitlab_import_enabled?, :gitlab_import_configured?,
:bitbucket_import_enabled?, :bitbucket_import_configured?,
+ :bitbucket_server_import_enabled?,
:google_code_import_enabled?, :fogbugz_import_enabled?,
:git_import_enabled?, :gitlab_project_import_enabled?,
:manifest_import_enabled?
@@ -337,6 +338,10 @@ class ApplicationController < ActionController::Base
!Gitlab::CurrentSettings.import_sources.empty?
end
+ def bitbucket_server_import_enabled?
+ Gitlab::CurrentSettings.import_sources.include?('bitbucket_server')
+ end
+
def github_import_enabled?
Gitlab::CurrentSettings.import_sources.include?('github')
end
@@ -419,6 +424,10 @@ class ApplicationController < ActionController::Base
request.path.start_with?('/-/peek')
end
+ def json_request?
+ request.format.json?
+ end
+
def should_enforce_terms?
return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms
diff --git a/app/controllers/concerns/todos_actions.rb b/app/controllers/concerns/todos_actions.rb
new file mode 100644
index 00000000000..c0acdb3498d
--- /dev/null
+++ b/app/controllers/concerns/todos_actions.rb
@@ -0,0 +1,12 @@
+module TodosActions
+ extend ActiveSupport::Concern
+
+ def create
+ todo = TodoService.new.mark_todo(issuable, current_user)
+
+ render json: {
+ count: TodosFinder.new(current_user, state: :pending).execute.count,
+ delete_path: dashboard_todo_path(todo)
+ }
+ end
+end
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index f9e8fe624e8..bd7111e28bc 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -70,7 +70,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def todo_params
- params.permit(:action_id, :author_id, :project_id, :type, :sort, :state)
+ params.permit(:action_id, :author_id, :project_id, :type, :sort, :state, :group_id)
end
def redirect_out_of_range(todos)
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
new file mode 100644
index 00000000000..798daeca6c9
--- /dev/null
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+class Import::BitbucketServerController < Import::BaseController
+ before_action :verify_bitbucket_server_import_enabled
+ before_action :bitbucket_auth, except: [:new, :configure]
+ before_action :validate_import_params, only: [:create]
+
+ # As a basic sanity check to prevent URL injection, restrict project
+ # repository input and repository slugs to allowed characters. For Bitbucket:
+ #
+ # Project keys must start with a letter and may only consist of ASCII letters, numbers and underscores (A-Z, a-z, 0-9, _).
+ #
+ # Repository names are limited to 128 characters. They must start with a
+ # letter or number and may contain spaces, hyphens, underscores, and periods.
+ # (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054)
+ VALID_BITBUCKET_CHARS = /\A[\w\-_\.\s]+\z/
+
+ def new
+ end
+
+ def create
+ repo = bitbucket_client.repo(@project_key, @repo_slug)
+
+ unless repo
+ return render json: { errors: "Project #{@project_key}/#{@repo_slug} could not be found" }, status: :unprocessable_entity
+ end
+
+ project_name = params[:new_name].presence || repo.name
+ namespace_path = params[:new_namespace].presence || current_user.username
+ target_namespace = find_or_create_namespace(namespace_path, current_user)
+
+ if current_user.can?(:create_projects, target_namespace)
+ project = Gitlab::BitbucketServerImport::ProjectCreator.new(@project_key, @repo_slug, repo, project_name, target_namespace, current_user, credentials).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project_save_error(project) }, status: :unprocessable_entity
+ end
+ else
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ end
+ rescue BitbucketServer::Client::ServerError => e
+ render json: { errors: "Unable to connect to server: #{e}" }, status: :unprocessable_entity
+ end
+
+ def configure
+ session[personal_access_token_key] = params[:personal_access_token]
+ session[bitbucket_server_username_key] = params[:bitbucket_username]
+ session[bitbucket_server_url_key] = params[:bitbucket_server_url]
+
+ redirect_to status_import_bitbucket_server_path
+ end
+
+ def status
+ repos = bitbucket_client.repos
+
+ @repos, @incompatible_repos = repos.partition { |repo| repo.valid? }
+
+ @already_added_projects = find_already_added_projects('bitbucket_server')
+ already_added_projects_names = @already_added_projects.pluck(:import_source)
+
+ @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.browse_url) }
+ rescue BitbucketServer::Connection::ConnectionError, BitbucketServer::Client::ServerError => e
+ flash[:alert] = "Unable to connect to server: #{e}"
+ clear_session_data
+ redirect_to new_import_bitbucket_server_path
+ end
+
+ def jobs
+ render json: find_jobs('bitbucket_server')
+ end
+
+ private
+
+ def bitbucket_client
+ @bitbucket_client ||= BitbucketServer::Client.new(credentials)
+ end
+
+ def validate_import_params
+ @project_key = params[:project]
+ @repo_slug = params[:repository]
+
+ return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present?
+ return render_validation_error('Missing repository slug') unless @repo_slug.present?
+ return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_CHARS
+ return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS
+ end
+
+ def render_validation_error(message)
+ render json: { errors: message }, status: :unprocessable_entity
+ end
+
+ def bitbucket_auth
+ unless session[bitbucket_server_url_key].present? &&
+ session[bitbucket_server_username_key].present? &&
+ session[personal_access_token_key].present?
+ redirect_to new_import_bitbucket_server_path
+ end
+ end
+
+ def verify_bitbucket_server_import_enabled
+ render_404 unless bitbucket_server_import_enabled?
+ end
+
+ def bitbucket_server_url_key
+ :bitbucket_server_url
+ end
+
+ def bitbucket_server_username_key
+ :bitbucket_server_username
+ end
+
+ def personal_access_token_key
+ :bitbucket_server_personal_access_token
+ end
+
+ def clear_session_data
+ session[bitbucket_server_url_key] = nil
+ session[bitbucket_server_username_key] = nil
+ session[personal_access_token_key] = nil
+ end
+
+ def credentials
+ {
+ base_uri: session[bitbucket_server_url_key],
+ user: session[bitbucket_server_username_key],
+ password: session[personal_access_token_key]
+ }
+ end
+end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index eaf4434f913..1b069fe507b 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -102,10 +102,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def test_reports
result = @merge_request.compare_test_reports
- Gitlab::PollingInterval.set_header(response, interval: 10_000)
-
case result[:status]
when :parsing
+ Gitlab::PollingInterval.set_header(response, interval: 3000)
+
render json: '', status: :no_content
when :parsed
render json: result[:data].to_json, status: :ok
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index cc7cce887bf..d118cec977c 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -5,7 +5,7 @@ class Projects::RunnersController < Projects::ApplicationController
layout 'project_settings'
def index
- redirect_to project_settings_ci_cd_path(@project)
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
end
def edit
@@ -50,13 +50,13 @@ class Projects::RunnersController < Projects::ApplicationController
def toggle_shared_runners
project.toggle!(:shared_runners_enabled)
- redirect_to project_settings_ci_cd_path(@project)
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
end
def toggle_group_runners
project.toggle_ci_cd_settings!(:group_runners_enabled)
- redirect_to project_settings_ci_cd_path(@project)
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
end
protected
diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb
index a41fcb85c40..93fb9da6510 100644
--- a/app/controllers/projects/todos_controller.rb
+++ b/app/controllers/projects/todos_controller.rb
@@ -1,19 +1,13 @@
class Projects::TodosController < Projects::ApplicationController
- before_action :authenticate_user!, only: [:create]
-
- def create
- todo = TodoService.new.mark_todo(issuable, current_user)
+ include Gitlab::Utils::StrongMemoize
+ include TodosActions
- render json: {
- count: TodosFinder.new(current_user, state: :pending).execute.count,
- delete_path: dashboard_todo_path(todo)
- }
- end
+ before_action :authenticate_user!, only: [:create]
private
def issuable
- @issuable ||= begin
+ strong_memoize(:issuable) do
case params[:issuable_type]
when "issue"
IssuesFinder.new(current_user, project_id: @project.id).find(params[:issuable_id])
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index 6f3de43f85a..cb12b707087 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -7,7 +7,7 @@ class Projects::TriggersController < Projects::ApplicationController
layout 'project_settings'
def index
- redirect_to project_settings_ci_cd_path(@project)
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
end
def create
@@ -19,7 +19,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = 'You could not create a new trigger.'
end
- redirect_to project_settings_ci_cd_path(@project)
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
end
def take_ownership
@@ -29,7 +29,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = 'You could not take ownership of trigger.'
end
- redirect_to project_settings_ci_cd_path(@project)
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
end
def edit
@@ -37,7 +37,7 @@ class Projects::TriggersController < Projects::ApplicationController
def update
if trigger.update(trigger_params)
- redirect_to project_settings_ci_cd_path(@project), notice: 'Trigger was successfully updated.'
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: 'Trigger was successfully updated.'
else
render action: "edit"
end
@@ -50,7 +50,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = "Could not remove the trigger."
end
- redirect_to project_settings_ci_cd_path(@project), status: :found
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), status: :found
end
private
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index 09e2c586f2a..6e9c8ea6fde 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -15,6 +15,7 @@
class TodosFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
+ include Gitlab::Utils::StrongMemoize
requires_cross_project_access unless: -> { project? }
@@ -34,6 +35,7 @@ class TodosFinder
items = by_author(items)
items = by_state(items)
items = by_type(items)
+ items = by_group(items)
# Filtering by project HAS TO be the last because we use
# the project IDs yielded by the todos query thus far
items = by_project(items)
@@ -82,6 +84,10 @@ class TodosFinder
params[:project_id].present?
end
+ def group?
+ params[:group_id].present?
+ end
+
def project
return @project if defined?(@project)
@@ -89,10 +95,6 @@ class TodosFinder
@project = Project.find(params[:project_id])
@project = nil if @project.pending_delete?
-
- unless Ability.allowed?(current_user, :read_project, @project)
- @project = nil
- end
else
@project = nil
end
@@ -100,18 +102,14 @@ class TodosFinder
@project
end
- def project_ids(items)
- ids = items.except(:order).select(:project_id)
- if Gitlab::Database.mysql?
- # To make UPDATE work on MySQL, wrap it in a SELECT with an alias
- ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t")
+ def group
+ strong_memoize(:group) do
+ Group.find(params[:group_id])
end
-
- ids
end
def type?
- type.present? && %w(Issue MergeRequest).include?(type)
+ type.present? && %w(Issue MergeRequest Epic).include?(type)
end
def type
@@ -148,12 +146,23 @@ class TodosFinder
def by_project(items)
if project?
- items.where(project: project)
- else
- projects = Project.public_or_visible_to_user(current_user)
+ items = items.where(project: project)
+ end
- items.joins(:project).merge(projects)
+ items
+ end
+
+ def by_group(items)
+ if group?
+ groups = group.self_and_descendants
+ project_todos = items.where(project_id: Project.where(group: groups).select(:id))
+ group_todos = items.where(group_id: groups.select(:id))
+
+ union = Gitlab::SQL::Union.new([project_todos, group_todos])
+ items = Todo.from("(#{union.to_sql}) #{Todo.table_name}")
end
+
+ items
end
def by_state(items)
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index a9499140f8a..2bdf2c2c120 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -255,7 +255,8 @@ module ApplicationSettingsHelper
:instance_statistics_visibility_private,
:user_default_external,
:user_oauth_applications,
- :version_check_enabled
+ :version_check_enabled,
+ :web_ide_clientside_preview_enabled
]
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 678fed9c414..c84ed8091c3 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -131,6 +131,19 @@ module IssuablesHelper
end
end
+ def group_dropdown_label(group_id, default_label)
+ return default_label if group_id.nil?
+ return "Any group" if group_id == "0"
+
+ group = ::Group.find_by(id: group_id)
+
+ if group
+ group.full_name
+ else
+ default_label
+ end
+ end
+
def milestone_dropdown_label(milestone_title, default_label = "Milestone")
title =
case milestone_title
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 9008db1b300..30585cb403d 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -3,19 +3,28 @@ module NamespacesHelper
params.dig(:project, :namespace_id) || params[:namespace_id]
end
- def namespaces_options(selected = :current_user, display_path: false, extra_group: nil, groups_only: false)
- groups = current_user.manageable_groups
- .joins(:route)
- .includes(:route)
- .order('routes.path')
+ def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false)
+ groups ||= current_user.manageable_groups
+ .eager_load(:route)
+ .order('routes.path')
users = [current_user.namespace]
+ selected_id = selected
unless extra_group.nil? || extra_group.is_a?(Group)
extra_group = Group.find(extra_group) if Namespace.find(extra_group).kind == 'group'
end
- if extra_group && extra_group.is_a?(Group) && (!Group.exists?(name: extra_group.name) || Ability.allowed?(current_user, :read_group, extra_group))
- groups |= [extra_group]
+ if extra_group && extra_group.is_a?(Group)
+ extra_group = dedup_extra_group(extra_group)
+
+ if Ability.allowed?(current_user, :read_group, extra_group)
+ # Assign the value to an invalid primary ID so that the select box works
+ extra_group.id = -1 unless extra_group.persisted?
+ selected_id = extra_group.id if selected == :extra_group
+ groups |= [extra_group]
+ else
+ selected_id = current_user.namespace.id
+ end
end
options = []
@@ -25,11 +34,11 @@ module NamespacesHelper
options << options_for_group(users, display_path: display_path, type: 'user')
if selected == :current_user && current_user.namespace
- selected = current_user.namespace.id
+ selected_id = current_user.namespace.id
end
end
- grouped_options_for_select(options, selected)
+ grouped_options_for_select(options, selected_id)
end
def namespace_icon(namespace, size = 40)
@@ -42,6 +51,17 @@ module NamespacesHelper
private
+ # Many importers create a temporary Group, so use the real
+ # group if one exists by that name to prevent duplicates.
+ def dedup_extra_group(extra_group)
+ unless extra_group.persisted?
+ existing_group = Group.find_by(name: extra_group.name)
+ extra_group = existing_group if existing_group&.persisted?
+ end
+
+ extra_group
+ end
+
def options_for_group(namespaces, display_path:, type:)
group_label = type.pluralize
elements = namespaces.sort_by(&:human_name).map! do |n|
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index a6a57db3002..e7aa92e6e5c 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -9,8 +9,4 @@ module ProfilesHelper
end
end
end
-
- def show_user_status_field?
- Feature.enabled?(:user_status_form) || cookies[:feature_user_status_form] == 'true'
- end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index cadb88ba632..98074a4c0c5 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -82,16 +82,16 @@ module SearchHelper
ref = @ref || @project.repository.root_ref
[
- { category: "Current Project", label: "Files", url: project_tree_path(@project, ref) },
- { category: "Current Project", label: "Commits", url: project_commits_path(@project, ref) },
- { category: "Current Project", label: "Network", url: project_network_path(@project, ref) },
- { category: "Current Project", label: "Graph", url: project_graph_path(@project, ref) },
- { category: "Current Project", label: "Issues", url: project_issues_path(@project) },
- { category: "Current Project", label: "Merge Requests", url: project_merge_requests_path(@project) },
- { category: "Current Project", label: "Milestones", url: project_milestones_path(@project) },
- { category: "Current Project", label: "Snippets", url: project_snippets_path(@project) },
- { category: "Current Project", label: "Members", url: project_project_members_path(@project) },
- { category: "Current Project", label: "Wiki", url: project_wikis_path(@project) }
+ { category: "In this project", label: "Files", url: project_tree_path(@project, ref) },
+ { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) },
+ { category: "In this project", label: "Network", url: project_network_path(@project, ref) },
+ { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) },
+ { category: "In this project", label: "Issues", url: project_issues_path(@project) },
+ { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) },
+ { category: "In this project", label: "Milestones", url: project_milestones_path(@project) },
+ { category: "In this project", label: "Snippets", url: project_snippets_path(@project) },
+ { category: "In this project", label: "Members", url: project_project_members_path(@project) },
+ { category: "In this project", label: "Wiki", url: project_wikis_path(@project) }
]
else
[]
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index f7620e0b6b8..7cd74358168 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -43,7 +43,7 @@ module TodosHelper
project_commit_path(todo.project,
todo.target, anchor: anchor)
else
- path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target]
+ path = [todo.parent, todo.target]
path.unshift(:pipelines) if todo.build_failed?
@@ -167,4 +167,12 @@ module TodosHelper
def show_todo_state?(todo)
(todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state)
end
+
+ def todo_group_options
+ groups = current_user.authorized_groups.map do |group|
+ { id: group.id, text: group.full_name }
+ end
+
+ groups.unshift({ id: '', text: 'Any Group' }).to_json
+ end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 75dfa00d12e..e4aed76f611 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -606,12 +606,12 @@ module Ci
end
def has_test_reports?
- complete? && builds.with_test_reports.any?
+ complete? && builds.latest.with_test_reports.any?
end
def test_reports
Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
- builds.with_test_reports.each do |build|
+ builds.latest.with_test_reports.each do |build|
build.collect_test_reports!(test_reports)
end
end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index 61df6174c86..55bbf7cae7e 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -1,15 +1,28 @@
# frozen_string_literal: true
+require 'openssl'
+
module Clusters
module Applications
class Helm < ActiveRecord::Base
self.table_name = 'clusters_applications_helm'
+ attr_encrypted :ca_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-cbc'
+
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
default_value_for :version, Gitlab::Kubernetes::Helm::HELM_VERSION
+ before_create :create_keys_and_certs
+
+ def issue_client_cert
+ ca_cert_obj.issue
+ end
+
def set_initial_status
return unless not_installable?
@@ -17,7 +30,41 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InitCommand.new(name)
+ Gitlab::Kubernetes::Helm::InitCommand.new(
+ name: name,
+ files: files
+ )
+ end
+
+ def has_ssl?
+ ca_key.present? && ca_cert.present?
+ end
+
+ private
+
+ def files
+ {
+ 'ca.pem': ca_cert,
+ 'cert.pem': tiller_cert.cert_string,
+ 'key.pem': tiller_cert.key_string
+ }
+ end
+
+ def create_keys_and_certs
+ ca_cert = Gitlab::Kubernetes::Helm::Certificate.generate_root
+ self.ca_key = ca_cert.key_string
+ self.ca_cert = ca_cert.cert_string
+ end
+
+ def tiller_cert
+ @tiller_cert ||= ca_cert_obj.issue(expires_in: Gitlab::Kubernetes::Helm::Certificate::INFINITE_EXPIRY)
+ end
+
+ def ca_cert_obj
+ return unless has_ssl?
+
+ Gitlab::Kubernetes::Helm::Certificate
+ .from_strings(ca_key, ca_cert)
end
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 2440efe76ab..93f654e0638 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -37,10 +37,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values
+ files: files
)
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 33d54ba86fe..ef1c76c03bd 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -38,10 +38,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values,
+ files: files,
repository: repository
)
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index ccb415b3fe2..88399dbbb95 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -46,10 +46,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values
+ files: files
)
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 426aed91089..bde255723c8 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -31,10 +31,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values,
+ files: files,
repository: repository
)
end
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
index 14e004b9a57..52498f123ff 100644
--- a/app/models/clusters/concerns/application_data.rb
+++ b/app/models/clusters/concerns/application_data.rb
@@ -14,8 +14,34 @@ module Clusters
File.read(chart_values_file)
end
+ def files
+ @files ||= begin
+ files = { 'values.yaml': values }
+
+ files.merge!(certificate_files) if cluster.application_helm.has_ssl?
+
+ files
+ end
+ end
+
private
+ def certificate_files
+ {
+ 'ca.pem': ca_cert,
+ 'cert.pem': helm_cert.cert_string,
+ 'key.pem': helm_cert.key_string
+ }
+ end
+
+ def ca_cert
+ cluster.application_helm.ca_cert
+ end
+
+ def helm_cert
+ @helm_cert ||= cluster.application_helm.issue_client_cert
+ end
+
def chart_values_file
"#{Rails.root}/vendor/#{name}/values.yaml"
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 095897b08e3..a6d604a580d 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -19,7 +19,7 @@ module Avatarable
# We use avatar_path instead of overriding avatar_url because of carrierwave.
# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
- avatar_path(only_path: args.fetch(:only_path, true)) || super
+ avatar_path(only_path: args.fetch(:only_path, true), size: args[:size]) || super
end
def retrieve_upload(identifier, paths)
@@ -40,12 +40,13 @@ module Avatarable
end
end
- def avatar_path(only_path: true)
+ def avatar_path(only_path: true, size: nil)
return unless self[:avatar].present?
asset_host = ActionController::Base.asset_host
use_asset_host = asset_host.present?
use_authentication = respond_to?(:public?) && !public?
+ query_params = size&.nonzero? ? "?width=#{size}" : ""
# Avatars for private and internal groups and projects require authentication to be viewed,
# which means they can only be served by Rails, on the regular GitLab host.
@@ -64,7 +65,7 @@ module Avatarable
url_base << gitlab_config.relative_url_root
end
- url_base + avatar.local_url
+ url_base + avatar.local_url + query_params
end
# Path that is persisted in the tracking Upload model. Used to fetch the
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index b93c1145f82..1588f76989b 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -154,7 +154,7 @@ module Issuable
end
# Break ties with the ID column for pagination
- sorted.order(id: :desc)
+ sorted.with_order_id_desc
end
def order_due_date_and_labels_priority(excluded_labels: [])
@@ -243,6 +243,12 @@ module Issuable
opened?
end
+ def overdue?
+ return false unless respond_to?(:due_date)
+
+ due_date.try(:past?) || false
+ end
+
def user_notes_count
if notes.loaded?
# Use the in-memory association to select and count to avoid hitting the db
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 9155d82d567..65cc7a751f9 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -42,6 +42,8 @@
module ReactiveCaching
extend ActiveSupport::Concern
+ InvalidateReactiveCache = Class.new(StandardError)
+
included do
class_attribute :reactive_cache_lease_timeout
@@ -63,15 +65,19 @@ module ReactiveCaching
end
def with_reactive_cache(*args, &blk)
- bootstrap = !within_reactive_cache_lifetime?(*args)
- Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime)
+ unless within_reactive_cache_lifetime?(*args)
+ refresh_reactive_cache!(*args)
+ return nil
+ end
- if bootstrap
- ReactiveCachingWorker.perform_async(self.class, id, *args)
- nil
- else
+ keep_alive_reactive_cache!(*args)
+
+ begin
data = Rails.cache.read(full_reactive_cache_key(*args))
yield data if data.present?
+ rescue InvalidateReactiveCache
+ refresh_reactive_cache!(*args)
+ nil
end
end
@@ -96,6 +102,16 @@ module ReactiveCaching
private
+ def refresh_reactive_cache!(*args)
+ clear_reactive_cache!(*args)
+ keep_alive_reactive_cache!(*args)
+ ReactiveCachingWorker.perform_async(self.class, id, *args)
+ end
+
+ def keep_alive_reactive_cache!(*args)
+ Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime)
+ end
+
def full_reactive_cache_key(*qualifiers)
prefix = self.class.reactive_cache_key
prefix = prefix.call(self) if prefix.respond_to?(:call)
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index cb76ae971d4..409255fb68b 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -6,6 +6,7 @@ module Sortable
extend ActiveSupport::Concern
included do
+ scope :with_order_id_desc, -> { order(id: :desc) }
scope :order_id_desc, -> { reorder(id: :desc) }
scope :order_id_asc, -> { reorder(id: :asc) }
scope :order_created_desc, -> { reorder(created_at: :desc) }
diff --git a/app/models/group.rb b/app/models/group.rb
index cd548fc0061..106a1f4a94c 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -41,6 +41,8 @@ class Group < Namespace
has_many :boards
has_many :badges, class_name: 'GroupBadge'
+ has_many :todos
+
accepts_nested_attributes_for :variables, allow_destroy: true
validate :visibility_level_allowed_by_projects
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 0d135f54038..94cf12f3c2b 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -278,10 +278,6 @@ class Issue < ActiveRecord::Base
user ? readable_by?(user) : publicly_visible?
end
- def overdue?
- due_date.try(:past?) || false
- end
-
def check_for_spam?
project.public? && (title_changed? || description_changed?)
end
diff --git a/app/models/list.rb b/app/models/list.rb
index eabe3ffccbb..1a30acc83cf 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -4,7 +4,7 @@ class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
- enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 }
+ enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 }
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
@@ -27,11 +27,11 @@ class List < ActiveRecord::Base
end
def destroyable?
- label?
+ self.class.destroyable_types.include?(list_type&.to_sym)
end
def movable?
- label?
+ self.class.movable_types.include?(list_type&.to_sym)
end
def title
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index acad8b91e9f..396647a14ae 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -16,8 +16,8 @@ class MergeRequest < ActiveRecord::Base
include ReactiveCaching
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
- self.reactive_cache_refresh_interval = 1.hour
- self.reactive_cache_lifetime = 1.hour
+ self.reactive_cache_refresh_interval = 10.minutes
+ self.reactive_cache_lifetime = 10.minutes
ignore_column :locked_at,
:ref_fetched,
@@ -1041,16 +1041,21 @@ class MergeRequest < ActiveRecord::Base
return { status: :error, status_reason: 'This merge request does not have test reports' }
end
- with_reactive_cache(
- :compare_test_results,
- base_pipeline&.iid,
- actual_head_pipeline.iid) { |data| data } || { status: :parsing }
+ with_reactive_cache(:compare_test_results) do |data|
+ unless Ci::CompareTestReportsService.new(project)
+ .latest?(base_pipeline, actual_head_pipeline, data)
+ raise InvalidateReactiveCache
+ end
+
+ data
+ end || { status: :parsing }
end
def calculate_reactive_cache(identifier, *args)
case identifier.to_sym
when :compare_test_results
- Ci::CompareTestReportsService.new(project).execute(*args)
+ Ci::CompareTestReportsService.new(project).execute(
+ base_pipeline, actual_head_pipeline)
else
raise NotImplementedError, "Unknown identifier: #{identifier}"
end
@@ -1089,23 +1094,29 @@ class MergeRequest < ActiveRecord::Base
def can_be_reverted?(current_user)
return false unless merge_commit
+ return false unless merged_at
- merged_at = metrics&.merged_at
- notes_association = notes_with_associations
+ # It is not guaranteed that Note#created_at will be strictly later than
+ # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this
+ # comparison, as will a HA environment if clocks are not *precisely*
+ # synchronized. Add a minute's leeway to compensate for both possibilities
+ cutoff = merged_at - 1.minute
- if merged_at
- # It is not guaranteed that Note#created_at will be strictly later than
- # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this
- # comparison, as will a HA environment if clocks are not *precisely*
- # synchronized. Add a minute's leeway to compensate for both possibilities
- cutoff = merged_at - 1.minute
-
- notes_association = notes_association.where('created_at >= ?', cutoff)
- end
+ notes_association = notes_with_associations.where('created_at >= ?', cutoff)
!merge_commit.has_been_reverted?(current_user, notes_association)
end
+ def merged_at
+ strong_memoize(:merged_at) do
+ next unless merged?
+
+ metrics&.merged_at ||
+ merge_event&.created_at ||
+ notes.system.reorder(nil).find_by(note: 'merged')&.created_at
+ end
+ end
+
def can_be_cherry_picked?
merge_commit.present?
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index f2b2d291da9..cb1def1b422 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -146,22 +146,25 @@ class Milestone < ActiveRecord::Base
end
def self.sort_by_attribute(method)
- case method.to_s
- when 'due_date_asc'
- reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC'))
- when 'due_date_desc'
- reorder(Gitlab::Database.nulls_last_order('due_date', 'DESC'))
- when 'name_asc'
- reorder(Arel::Nodes::Ascending.new(arel_table[:title].lower))
- when 'name_desc'
- reorder(Arel::Nodes::Descending.new(arel_table[:title].lower))
- when 'start_date_asc'
- reorder(Gitlab::Database.nulls_last_order('start_date', 'ASC'))
- when 'start_date_desc'
- reorder(Gitlab::Database.nulls_last_order('start_date', 'DESC'))
- else
- order_by(method)
- end
+ sorted =
+ case method.to_s
+ when 'due_date_asc'
+ reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC'))
+ when 'due_date_desc'
+ reorder(Gitlab::Database.nulls_last_order('due_date', 'DESC'))
+ when 'name_asc'
+ reorder(Arel::Nodes::Ascending.new(arel_table[:title].lower))
+ when 'name_desc'
+ reorder(Arel::Nodes::Descending.new(arel_table[:title].lower))
+ when 'start_date_asc'
+ reorder(Gitlab::Database.nulls_last_order('start_date', 'ASC'))
+ when 'start_date_desc'
+ reorder(Gitlab::Database.nulls_last_order('start_date', 'DESC'))
+ else
+ order_by(method)
+ end
+
+ sorted.with_order_id_desc
end
##
diff --git a/app/models/note.rb b/app/models/note.rb
index 969d34ae09a..2e343b8f9f8 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -231,6 +231,10 @@ class Note < ActiveRecord::Base
!for_personal_snippet?
end
+ def for_issuable?
+ for_issue? || for_merge_request?
+ end
+
def skip_project_check?
!for_project_noteable?
end
diff --git a/app/models/project.rb b/app/models/project.rb
index cb4d2610e0d..36089995ed3 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -507,6 +507,10 @@ class Project < ActiveRecord::Base
end
end
+ def has_auto_devops_implicitly_enabled?
+ auto_devops&.enabled.nil? && Gitlab::CurrentSettings.auto_devops_enabled?
+ end
+
def has_auto_devops_implicitly_disabled?
auto_devops&.enabled.nil? && !Gitlab::CurrentSettings.auto_devops_enabled?
end
@@ -654,6 +658,8 @@ class Project < ActiveRecord::Base
project_import_data.credentials ||= {}
project_import_data.credentials = project_import_data.credentials.merge(credentials)
end
+
+ project_import_data
end
def import?
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 5f5c2f9073d..48d92ad04b3 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -24,15 +24,18 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
+ belongs_to :group
belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
- validates :action, :project, :target_type, :user, presence: true
+ validates :action, :target_type, :user, presence: true
validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
+ validates :project, presence: true, unless: :group_id
+ validates :group, presence: true, unless: :project_id
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
@@ -46,7 +49,7 @@ class Todo < ActiveRecord::Base
state :done
end
- after_save :keep_around_commit
+ after_save :keep_around_commit, if: :commit_id
class << self
# Priority sorting isn't displayed in the dropdown, because we don't show
@@ -81,6 +84,10 @@ class Todo < ActiveRecord::Base
end
end
+ def parent
+ project
+ end
+
def unmergeable?
action == UNMERGEABLE
end
diff --git a/app/services/ci/compare_test_reports_service.rb b/app/services/ci/compare_test_reports_service.rb
index 7a112211d94..ec25e934a27 100644
--- a/app/services/ci/compare_test_reports_service.rb
+++ b/app/services/ci/compare_test_reports_service.rb
@@ -2,23 +2,36 @@
module Ci
class CompareTestReportsService < ::BaseService
- def execute(base_pipeline_iid, head_pipeline_iid)
- base_pipeline = project.pipelines.find_by_iid(base_pipeline_iid) if base_pipeline_iid
- head_pipeline = project.pipelines.find_by_iid(head_pipeline_iid)
+ def execute(base_pipeline, head_pipeline)
+ comparer = Gitlab::Ci::Reports::TestReportsComparer
+ .new(base_pipeline&.test_reports, head_pipeline.test_reports)
- begin
- comparer = Gitlab::Ci::Reports::TestReportsComparer
- .new(base_pipeline&.test_reports, head_pipeline.test_reports)
+ {
+ status: :parsed,
+ key: key(base_pipeline, head_pipeline),
+ data: TestReportsComparerSerializer
+ .new(project: project)
+ .represent(comparer).as_json
+ }
+ rescue => e
+ {
+ status: :error,
+ key: key(base_pipeline, head_pipeline),
+ status_reason: e.message
+ }
+ end
+
+ def latest?(base_pipeline, head_pipeline, data)
+ data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
+ end
+
+ private
- {
- status: :parsed,
- data: TestReportsComparerSerializer
- .new(project: project)
- .represent(comparer).as_json
- }
- rescue => e
- { status: :error, status_reason: e.message }
- end
+ def key(base_pipeline, head_pipeline)
+ [
+ base_pipeline&.id, base_pipeline&.updated_at,
+ head_pipeline&.id, head_pipeline&.updated_at
+ ]
end
end
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 436a6b18cb1..fe47aa2f140 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -14,7 +14,9 @@ module Groups
group.assign_attributes(params)
begin
- group.save
+ after_update if group.save
+
+ true
rescue Gitlab::UpdatePathError => e
group.errors.add(:base, e.message)
@@ -24,6 +26,13 @@ module Groups
private
+ 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
+ TodosDestroyer::GroupPrivateWorker.perform_in(1.hour, group.id)
+ end
+ end
+
def reject_parent_id!
params.except!(:parent_id)
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 0bcd53c76a9..0df61ad3bce 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -262,15 +262,15 @@ class TodoService
end
end
- def create_mention_todos(project, target, author, note = nil, skip_users = [])
+ def create_mention_todos(parent, target, author, note = nil, skip_users = [])
# Create Todos for directly addressed users
- directly_addressed_users = filter_directly_addressed_users(project, note || target, author, skip_users)
- attributes = attributes_for_todo(project, target, author, Todo::DIRECTLY_ADDRESSED, note)
+ directly_addressed_users = filter_directly_addressed_users(parent, note || target, author, skip_users)
+ attributes = attributes_for_todo(parent, target, author, Todo::DIRECTLY_ADDRESSED, note)
create_todos(directly_addressed_users, attributes)
# Create Todos for mentioned users
- mentioned_users = filter_mentioned_users(project, note || target, author, skip_users)
- attributes = attributes_for_todo(project, target, author, Todo::MENTIONED, note)
+ mentioned_users = filter_mentioned_users(parent, note || target, author, skip_users)
+ attributes = attributes_for_todo(parent, target, author, Todo::MENTIONED, note)
create_todos(mentioned_users, attributes)
end
@@ -301,36 +301,36 @@ class TodoService
def attributes_for_todo(project, target, author, action, note = nil)
attributes_for_target(target).merge!(
- project_id: project.id,
+ project_id: project&.id,
author_id: author.id,
action: action,
note: note
)
end
- def filter_todo_users(users, project, target)
- reject_users_without_access(users, project, target).uniq
+ def filter_todo_users(users, parent, target)
+ reject_users_without_access(users, parent, target).uniq
end
- def filter_mentioned_users(project, target, author, skip_users = [])
+ def filter_mentioned_users(parent, target, author, skip_users = [])
mentioned_users = target.mentioned_users(author) - skip_users
- filter_todo_users(mentioned_users, project, target)
+ filter_todo_users(mentioned_users, parent, target)
end
- def filter_directly_addressed_users(project, target, author, skip_users = [])
+ def filter_directly_addressed_users(parent, target, author, skip_users = [])
directly_addressed_users = target.directly_addressed_users(author) - skip_users
- filter_todo_users(directly_addressed_users, project, target)
+ filter_todo_users(directly_addressed_users, parent, target)
end
- def reject_users_without_access(users, project, target)
- if target.is_a?(Note) && (target.for_issue? || target.for_merge_request?)
+ def reject_users_without_access(users, parent, target)
+ if target.is_a?(Note) && target.for_issuable?
target = target.noteable
end
if target.is_a?(Issuable)
select_users(users, :"read_#{target.to_ability_name}", target)
else
- select_users(users, :read_project, project)
+ select_users(users, :read_project, parent)
end
end
diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb
index 2ff9f94b718..045f5ecaae7 100644
--- a/app/services/todos/destroy/entity_leave_service.rb
+++ b/app/services/todos/destroy/entity_leave_service.rb
@@ -3,55 +3,97 @@ module Todos
class EntityLeaveService < ::Todos::Destroy::BaseService
extend ::Gitlab::Utils::Override
- attr_reader :user_id, :entity
+ attr_reader :user, :entity
def initialize(user_id, entity_id, entity_type)
unless %w(Group Project).include?(entity_type)
raise ArgumentError.new("#{entity_type} is not an entity user can leave")
end
- @user_id = user_id
+ @user = User.find_by(id: user_id)
@entity = entity_type.constantize.find_by(id: entity_id)
end
- private
+ def execute
+ return unless entity && user
+
+ # if at least reporter, all entities including confidential issues can be accessed
+ return if user_has_reporter_access?
+
+ remove_confidential_issue_todos
- override :todos
- def todos
if entity.private?
- Todo.where(project_id: project_ids, user_id: user_id)
+ remove_project_todos
+ remove_group_todos
else
- project_ids.each do |project_id|
- TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user_id)
- end
+ enqueue_private_features_worker
+ end
+ end
+
+ private
- Todo.where(
- target_id: confidential_issues.select(:id), target_type: Issue, user_id: user_id
- )
+ def enqueue_private_features_worker
+ project_ids.each do |project_id|
+ TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user.id)
end
end
+ def remove_confidential_issue_todos
+ Todo.where(
+ target_id: confidential_issues.select(:id), target_type: Issue, user_id: user.id
+ ).delete_all
+ end
+
+ def remove_project_todos
+ Todo.where(project_id: non_authorized_projects, user_id: user.id).delete_all
+ end
+
+ def remove_group_todos
+ Todo.where(group_id: non_authorized_groups, user_id: user.id).delete_all
+ end
+
override :project_ids
def project_ids
- case entity
- when Project
- [entity.id]
- when Namespace
- Project.select(:id).where(namespace_id: entity.self_and_descendants.select(:id))
- end
+ condition = case entity
+ when Project
+ { id: entity.id }
+ when Namespace
+ { namespace_id: non_member_groups }
+ end
+
+ Project.where(condition).select(:id)
end
- override :todos_to_remove?
- def todos_to_remove?
- # if an entity is provided we want to check always at least private features
- !!entity
+ def non_authorized_projects
+ project_ids.where('id NOT IN (?)', user.authorized_projects.select(:id))
+ end
+
+ def non_authorized_groups
+ return [] unless entity.is_a?(Namespace)
+
+ entity.self_and_descendants.select(:id)
+ .where('id NOT IN (?)', GroupsFinder.new(user).execute.select(:id))
+ end
+
+ def non_member_groups
+ entity.self_and_descendants.select(:id)
+ .where('id NOT IN (?)', user.membership_groups.select(:id))
+ end
+
+ def user_has_reporter_access?
+ return unless entity.is_a?(Namespace)
+
+ entity.member?(User.find(user.id), Gitlab::Access::REPORTER)
end
def confidential_issues
- assigned_ids = IssueAssignee.select(:issue_id).where(user_id: user_id)
+ assigned_ids = IssueAssignee.select(:issue_id).where(user_id: user.id)
+ authorized_reporter_projects = user
+ .authorized_projects(Gitlab::Access::REPORTER).select(:id)
Issue.where(project_id: project_ids, confidential: true)
- .where('author_id != ?', user_id)
+ .where('project_id NOT IN(?)', authorized_reporter_projects)
+ .where('author_id != ?', user.id)
.where('id NOT IN (?)', assigned_ids)
end
end
diff --git a/app/services/todos/destroy/group_private_service.rb b/app/services/todos/destroy/group_private_service.rb
new file mode 100644
index 00000000000..d13fa7a6516
--- /dev/null
+++ b/app/services/todos/destroy/group_private_service.rb
@@ -0,0 +1,30 @@
+module Todos
+ module Destroy
+ class GroupPrivateService < ::Todos::Destroy::BaseService
+ extend ::Gitlab::Utils::Override
+
+ attr_reader :group
+
+ def initialize(group_id)
+ @group = Group.find_by(id: group_id)
+ end
+
+ private
+
+ override :todos
+ def todos
+ Todo.where(group_id: group.id)
+ end
+
+ override :authorized_users
+ def authorized_users
+ group.direct_and_indirect_users.select(:id)
+ end
+
+ override :todos_to_remove?
+ def todos_to_remove?
+ group&.private?
+ end
+ end
+ end
+end
diff --git a/app/services/todos/destroy/project_private_service.rb b/app/services/todos/destroy/project_private_service.rb
index 171933e7cbc..315a0c33398 100644
--- a/app/services/todos/destroy/project_private_service.rb
+++ b/app/services/todos/destroy/project_private_service.rb
@@ -13,7 +13,7 @@ module Todos
override :todos
def todos
- Todo.where(project_id: project_ids)
+ Todo.where(project_id: project.id)
end
override :project_ids
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 472616b1315..5037017e38a 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -3,13 +3,15 @@
%fieldset
.form-group
- .form-check
- = f.check_box :auto_devops_enabled, class: 'form-check-input'
- = f.label :auto_devops_enabled, class: 'form-check-label' do
- Enabled Auto DevOps for projects by default
- .form-text.text-muted
- It will automatically build, test, and deploy applications based on a predefined CI/CD configuration
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md')
+ .card.auto-devops-card
+ .card-body
+ .form-check
+ = f.check_box :auto_devops_enabled, class: 'form-check-input'
+ = f.label :auto_devops_enabled, class: 'form-check-label' do
+ Default to Auto DevOps pipeline for all projects
+ .form-text.text-muted
+ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
+ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
.form-group
= f.label :auto_devops_domain, class: 'label-bold'
= f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com'
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index 1c8801566d4..258d50ad676 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -338,4 +338,27 @@
= render_if_exists 'admin/application_settings/custom_templates_form', expanded: expanded
+%section.settings.no-animate#js-web-ide-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Web IDE')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _('Manage Web IDE features')
+ .settings-content
+ = form_for @application_setting, url: admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .form-check
+ = f.check_box :web_ide_clientside_preview_enabled, class: 'form-check-input'
+ = f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do
+ = s_('IDE|Client side evaluation')
+ %span.form-text.text-muted
+ = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation.')
+
+ = f.submit _('Save changes'), class: "btn btn-success"
+
= render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index c3ea2352898..dbb7224f5f9 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,7 +1,7 @@
-%li{ id: dom_id(label) }
- .label-row
- = render_colored_label(label, tooltip: false)
- = markdown_field(label, :description)
- .float-right
- = link_to _('Edit'), edit_admin_label_path(label), class: 'btn btn-sm'
- = link_to _('Delete'), admin_label_path(label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Delete this label? Are you sure?"}
+%li.label-list-item{ id: dom_id(label) }
+ = render "shared/label_row", label: label
+ .label-actions-list
+ = link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = sprite_icon('pencil')
+ = link_to admin_label_path(label), class: 'btn btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
+ = sprite_icon('remove')
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index d3e5247447a..f1b8658f84e 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -7,10 +7,11 @@
= _('Labels')
%hr
-.labels
+.labels.labels-container.admin-labels
- if @labels.present?
- %ul.bordered-list.manage-labels-list
+ %ul.manage-labels-list
= render @labels
+
= paginate @labels, theme: 'gitlab'
- else
.card.bg-light
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index fdaacc098e0..50296a2afe7 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -20,7 +20,7 @@
= link_to(admin_namespace_project_path(project.namespace, project)) do
.dash-project-avatar
.avatar-container.s40
- = project_icon(project, alt: '', class: 'avatar project-avatar s40')
+ = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
%span.project-full-name
%span.namespace-name
- if project.namespace
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index d5a9cc646a6..8b3974d97f8 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -30,27 +30,33 @@
.todos-filters
.row-content-block.second-block
- = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form' do
- .filter-item.inline
- - if params[:project_id].present?
- = hidden_field_tag(:project_id, params[:project_id])
- = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
- placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } })
- .filter-item.inline
- - if params[:author_id].present?
- = hidden_field_tag(:author_id, params[:author_id])
- = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit',
- placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } })
- .filter-item.inline
- - if params[:type].present?
- = hidden_field_tag(:type, params[:type])
- = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit',
- data: { data: todo_types_options, default_label: 'Type' } })
- .filter-item.inline.actions-filter
- - if params[:action_id].present?
- = hidden_field_tag(:action_id, params[:action_id])
- = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
- data: { data: todo_actions_options, default_label: 'Action' } })
+ = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do
+ .filter-categories.flex-fill
+ .filter-item.inline
+ - if params[:group_id].present?
+ = hidden_field_tag(:group_id, params[:group_id])
+ = dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit',
+ placeholder: 'Search groups', data: { data: todo_group_options, default_label: 'Group', display: 'static' } })
+ .filter-item.inline
+ - if params[:project_id].present?
+ = hidden_field_tag(:project_id, params[:project_id])
+ = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
+ placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } })
+ .filter-item.inline
+ - if params[:author_id].present?
+ = hidden_field_tag(:author_id, params[:author_id])
+ = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit',
+ placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } })
+ .filter-item.inline
+ - if params[:type].present?
+ = hidden_field_tag(:type, params[:type])
+ = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit',
+ data: { data: todo_types_options, default_label: 'Type' } })
+ .filter-item.inline.actions-filter
+ - if params[:action_id].present?
+ = hidden_field_tag(:action_id, params[:action_id])
+ = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
+ data: { data: todo_actions_options, default_label: 'Action' } })
.filter-item.sort-filter
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', 'data-toggle' => 'dropdown' }
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index ca62a59d909..74791b81ccd 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -4,7 +4,7 @@
.modal-header
%h3.page-title
- link_to_client = link_to(@pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer')
- = _("Authorize %{link_to_client} to use your account?")
+ = _("Authorize %{link_to_client} to use your account?").html_safe % { link_to_client: link_to_client }
.modal-body
- if current_user.admin?
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
index d29dda43c89..4cae9c51acc 100644
--- a/app/views/ide/index.html.haml
+++ b/app/views/ide/index.html.haml
@@ -8,7 +8,10 @@
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
"pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
- "ci-help-page-path" => help_page_path('ci/quick_start/README'), } }
+ "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
+ "ci-help-page-path" => help_page_path('ci/quick_start/README'),
+ "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
+ "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } }
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
new file mode 100644
index 00000000000..ac86be8fa7a
--- /dev/null
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -0,0 +1,26 @@
+- title = _('Bitbucket Server Import')
+- page_title title
+- breadcrumb_title title
+- header_title "Projects", root_path
+
+%h3.page-title
+ = icon 'bitbucket-square', text: _('Import repositories from Bitbucket Server')
+
+%p
+ = _('Enter in your Bitbucket Server URL and personal access token below')
+
+= form_tag configure_import_bitbucket_server_path, method: :post do
+ .form-group.row
+ = label_tag :bitbucket_server_url, 'Bitbucket Server URL', class: 'col-form-label col-md-2'
+ .col-md-4
+ = text_field_tag :bitbucket_server_url, '', class: 'form-control append-right-8', placeholder: _('https://your-bitbucket-server'), size: 40
+ .form-group.row
+ = label_tag :bitbucket_server_url, 'Username', class: 'col-form-label col-md-2'
+ .col-md-4
+ = text_field_tag :bitbucket_username, '', class: 'form-control append-right-8', placeholder: _('username'), size: 40
+ .form-group.row
+ = label_tag :personal_access_token, 'Password/Personal Access Token', class: 'col-form-label col-md-2'
+ .col-md-4
+ = password_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40
+ .form-actions
+ = submit_tag _('List your Bitbucket Server repositories'), class: 'btn btn-success'
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
new file mode 100644
index 00000000000..3d05a5e696f
--- /dev/null
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -0,0 +1,87 @@
+- page_title 'Bitbucket Server import'
+- header_title 'Projects', root_path
+
+%h3.page-title
+ %i.fa.fa-bitbucket-square
+ = _('Import projects from Bitbucket Server')
+
+- if @repos.any?
+ %p.light
+ = _('Select projects you want to import.')
+ .btn-group
+ - if @incompatible_repos.any?
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all compatible projects')
+ = icon('spinner spin', class: 'loading-icon')
+ - else
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all projects')
+ = icon('spinner spin', class: 'loading-icon')
+ .btn-group
+ = link_to('Reconfigure', configure_import_bitbucket_server_path, class: 'btn btn-primary', method: :post)
+
+.table-responsive.prepend-top-10
+ %table.table.import-jobs
+ %colgroup.import-jobs-from-col
+ %colgroup.import-jobs-to-col
+ %colgroup.import-jobs-status-col
+ %thead
+ %tr
+ %th= _('From Bitbucket Server')
+ %th= _('To GitLab')
+ %th= _(' Status')
+ %tbody
+ - @already_added_projects.each do |project|
+ %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
+ %td
+ = link_to project.import_source, project.import_source, target: '_blank', rel: 'noopener noreferrer'
+ %td
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
+ %td.job-status
+ - if project.import_status == 'finished'
+ = icon('check', text: 'Done')
+ - elsif project.import_status == 'started'
+ = icon('spin', text: 'started')
+ - else
+ = project.human_import_status_name
+
+ - @repos.each do |repo|
+ %tr{ id: "repo_#{repo.project_key}___#{repo.slug}", data: { project: repo.project_key, repository: repo.slug } }
+ %td
+ = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
+ %td.import-target
+ %fieldset.row
+ .input-group
+ .project-path.input-group-prepend
+ - if current_user.can_select_namespace?
+ - selected = params[:namespace_id] || :extra_group
+ - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.project_key, path: repo.project_key) } : {}
+ = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 }
+ - else
+ = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
+ %span.input-group-prepend
+ .input-group-text /
+ = text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
+ %td.import-actions.job-status
+ = button_tag class: 'btn btn-import js-add-to-import' do
+ Import
+ = icon('spinner spin', class: 'loading-icon')
+ - @incompatible_repos.each do |repo|
+ %tr{ id: "repo_#{repo.project_key}___#{repo.slug}" }
+ %td
+ = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
+ %td.import-target
+ %td.import-actions-job-status
+ = label_tag 'Incompatible Project', nil, class: 'label badge-danger'
+
+- if @incompatible_repos.any?
+ %p
+ One or more of your Bitbucket Server projects cannot be imported into GitLab
+ directly because they use Subversion or Mercurial for version control,
+ rather than Git. Please convert
+ = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview'
+ and go through the
+ = link_to 'import flow', status_import_bitbucket_server_path
+ again.
+
+.js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_server_path}", import_path: "#{import_bitbucket_server_path}" } }
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index 556ad8cf306..9a7a67cfa83 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -6,21 +6,19 @@
- group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) }
- if @project && @project.persisted?
- project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? }
-.search.search-form{ class: "#{'has-location-badge' if label.present?}" }
+.search.search-form
= form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container
- - if label.present?
- .location-badge= label
.search-input-wrap
.dropdown{ data: { url: search_autocomplete_path } }
- = search_field_tag 'search', nil, placeholder: _('Search'),
+ = search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
tabindex: '1',
autocomplete: 'off',
data: { issues_path: issues_dashboard_path,
mr_path: merge_requests_dashboard_path },
- aria: { label: _('Search') }
+ aria: { label: _('Search or jump to…') }
%button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } }
.dropdown-menu.dropdown-select
= dropdown_content do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 2c262a2b7dd..34f47806205 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -4,7 +4,7 @@
.context-header
= link_to project_path(@project), title: @project.name do
.avatar-container.s40.project-avatar
- = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile')
+ = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40)
.sidebar-context-title
= @project.name
%ul.sidebar-top-level-items
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index e7044f722c5..6f08a294c5d 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -31,17 +31,37 @@
%hr
= link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted'
- - if show_user_status_field?
- %hr
- .row
- .col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0= s_("User|Current Status")
- %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface. The message can contain emoji codes, too.")
- .col-lg-8
- .row
- = f.fields_for :status, @user.status do |status_form|
- = status_form.text_field :emoji
- = status_form.text_field :message, maxlength: 100
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0= s_("User|Current status")
+ %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
+ .col-lg-8
+ = f.fields_for :status, @user.status do |status_form|
+ - emoji_button = button_tag type: :button,
+ class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn has-tooltip',
+ title: s_("Profiles|Add status emoji") do
+ - if @user.status
+ = emoji_icon @user.status.emoji
+ %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) }
+ = sprite_icon('emoji_slightly_smiling_face', css_class: 'award-control-icon-neutral')
+ = sprite_icon('emoji_smiley', css_class: 'award-control-icon-positive')
+ = sprite_icon('emoji_smile', css_class: 'award-control-icon-super-positive')
+ - reset_message_button = button_tag type: :button,
+ id: 'js-clear-user-status-button',
+ class: 'clear-user-status btn has-tooltip',
+ title: s_("Profiles|Clear status") do
+ = sprite_icon("close")
+
+ = status_form.hidden_field :emoji, id: 'js-status-emoji-field'
+ = status_form.text_field :message,
+ id: 'js-status-message-field',
+ class: 'form-control input-lg',
+ label: s_("Profiles|Your status"),
+ prepend: emoji_button,
+ append: reset_message_button,
+ placeholder: s_("Profiles|What's your status?")
+
%hr
.row
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 74ab8cf8250..fbe88ec9618 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -3,7 +3,7 @@
.project-home-panel.text-center{ class: ("empty-project" if empty_repo) }
.limit-container-width{ class: container_class }
.avatar-container.s70.project-avatar
- = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile')
+ = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile', width: 70, height: 70)
%h1.project-title.qa-project-name
= @project.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 3da6db08580..70e1c557547 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -18,10 +18,14 @@
- if bitbucket_import_enabled?
%div
= link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
- = icon('bitbucket', text: 'Bitbucket')
+ = icon('bitbucket', text: 'Bitbucket Cloud')
- unless bitbucket_import_configured?
= render 'bitbucket_import_modal'
-
+ - if bitbucket_server_import_enabled?
+ %div
+ = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket" do
+ = icon('bitbucket-square', text: 'Bitbucket Server')
+ %div
- if gitlab_import_enabled?
%div
= link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index c78baa5dfe4..ad8c7911fad 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -12,7 +12,13 @@
.input-group-prepend.has-tooltip{ title: root_url }
.input-group-text
= root_url
- = f.select :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), {}, { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1}
+ - namespace_id = namespace_id_from(params)
+ = f.select(:namespace_id,
+ namespaces_options(namespace_id || :current_user,
+ display_path: true,
+ extra_group: namespace_id),
+ {},
+ { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1})
- else
.input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 5edab38bd64..a0b0384d78d 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -3,7 +3,8 @@
- page_title @blob.path, @ref
-.js-signature-container{ data: { 'signatures-path': namespace_project_signatures_path } }
+- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit)
+.js-signature-container{ data: { 'signatures-path': signatures_path } }
%div{ class: container_class }
= render 'projects/last_push'
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 0ff88b82ae6..f483fad6142 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -51,7 +51,7 @@
.form-group
- if @project.avatar?
.avatar-container.s160.append-bottom-15
- = project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160')
+ = project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160)
- if @project.avatar_in_git
%p.light
= _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
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 31c2616d283..ab9ba5c7569 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -1,6 +1,6 @@
.row
.col-lg-12
- = form_for @project, url: project_settings_ci_cd_path(@project) do |f|
+ = form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f|
= form_errors(@project)
%fieldset.builds-feature.js-auto-devops-settings
.form-group
@@ -13,23 +13,15 @@
.card.auto-devops-card
.card-body
.form-check
- = form.radio_button :enabled, 'true', class: 'form-check-input js-toggle-extra-settings'
- = form.label :enabled_true, class: 'form-check-label' do
- %strong= s_('CICD|Enable Auto DevOps')
+ = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: @project.auto_devops_enabled?
+ = form.label :enabled, class: 'form-check-label' do
+ %strong= s_('CICD|Default to Auto DevOps pipeline')
+ - if @project.has_auto_devops_implicitly_enabled?
+ %span.badge.badge-info.js-instance-default-badge= s_('CICD|instance enabled')
.form-text.text-muted
- = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted }
-
- .card.auto-devops-card
- .card-body
- .form-check
- = form.radio_button :enabled, '', class: 'form-check-input js-toggle-extra-settings'
- = form.label :enabled_, class: 'form-check-label' do
- %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" }
- .form-text.text-muted
- = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted }
-
- .card.auto-devops-card.js-extra-settings{ class: form.object&.enabled == false ? 'hidden' : nil }
- .card-body.bg-light
+ = 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'
@@ -46,21 +38,12 @@
.form-check
= form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input'
= form.label :deploy_strategy_continuous, class: 'form-check-label' do
- %strong= s_('CICD|Continuous deployment to production')
+ = s_('CICD|Continuous deployment to production')
= link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-deploy'), target: '_blank'
.form-check
= form.radio_button :deploy_strategy, 'manual', class: 'form-check-input'
= form.label :deploy_strategy_manual, class: 'form-check-label' do
- %strong= s_('CICD|Automatic deployment to staging, manual deployment to production')
+ = s_('CICD|Automatic deployment to staging, manual deployment to production')
= link_to icon('question-circle'), help_page_path('ci/environments.md', anchor: 'manually-deploying-to-environments'), target: '_blank'
- .card.auto-devops-card
- .card-body
- .form-check
- = form.radio_button :enabled, 'false', class: 'form-check-input js-toggle-extra-settings', data: { hide_extra_settings: true }
- = form.label :enabled_false, class: 'form-check-label' do
- %strong= s_('CICD|Disable Auto DevOps')
- .form-text.text-muted
- = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted }
-
= f.submit _('Save changes'), class: "btn btn-success prepend-top-15"
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 64751e5616a..434aed2f603 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -1,6 +1,6 @@
.row.prepend-top-default
.col-lg-12
- = form_for @project, url: project_settings_ci_cd_path(@project) do |f|
+ = form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f|
= form_errors(@project)
%fieldset.builds-feature
.form-group.append-bottom-default.js-secret-runner-token
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index e011851be78..8a5abb64515 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -9,7 +9,7 @@
= render partial: 'flash_messages', locals: { project: @project }
- if @project.repository_exists? && !@project.empty_repo?
- - signatures_path = namespace_project_signatures_path(project_id: @project.path, id: @project.default_branch)
+ - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @project.default_branch)
.js-signature-container{ data: { 'signatures-path': signatures_path } }
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index ace8120eeff..9d2aee7a8bd 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,7 +1,7 @@
- @no_container = true
- breadcrumb_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout
-- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.path, project_id: @project.path, id: @ref)
+- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit)
- page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index e93925b5ef9..2c3cbd0b986 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -17,13 +17,13 @@
- if can?(current_user, :admin_label, @project)
%li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label),
dom_id: dom_id(label), type: label.type } }
- %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'top' }, aria_label: _('Prioritize label') }
+ %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Prioritize label') }
= sprite_icon('star-o')
- %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'top' }, aria_label: _('Deprioritize label') }
+ %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Deprioritize label') }
= sprite_icon('star')
- if can?(current_user, :admin_label, label)
%li.inline
- = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit', aria_label: 'Edit label' do
+ = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
%li.inline
.dropdown
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 0ae3ab8f090..c5ea15a7f63 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -1,14 +1,17 @@
- subject = local_assigns[:subject]
- force_priority = local_assigns.fetch(:force_priority, false)
-- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
-- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
+- show_label_issues_link = defined?(@project) && show_label_issuables_link?(label, :issues, project: @project)
+- show_label_merge_requests_link = defined?(@project) && show_label_issuables_link?(label, :merge_requests, project: @project)
.label-name
- = link_to_label(label, subject: @project, tooltip: false)
+ - if defined?(@project)
+ = link_to_label(label, subject: @project, tooltip: false)
+ - else
+ = render_colored_label(label, tooltip: false)
.label-description
.append-right-default.prepend-left-default
- if label.description.present?
- .description-text.append-bottom-10
+ .description-text
= markdown_field(label, :description)
%ul.label-links
- if show_label_issues_link
@@ -19,5 +22,5 @@
%li.label-link-item.inline
= link_to_label(label, subject: subject, type: :merge_request) { _('Merge requests') }
- if force_priority
- %li.label-link-item.js-priority-badge.inline.prepend-left-10
+ %li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10
.label-badge.label-badge-blue= _('Prioritized label')
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index b35877e5518..e26f5260e5b 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -6,12 +6,13 @@
%i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" }
+ = render_if_exists "shared/boards/components/list_milestone"
%a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
-# haml-lint:disable AltText
%img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" }
- %span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"",
+ %span.board-title-text.has-tooltip.block-truncated{ "v-if": "list.type !== \"label\"",
":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
{{ list.title }}
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index 607e7f471c9..532045f3697 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -19,6 +19,7 @@
":value" => "label.id" }
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
+ "v-bind:data-selected" => "selectedLabels",
data: { toggle: "dropdown",
field_name: "issue[label_names][]",
show_no: "true",
@@ -28,7 +29,7 @@
namespace_path: @namespace_path,
project_path: @project.try(:path) } }
%span.dropdown-toggle-text
- = _("Label")
+ {{ labelDropdownTitle }}
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default"
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 6be1fb485a4..be053d481e4 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -19,7 +19,7 @@
- if project.creator && use_creator_avatar
= image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:''
- else
- = project_icon(project, alt: '', class: 'avatar project-avatar s40')
+ = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
.project-details
%h3.prepend-top-0.append-bottom-0
= link_to project_path(project), class: 'text-plain' do
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index e8b9999f83b..f95df7ecf03 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -77,6 +77,7 @@
- todos_destroyer:todos_destroyer_entity_leave
- todos_destroyer:todos_destroyer_project_private
- todos_destroyer:todos_destroyer_private_features
+- todos_destroyer:todos_destroyer_group_private
- default
- mailers # ActionMailer::DeliveryJob.queue_name
diff --git a/app/workers/todos_destroyer/group_private_worker.rb b/app/workers/todos_destroyer/group_private_worker.rb
new file mode 100644
index 00000000000..3e47eec7461
--- /dev/null
+++ b/app/workers/todos_destroyer/group_private_worker.rb
@@ -0,0 +1,10 @@
+module TodosDestroyer
+ class GroupPrivateWorker
+ include ApplicationWorker
+ include TodosDestroyerQueue
+
+ def perform(group_id)
+ ::Todos::Destroy::GroupPrivateService.new(group_id).execute
+ end
+ end
+end
diff --git a/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml b/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml
new file mode 100644
index 00000000000..efa13c9ab3c
--- /dev/null
+++ b/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml
@@ -0,0 +1,5 @@
+---
+title: UX improvements to top nav search bar
+merge_request: 20537
+author:
+type: changed
diff --git a/changelogs/unreleased/44127-board-label-edit-drop-down-is-showing-incorrect-selected-labels-summary.yml b/changelogs/unreleased/44127-board-label-edit-drop-down-is-showing-incorrect-selected-labels-summary.yml
new file mode 100644
index 00000000000..de991ef475a
--- /dev/null
+++ b/changelogs/unreleased/44127-board-label-edit-drop-down-is-showing-incorrect-selected-labels-summary.yml
@@ -0,0 +1,5 @@
+---
+title: Board label edit dropdown shows incorrect selected labels summary
+merge_request: 20673
+author:
+type: fixed
diff --git a/changelogs/unreleased/46165-web-ide-branch-picker.yml b/changelogs/unreleased/46165-web-ide-branch-picker.yml
new file mode 100644
index 00000000000..ff879cb3d37
--- /dev/null
+++ b/changelogs/unreleased/46165-web-ide-branch-picker.yml
@@ -0,0 +1,5 @@
+---
+title: Create branch and MR picker for Web IDE
+merge_request: 20978
+author:
+type: changed
diff --git a/changelogs/unreleased/46535-orphaned-uploads.yml b/changelogs/unreleased/46535-orphaned-uploads.yml
new file mode 100644
index 00000000000..1cd087a6aad
--- /dev/null
+++ b/changelogs/unreleased/46535-orphaned-uploads.yml
@@ -0,0 +1,5 @@
+---
+title: Clean orphaned files in object storage
+merge_request: 20918
+author:
+type: added
diff --git a/changelogs/unreleased/46703-group-dashboard-line-height-is-too-tall-for-group-names.yml b/changelogs/unreleased/46703-group-dashboard-line-height-is-too-tall-for-group-names.yml
new file mode 100644
index 00000000000..5b91c6d5a9f
--- /dev/null
+++ b/changelogs/unreleased/46703-group-dashboard-line-height-is-too-tall-for-group-names.yml
@@ -0,0 +1,5 @@
+---
+title: Solves group dashboard line height is too tall for group names.
+merge_request: 21033
+author:
+type: fixed
diff --git a/changelogs/unreleased/47156-improve-auto-devops-settings.yml b/changelogs/unreleased/47156-improve-auto-devops-settings.yml
new file mode 100644
index 00000000000..d8993565047
--- /dev/null
+++ b/changelogs/unreleased/47156-improve-auto-devops-settings.yml
@@ -0,0 +1,5 @@
+---
+title: Improve and simplify Auto DevOps settings flow
+merge_request: 20946
+author:
+type: other
diff --git a/changelogs/unreleased/47768-web-ide-redesign-header.yml b/changelogs/unreleased/47768-web-ide-redesign-header.yml
new file mode 100644
index 00000000000..49133158164
--- /dev/null
+++ b/changelogs/unreleased/47768-web-ide-redesign-header.yml
@@ -0,0 +1,5 @@
+---
+title: Redesign Web IDE back button and context header
+merge_request: 20850
+author:
+type: changed
diff --git a/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml b/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml
new file mode 100644
index 00000000000..43125ef25c4
--- /dev/null
+++ b/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml
@@ -0,0 +1,6 @@
+---
+title: Ensure installed Helm Tiller For GitLab Managed Apps Is protected by mutual
+ auth
+merge_request: 20928
+author:
+type: changed
diff --git a/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml b/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml
new file mode 100644
index 00000000000..b3ccbb121f0
--- /dev/null
+++ b/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml
@@ -0,0 +1,5 @@
+---
+title: fix height of full-width Metrics charts on large screens
+merge_request: 20866
+author:
+type: fixed
diff --git a/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml b/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml
new file mode 100644
index 00000000000..c34750a3b88
--- /dev/null
+++ b/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Fix the UI for listing system-level labels
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml
new file mode 100644
index 00000000000..ffa4a3bc710
--- /dev/null
+++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml
@@ -0,0 +1,5 @@
+---
+title: Fix rendering of the context lines in MR diffs page.
+merge_request: 20968
+author:
+type: fixed
diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml
new file mode 100644
index 00000000000..42b0e4194f1
--- /dev/null
+++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml
@@ -0,0 +1,5 @@
+---
+title: Fix autosave and ESC confirmation issues for MR discussions.
+merge_request: 20968
+author:
+type: fixed
diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml
new file mode 100644
index 00000000000..29419091d02
--- /dev/null
+++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml
@@ -0,0 +1,5 @@
+---
+title: Fix navigation to First and Next discussion on MR Changes tab.
+merge_request: 20968
+author:
+type: fixed
diff --git a/changelogs/unreleased/49966-improve-junit-fe.yml b/changelogs/unreleased/49966-improve-junit-fe.yml
new file mode 100644
index 00000000000..48971d3bfd6
--- /dev/null
+++ b/changelogs/unreleased/49966-improve-junit-fe.yml
@@ -0,0 +1,5 @@
+---
+title: Renders test reports for resolved failures and resets error state
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/add-homepage-link-to-status-pages.yml b/changelogs/unreleased/add-homepage-link-to-status-pages.yml
new file mode 100644
index 00000000000..0e7375f2061
--- /dev/null
+++ b/changelogs/unreleased/add-homepage-link-to-status-pages.yml
@@ -0,0 +1,5 @@
+---
+title: Add link to homepage on static http status pages (404, 500, etc)
+merge_request: 20898
+author: Jason Funk
+type: added
diff --git a/changelogs/unreleased/ce-5666-backport.yml b/changelogs/unreleased/ce-5666-backport.yml
new file mode 100644
index 00000000000..344f1a1983f
--- /dev/null
+++ b/changelogs/unreleased/ce-5666-backport.yml
@@ -0,0 +1,5 @@
+---
+title: CE port of "List groups with developer maintainer access on project creation"
+merge_request: 21051
+author:
+type: other
diff --git a/changelogs/unreleased/fix-prometheus-updated-status.yml b/changelogs/unreleased/fix-prometheus-updated-status.yml
new file mode 100644
index 00000000000..7261c3429c8
--- /dev/null
+++ b/changelogs/unreleased/fix-prometheus-updated-status.yml
@@ -0,0 +1,5 @@
+---
+title: Fix UI error whereby prometheus application status is updated
+merge_request: 21029
+author:
+type: fixed
diff --git a/changelogs/unreleased/git-rerere-link-doc-update.yml b/changelogs/unreleased/git-rerere-link-doc-update.yml
new file mode 100644
index 00000000000..06093e8ec13
--- /dev/null
+++ b/changelogs/unreleased/git-rerere-link-doc-update.yml
@@ -0,0 +1,5 @@
+---
+title: Update git rerere link in docs
+merge_request: 21060
+author: gfyoung
+type: other
diff --git a/changelogs/unreleased/ide-codesandbox-poc.yml b/changelogs/unreleased/ide-codesandbox-poc.yml
new file mode 100644
index 00000000000..7da1f4e6472
--- /dev/null
+++ b/changelogs/unreleased/ide-codesandbox-poc.yml
@@ -0,0 +1,5 @@
+---
+title: Added live preview for JavaScript projects in the Web IDE
+merge_request: 19764
+author:
+type: added
diff --git a/changelogs/unreleased/improve-junit-support-be.yml b/changelogs/unreleased/improve-junit-support-be.yml
new file mode 100644
index 00000000000..db4d47caa7c
--- /dev/null
+++ b/changelogs/unreleased/improve-junit-support-be.yml
@@ -0,0 +1,5 @@
+---
+title: Improve JUnit test reports in merge request widgets
+merge_request: 49966
+author:
+type: fixed
diff --git a/changelogs/unreleased/kp-6927-epic-dates-from-milestone.yml b/changelogs/unreleased/kp-6927-epic-dates-from-milestone.yml
new file mode 100644
index 00000000000..c15d73a0c12
--- /dev/null
+++ b/changelogs/unreleased/kp-6927-epic-dates-from-milestone.yml
@@ -0,0 +1,5 @@
+---
+title: Add 'tabindex' attribute support on Icon component to show BS4 popover on trigger type 'focus'
+merge_request: 21066
+author:
+type: other
diff --git a/changelogs/unreleased/osw-fix-missing-and-duplicated-milestones-on-list.yml b/changelogs/unreleased/osw-fix-missing-and-duplicated-milestones-on-list.yml
new file mode 100644
index 00000000000..62416b7f87e
--- /dev/null
+++ b/changelogs/unreleased/osw-fix-missing-and-duplicated-milestones-on-list.yml
@@ -0,0 +1,5 @@
+---
+title: Fix missing and duplicates on project milestone listing page
+merge_request: 21058
+author:
+type: fixed
diff --git a/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml b/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml
new file mode 100644
index 00000000000..dc8148fa1a5
--- /dev/null
+++ b/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml
@@ -0,0 +1,5 @@
+---
+title: Avoid N+1 on MRs page when metrics merging date cannot be found
+merge_request: 21053
+author:
+type: performance
diff --git a/changelogs/unreleased/pl-json-gon.yml b/changelogs/unreleased/pl-json-gon.yml
new file mode 100644
index 00000000000..c0f93006c07
--- /dev/null
+++ b/changelogs/unreleased/pl-json-gon.yml
@@ -0,0 +1,5 @@
+---
+title: Don't set gon variables in JSON requests
+merge_request: 21016
+author: Peter Leitzen
+type: performance
diff --git a/changelogs/unreleased/sh-bump-gitaly-0-117.yml b/changelogs/unreleased/sh-bump-gitaly-0-117.yml
new file mode 100644
index 00000000000..90ca86d076b
--- /dev/null
+++ b/changelogs/unreleased/sh-bump-gitaly-0-117.yml
@@ -0,0 +1,5 @@
+---
+title: Bump Gitaly to 0.117.0
+merge_request: 21055
+author:
+type: performance
diff --git a/changelogs/unreleased/todos-visibility-migration.yml b/changelogs/unreleased/todos-visibility-migration.yml
new file mode 100644
index 00000000000..651facc4ec8
--- /dev/null
+++ b/changelogs/unreleased/todos-visibility-migration.yml
@@ -0,0 +1,5 @@
+---
+title: Remove todos of users without access to targets migration
+merge_request: 20927
+author:
+type: other
diff --git a/changelogs/unreleased/tz-mr-port-memory-fixes.yml b/changelogs/unreleased/tz-mr-port-memory-fixes.yml
new file mode 100644
index 00000000000..61d3c9abf71
--- /dev/null
+++ b/changelogs/unreleased/tz-mr-port-memory-fixes.yml
@@ -0,0 +1,5 @@
+---
+title: Improve performance and memory footprint of Changes tab of Merge Requests
+merge_request: 21028
+author:
+type: performance
diff --git a/changelogs/unreleased/winh-fix-gpg-regressions.yml b/changelogs/unreleased/winh-fix-gpg-regressions.yml
new file mode 100644
index 00000000000..75d28321259
--- /dev/null
+++ b/changelogs/unreleased/winh-fix-gpg-regressions.yml
@@ -0,0 +1,5 @@
+---
+title: Fix GPG status badge loading regressions
+merge_request: 20987
+author:
+type: fixed
diff --git a/changelogs/unreleased/winh-restyle-user-status.yml b/changelogs/unreleased/winh-restyle-user-status.yml
new file mode 100644
index 00000000000..90370e87825
--- /dev/null
+++ b/changelogs/unreleased/winh-restyle-user-status.yml
@@ -0,0 +1,5 @@
+---
+title: Restyle status message input on profile settings
+merge_request: 20903
+author:
+type: changed
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index 73115449871..dce1fc1bc45 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -546,3 +546,27 @@
:why: Our own library
:versions: []
:when: 2018-07-17 21:02:54.529227000 Z
+- - :approve
+ - lz-string
+ - :who: Phil Hughes
+ :why: https://github.com/pieroxy/lz-string/blob/master/LICENSE.txt
+ :versions: []
+ :when: 2018-08-03 08:22:44.973457000 Z
+- - :approve
+ - smooshpack
+ - :who: Phil Hughes
+ :why: https://github.com/CompuIves/codesandbox-client/blob/master/packages/sandpack/LICENSE.md
+ :versions: []
+ :when: 2018-08-03 08:24:29.578991000 Z
+- - :approve
+ - codesandbox-import-util-types
+ - :who: Phil Hughes
+ :why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/types/LICENSE
+ :versions: []
+ :when: 2018-08-03 12:22:47.574421000 Z
+- - :approve
+ - codesandbox-import-utils
+ - :who: Phil Hughes
+ :why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/import-utils/LICENSE
+ :versions: []
+ :when: 2018-08-03 12:23:24.083046000 Z
diff --git a/config/initializers/active_record_verbose_query_logs.rb b/config/initializers/active_record_verbose_query_logs.rb
new file mode 100644
index 00000000000..44f86fec7e0
--- /dev/null
+++ b/config/initializers/active_record_verbose_query_logs.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# This is backport of https://github.com/rails/rails/pull/26815/files
+# Enabled by default for every non-production environment
+
+module ActiveRecord
+ class LogSubscriber
+ module VerboseQueryLogs
+ def debug(progname = nil, &block)
+ return unless super
+
+ log_query_source
+ end
+
+ def log_query_source
+ source_line, line_number = extract_callstack(caller_locations)
+
+ if source_line
+ if defined?(::Rails.root)
+ app_root = "#{::Rails.root}/".freeze
+ source_line = source_line.sub(app_root, "")
+ end
+
+ logger.debug(" ↳ #{source_line}:#{line_number}")
+ end
+ end
+
+ def extract_callstack(callstack)
+ line = callstack.find do |frame|
+ frame.absolute_path && !ignored_callstack(frame.absolute_path)
+ end
+
+ offending_line = line || callstack.first
+ [
+ offending_line.path,
+ offending_line.lineno,
+ offending_line.label
+ ]
+ end
+
+ LOG_SUBSCRIBER_FILE = ActiveRecord::LogSubscriber.method(:logger).source_location.first
+ RAILS_GEM_ROOT = File.expand_path("../../../..", LOG_SUBSCRIBER_FILE) + "/"
+ APP_CONFIG_ROOT = File.expand_path("..", __dir__) + "/"
+
+ def ignored_callstack(path)
+ path.start_with?(APP_CONFIG_ROOT, RAILS_GEM_ROOT, RbConfig::CONFIG["rubylibdir"])
+ end
+ end
+
+ unless Gitlab.rails5?
+ prepend(VerboseQueryLogs) unless Rails.env.production?
+ end
+ end
+end
diff --git a/config/routes/import.rb b/config/routes/import.rb
index efd0260ff60..3998d977c81 100644
--- a/config/routes/import.rb
+++ b/config/routes/import.rb
@@ -24,6 +24,13 @@ namespace :import do
get :jobs
end
+ resource :bitbucket_server, only: [:create, :new], controller: :bitbucket_server do
+ post :configure
+ get :status
+ get :callback
+ get :jobs
+ end
+
resource :google_code, only: [:create, :new], controller: :google_code do
get :status
post :callback
diff --git a/db/migrate/20180608091413_add_group_to_todos.rb b/db/migrate/20180608091413_add_group_to_todos.rb
new file mode 100644
index 00000000000..20ba4849057
--- /dev/null
+++ b/db/migrate/20180608091413_add_group_to_todos.rb
@@ -0,0 +1,36 @@
+class AddGroupToTodos < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Todo < ActiveRecord::Base
+ self.table_name = 'todos'
+
+ include ::EachBatch
+ end
+
+ def up
+ add_column(:todos, :group_id, :integer) unless group_id_exists?
+ add_concurrent_foreign_key :todos, :namespaces, column: :group_id, on_delete: :cascade
+ add_concurrent_index :todos, :group_id
+
+ change_column_null :todos, :project_id, true
+ end
+
+ def down
+ remove_foreign_key_without_error(:todos, column: :group_id)
+ remove_concurrent_index(:todos, :group_id)
+ remove_column(:todos, :group_id) if group_id_exists?
+
+ Todo.where(project_id: nil).each_batch { |batch| batch.delete_all }
+ change_column_null :todos, :project_id, false
+ end
+
+ private
+
+ def group_id_exists?
+ column_exists?(:todos, :group_id)
+ end
+end
diff --git a/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb b/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb
new file mode 100644
index 00000000000..57cea18abcd
--- /dev/null
+++ b/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+class AddColumnsForHelmTillerCertificates < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :clusters_applications_helm, :encrypted_ca_key, :text
+ add_column :clusters_applications_helm, :encrypted_ca_key_iv, :text
+ add_column :clusters_applications_helm, :ca_cert, :text
+ end
+end
diff --git a/db/migrate/20180717125853_remove_restricted_todos.rb b/db/migrate/20180717125853_remove_restricted_todos.rb
new file mode 100644
index 00000000000..fdf43921a73
--- /dev/null
+++ b/db/migrate/20180717125853_remove_restricted_todos.rb
@@ -0,0 +1,31 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+# frozen_string_literal: true
+
+class RemoveRestrictedTodos < ActiveRecord::Migration
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ MIGRATION = 'RemoveRestrictedTodos'.freeze
+ BATCH_SIZE = 1000
+ DELAY_INTERVAL = 5.minutes.to_i
+
+ class Project < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'projects'
+ end
+
+ def up
+ Project.where('EXISTS (SELECT 1 FROM todos WHERE todos.project_id = projects.id)')
+ .each_batch(of: BATCH_SIZE) do |batch, index|
+ range = batch.pluck('MIN(id)', 'MAX(id)').first
+
+ BackgroundMigrationWorker.perform_in(index * DELAY_INTERVAL, MIGRATION, range)
+ end
+ end
+
+ def down
+ # nothing to do
+ end
+end
diff --git a/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb
new file mode 100644
index 00000000000..1ebb91da00c
--- /dev/null
+++ b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AddWebIdeClientSidePreviewEnabledToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:application_settings, :web_ide_clientside_preview_enabled,
+ :boolean,
+ default: false,
+ allow_null: false)
+ end
+
+ def down
+ remove_column(:application_settings, :web_ide_clientside_preview_enabled)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2bef2971f29..c132f787530 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do
t.boolean "mirror_available", default: true, null: false
t.boolean "hide_third_party_offers", default: false, null: false
t.boolean "instance_statistics_visibility_private", default: false, null: false
+ t.boolean "web_ide_clientside_preview_enabled", default: false, null: false
end
create_table "audit_events", force: :cascade do |t|
@@ -637,6 +638,9 @@ ActiveRecord::Schema.define(version: 20180726172057) do
t.integer "status", null: false
t.string "version", null: false
t.text "status_reason"
+ t.text "encrypted_ca_key"
+ t.text "encrypted_ca_key_iv"
+ t.text "ca_cert"
end
create_table "clusters_applications_ingress", force: :cascade do |t|
@@ -1988,7 +1992,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do
create_table "todos", force: :cascade do |t|
t.integer "user_id", null: false
- t.integer "project_id", null: false
+ t.integer "project_id"
t.integer "target_id"
t.string "target_type", null: false
t.integer "author_id", null: false
@@ -1998,10 +2002,12 @@ ActiveRecord::Schema.define(version: 20180726172057) do
t.datetime "updated_at"
t.integer "note_id"
t.string "commit_id"
+ t.integer "group_id"
end
add_index "todos", ["author_id"], name: "index_todos_on_author_id", using: :btree
add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree
+ add_index "todos", ["group_id"], name: "index_todos_on_group_id", using: :btree
add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree
add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree
add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree
@@ -2389,6 +2395,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do
add_foreign_key "term_agreements", "users", on_delete: :cascade
add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade
add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade
+ add_foreign_key "todos", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "todos", "notes", name: "fk_91d1f47b13", on_delete: :cascade
add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade
add_foreign_key "todos", "users", column: "author_id", name: "fk_ccf0373936", on_delete: :cascade
diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md
index d9a61aea6ef..6d7e408d41b 100644
--- a/doc/administration/gitaly/index.md
+++ b/doc/administration/gitaly/index.md
@@ -63,7 +63,7 @@ Gitaly network traffic is unencrypted so you should use a firewall to
restrict access to your Gitaly server.
Below we describe how to configure a Gitaly server at address
-`gitaly.internal:9999` with secret token `abc123secret`. We assume
+`gitaly.internal:8075` with secret token `abc123secret`. We assume
your GitLab installation has two repository storages, `default` and
`storage1`.
@@ -101,18 +101,42 @@ documentation on configuring Gitaly
authentication](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md#authentication)
.
-In most or all cases the storage paths below end in `/repositories`. Check the
+>
+**NOTE:** In most or all cases the storage paths below end in `/repositories` which is
+different than `path` in `git_data_dirs` of Omnibus installations. Check the
directory layout on your Gitaly server to be sure.
Omnibus installations:
```ruby
# /etc/gitlab/gitlab.rb
-gitaly['listen_addr'] = '0.0.0.0:9999'
+
+# Avoid running unnecessary services on the gitaly server
+postgresql['enable'] = false
+redis['enable'] = false
+nginx['enable'] = false
+prometheus['enable'] = false
+unicorn['enable'] = false
+sidekiq['enable'] = false
+gitlab_workhorse['enable'] = false
+
+# Prevent database connections during 'gitlab-ctl reconfigure'
+gitlab_rails['rake_cache_clear'] = false
+gitlab_rails['auto_migrate'] = false
+
+# Configure the gitlab-shell API callback URL. Without this, `git push` will
+# fail. This can be your 'front door' GitLab URL or an internal load
+# balancer.
+gitlab_rails['internal_api_url'] = 'https://gitlab.example.com'
+
+# Make Gitaly accept connections on all network interfaces. You must use
+# firewalls to restrict access to this address/port.
+gitaly['listen_addr'] = "0.0.0.0:8075"
gitaly['auth_token'] = 'abc123secret'
+
gitaly['storage'] = [
- { 'name' => 'default', 'path' => '/path/to/default/repositories' },
- { 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' },
+ { 'name' => 'default', 'path' => '/mnt/gitlab/default/repositories' },
+ { 'name' => 'storage1', 'path' => '/mnt/gitlab/storage1/repositories' },
]
```
@@ -120,18 +144,18 @@ Source installations:
```toml
# /home/git/gitaly/config.toml
-listen_addr = '0.0.0.0:9999'
+listen_addr = '0.0.0.0:8075'
[auth]
token = 'abc123secret'
[[storage]
name = 'default'
-path = '/path/to/default/repositories'
+path = '/mnt/gitlab/default/repositories'
[[storage]]
name = 'storage1'
-path = '/path/to/storage1/repositories'
+path = '/mnt/gitlab/storage1/repositories'
```
Again, reconfigure (Omnibus) or restart (source).
@@ -146,7 +170,7 @@ server from reaching the Gitaly server then all Gitaly requests will
fail.
We assume that your Gitaly server can be reached at
-`gitaly.internal:9999` from your GitLab server, and that your GitLab
+`gitaly.internal:8075` from your GitLab server, and that your GitLab
NFS shares are mounted at `/mnt/gitlab/default` and
`/mnt/gitlab/storage1` respectively.
@@ -155,8 +179,8 @@ Omnibus installations:
```ruby
# /etc/gitlab/gitlab.rb
git_data_dirs({
- 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitlab.internal:9999' },
- 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitlab.internal:9999' },
+ 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitaly.internal:8075' },
+ 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitaly.internal:8075' },
})
gitlab_rails['gitaly_token'] = 'abc123secret'
@@ -171,10 +195,10 @@ gitlab:
storages:
default:
path: /mnt/gitlab/default/repositories
- gitaly_address: tcp://gitlab.internal:9999
+ gitaly_address: tcp://gitaly.internal:8075
storage1:
path: /mnt/gitlab/storage1/repositories
- gitaly_address: tcp://gitlab.internal:9999
+ gitaly_address: tcp://gitaly.internal:8075
gitaly:
token: 'abc123secret'
diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md
index 752a2774bd7..eada7b19dcd 100644
--- a/doc/administration/operations/fast_ssh_key_lookup.md
+++ b/doc/administration/operations/fast_ssh_key_lookup.md
@@ -1,11 +1,9 @@
-# Consider using SSH certificates instead of, or in addition to this
+# Fast lookup of authorized SSH keys in the database
-This document describes a drop-in replacement for the
+NOTE: **Note:** This document describes a drop-in replacement for the
`authorized_keys` file for normal (non-deploy key) users. Consider
using [ssh certificates](ssh_certificates.md), they are even faster,
-but are not is not a drop-in replacement.
-
-# Fast lookup of authorized SSH keys in the database
+but are not a drop-in replacement.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/1631) in
> [GitLab Starter](https://about.gitlab.com/gitlab-ee) 9.3.
diff --git a/doc/api/todos.md b/doc/api/todos.md
index 27e623007cc..0843e4eedc6 100644
--- a/doc/api/todos.md
+++ b/doc/api/todos.md
@@ -18,6 +18,7 @@ Parameters:
| `action` | string | no | The action to be filtered. Can be `assigned`, `mentioned`, `build_failed`, `marked`, `approval_required`, `unmergeable` or `directly_addressed`. |
| `author_id` | integer | no | The ID of an author |
| `project_id` | integer | no | The ID of a project |
+| `group_id` | integer | no | The ID of a group |
| `state` | string | no | The state of the todo. Can be either `pending` or `done` |
| `type` | string | no | The type of a todo. Can be either `Issue` or `MergeRequest` |
diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md
index a85e5b1b1cc..8d41503f874 100644
--- a/doc/development/automatic_ce_ee_merge.md
+++ b/doc/development/automatic_ce_ee_merge.md
@@ -100,7 +100,7 @@ Notes:
number of times you have to resolve conflicts.
- Please remember to
[always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts).
-- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html)
+- You can use [`git rerere`](https://git-scm.com/docs/git-rerere)
to avoid resolving the same conflicts multiple times.
### Cherry-picking from CE to EE
diff --git a/doc/raketasks/cleanup.md b/doc/raketasks/cleanup.md
index e2eb342361a..e70a009323e 100644
--- a/doc/raketasks/cleanup.md
+++ b/doc/raketasks/cleanup.md
@@ -52,4 +52,33 @@ D, [2018-07-27T12:08:33.293568 #89817] DEBUG -- : Processing batch of 500 projec
I, [2018-07-27T12:08:33.689869 #89817] INFO -- : Did move to lost and found /opt/gitlab/embedded/service/gitlab-rails/public/uploads/test.out -> /opt/gitlab/embedded/service/gitlab-rails/public/uploads/-/project-lost-found/test.out
I, [2018-07-27T12:08:33.755624 #89817] INFO -- : Did fix /opt/gitlab/embedded/service/gitlab-rails/public/uploads/foo/bar/89a0f7b0b97008a4a18cedccfdcd93fb/foo.txt -> /opt/gitlab/embedded/service/gitlab-rails/public/uploads/qux/foo/bar/89a0f7b0b97008a4a18cedccfdcd93fb/foo.txt
I, [2018-07-27T12:08:33.760257 #89817] INFO -- : Did move to lost and found /opt/gitlab/embedded/service/gitlab-rails/public/uploads/foo/bar/1dd6f0f7eefd2acc4c2233f89a0f7b0b/image.png -> /opt/gitlab/embedded/service/gitlab-rails/public/uploads/-/project-lost-found/foo/bar/1dd6f0f7eefd2acc4c2233f89a0f7b0b/image.png
-``` \ No newline at end of file
+```
+
+Remove object store upload files if they don't exist in GitLab database.
+
+```
+# omnibus-gitlab
+sudo gitlab-rake gitlab:cleanup:remote_upload_files
+
+# installation from source
+bundle exec rake gitlab:cleanup:remote_upload_files RAILS_ENV=production
+```
+
+Example output:
+
+```
+$ sudo gitlab-rake gitlab:cleanup:remote_upload_files
+
+I, [2018-08-02T10:26:13.995978 #45011] INFO -- : Looking for orphaned remote uploads to remove. Dry run...
+I, [2018-08-02T10:26:14.120400 #45011] INFO -- : Can be moved to lost and found: @hashed/6b/DSC_6152.JPG
+I, [2018-08-02T10:26:14.120482 #45011] INFO -- : Can be moved to lost and found: @hashed/79/02/7902699be42c8a8e46fbbb4501726517e86b22c56a189f7625a6da49081b2451/711491b29d3eb08837798c4909e2aa4d/DSC00314.jpg
+I, [2018-08-02T10:26:14.120634 #45011] INFO -- : To cleanup these files run this command with DRY_RUN=false
+```
+
+```
+$ sudo gitlab-rake gitlab:cleanup:remote_upload_files DRY_RUN=false
+
+I, [2018-08-02T10:26:47.598424 #45087] INFO -- : Looking for orphaned remote uploads to remove...
+I, [2018-08-02T10:26:47.753131 #45087] INFO -- : Moved to lost and found: @hashed/6b/DSC_6152.JPG -> lost_and_found/@hashed/6b/DSC_6152.JPG
+I, [2018-08-02T10:26:47.764356 #45087] INFO -- : Moved to lost and found: @hashed/79/02/7902699be42c8a8e46fbbb4501726517e86b22c56a189f7625a6da49081b2451/711491b29d3eb08837798c4909e2aa4d/DSC00314.jpg -> lost_and_found/@hashed/79/02/7902699be42c8a8e46fbbb4501726517e86b22c56a189f7625a6da49081b2451/711491b29d3eb08837798c4909e2aa4d/DSC00314.jpg
+```
diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md
index 96a08c04905..b1b822f25bd 100644
--- a/doc/user/profile/index.md
+++ b/doc/user/profile/index.md
@@ -30,6 +30,7 @@ You can edit your account settings by navigating from the up-right corner menu b
From there, you can:
- Update your personal information
+- Set a [custom status](#current-status) for your profile
- Manage [2FA](account/two_factor_authentication.md)
- Change your username and [delete your account](account/delete_account.md)
- Manage applications that can
@@ -90,6 +91,27 @@ To enable private profile:
NOTE: **Note:**
You and GitLab admins can see your the abovementioned information on your profile even if it is private.
+## Current status
+
+> Introduced in GitLab 11.2.
+
+You can provide a custom status message for your user profile along with an emoji that describes it.
+This may be helpful when you are out of office or otherwise not available.
+Other users can then take your status into consideration when responding to your issues or assigning work to you.
+Please be aware that your status is publicly visible even if your [profile is private](#private-profile).
+
+To set your current status:
+
+1. Navigate to your personal [profile settings](#profile-settings).
+1. In the text field below `Your status`, enter your status message.
+1. Select an emoji from the dropdown if you like.
+1. Hit **Update profile settings**.
+
+Status messages are restricted to 100 characters of plain text.
+They may however contain emoji codes such as `I'm on vacation :palm_tree:`.
+
+You can also set your current status [using the API](../../api/users.md#user-status).
+
## Troubleshooting
### Why do I keep getting signed out?
diff --git a/doc/user/project/img/labels_project_list_search.png b/doc/user/project/img/labels_project_list_search.png
new file mode 100644
index 00000000000..ff9bf92e1c3
--- /dev/null
+++ b/doc/user/project/img/labels_project_list_search.png
Binary files differ
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index 6dfdbe6c0d5..49b49271cff 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -119,7 +119,7 @@ Issue Board, that is, create or delete lists and drag issues from one list to an
## Issue Board terminology
- **Issue Board** - Each board represents a unique view for your issues. It can have multiple lists with each list consisting of issues represented by cards.
-- **List** - A column on the issue board that displays issues matching certain attributes. In addition to the default lists of 'Backlog' and 'Closed' issue, each additional list will show issues matching your chosen label or assignee.
+- **List** - A column on the issue board that displays issues matching certain attributes. In addition to the default lists of 'Backlog' and 'Closed' issue, each additional list will show issues matching your chosen label or assignee. On the top of that list you can see the number of issues that belong to it.
- **Label list**: a list based on a label. It shows all opened issues with that label.
- **Assignee list**: a list which includes all issues assigned to a user.
- **Backlog** (default): shows all open issues that do not belong to one of the other lists. Always appears as the leftmost list.
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index 914898ea2ea..3ae6dbe585e 100644
--- a/doc/user/project/labels.md
+++ b/doc/user/project/labels.md
@@ -69,6 +69,16 @@ Every issue and merge request can be assigned any number of labels. The labels a
|:---:|:---:|
| ![Labels sidebar](img/labels_sidebar.png) | ![Labels sidebar assign](img/labels_sidebar_assign.png) |
+## Searching for project labels
+
+You can search for project labels by navigating from the left sidebar to your
+project's **Issues > Labels** and entering your query to the search bar on the
+top-right:
+
+![Labels project list search](img/labels_project_list_search.png)
+
+GitLab will consider the label title and description for the search.
+
## Filtering issues and merge requests by label
### Filtering in list pages
diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md
index b0143e45ab6..511ac2d7e79 100644
--- a/doc/user/project/web_ide/index.md
+++ b/doc/user/project/web_ide/index.md
@@ -59,9 +59,18 @@ left.
> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0.
Switching between your authored and assigned merge requests can be done without
-leaving the Web IDE. Click the project name in the top left to open a list of
-merge requests. You will need to commit or discard all your changes before
+leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
+of merge requests. You will need to commit or discard all your changes before
switching to a different merge request.
+## Switching branches
+
+> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20850) [GitLab Core][ce] 11.2.
+
+Switching between branches of the current project repository can be done without
+leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
+of branches. You will need to commit or discard all your changes before
+switching to a different branch.
+
[ce]: https://about.gitlab.com/pricing/
[ee]: https://about.gitlab.com/pricing/
diff --git a/doc/user/search/img/issues_mrs_shortcut.png b/doc/user/search/img/issues_mrs_shortcut.png
index 6380b337b54..cf43df98aa0 100644
--- a/doc/user/search/img/issues_mrs_shortcut.png
+++ b/doc/user/search/img/issues_mrs_shortcut.png
Binary files differ
diff --git a/doc/user/search/img/project_search.png b/doc/user/search/img/project_search.png
index 3150b40de29..0b76d7d6038 100644
--- a/doc/user/search/img/project_search.png
+++ b/doc/user/search/img/project_search.png
Binary files differ
diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md
index 760cd87d4cc..dda82352c67 100644
--- a/doc/workflow/todos.md
+++ b/doc/workflow/todos.md
@@ -109,6 +109,7 @@ There are four kinds of filters you can use on your Todos dashboard.
| Filter | Description |
| ------- | ----------- |
| Project | Filter by project |
+| Group | Filter by group |
| Author | Filter by the author that triggered the Todo |
| Type | Filter by issue or merge request |
| Action | Filter by the action that triggered the Todo |
diff --git a/lib/api/boards.rb b/lib/api/boards.rb
index 086d39d5070..0f89414148b 100644
--- a/lib/api/boards.rb
+++ b/lib/api/boards.rb
@@ -71,12 +71,10 @@ module API
success Entities::List
end
params do
- requires :label_id, type: Integer, desc: 'The ID of an existing label'
+ use :list_creation_params
end
post '/lists' do
- unless available_labels_for(user_project).exists?(params[:label_id])
- render_api_error!({ error: 'Label not found!' }, 400)
- end
+ authorize_list_type_resource!
authorize!(:admin_list, user_project)
diff --git a/lib/api/boards_responses.rb b/lib/api/boards_responses.rb
index ead0943a74d..7e873012efe 100644
--- a/lib/api/boards_responses.rb
+++ b/lib/api/boards_responses.rb
@@ -14,7 +14,7 @@ module API
def create_list
create_list_service =
- ::Boards::Lists::CreateService.new(board_parent, current_user, { label_id: params[:label_id] })
+ ::Boards::Lists::CreateService.new(board_parent, current_user, create_list_params)
list = create_list_service.execute(board)
@@ -25,6 +25,10 @@ module API
end
end
+ def create_list_params
+ params.slice(:label_id)
+ end
+
def move_list(list)
move_list_service =
::Boards::Lists::MoveService.new(board_parent, current_user, { position: params[:position].to_i })
@@ -44,6 +48,16 @@ module API
end
end
end
+
+ def authorize_list_type_resource!
+ unless available_labels_for(board_parent).exists?(params[:label_id])
+ render_api_error!({ error: 'Label not found!' }, 400)
+ end
+ end
+
+ params :list_creation_params do
+ requires :label_id, type: Integer, desc: 'The ID of an existing label'
+ end
end
end
end
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index 4b223a391ae..3e445e6b1fa 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -19,6 +19,7 @@ module API
params :filter_params do
optional :search, type: String, desc: 'Return list of branches matching the search criteria'
+ optional :sort, type: String, desc: 'Return list of branches sorted by the given field'
end
end
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index f858d9fa23d..27f28e1df93 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -795,28 +795,33 @@ module API
class Todo < Grape::Entity
expose :id
- expose :project, using: Entities::BasicProjectDetails
+ expose :project, using: Entities::ProjectIdentity, if: -> (todo, _) { todo.project_id }
+ expose :group, using: 'API::Entities::NamespaceBasic', if: -> (todo, _) { todo.group_id }
expose :author, using: Entities::UserBasic
expose :action_name
expose :target_type
expose :target do |todo, options|
- Entities.const_get(todo.target_type).represent(todo.target, options)
+ todo_target_class(todo.target_type).represent(todo.target, options)
end
expose :target_url do |todo, options|
target_type = todo.target_type.underscore
- target_url = "namespace_project_#{target_type}_url"
+ target_url = "#{todo.parent.class.to_s.underscore}_#{target_type}_url"
target_anchor = "note_#{todo.note_id}" if todo.note_id?
Gitlab::Routing
.url_helpers
- .public_send(target_url, todo.project.namespace, todo.project, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend
+ .public_send(target_url, todo.parent, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend
end
expose :body
expose :state
expose :created_at
+
+ def todo_target_class(target_type)
+ ::API::Entities.const_get(target_type)
+ end
end
class NamespaceBasic < Grape::Entity
diff --git a/lib/api/group_boards.rb b/lib/api/group_boards.rb
index aa9fff25fc8..3832cdc10a8 100644
--- a/lib/api/group_boards.rb
+++ b/lib/api/group_boards.rb
@@ -70,12 +70,10 @@ module API
success Entities::List
end
params do
- requires :label_id, type: Integer, desc: 'The ID of an existing label'
+ use :list_creation_params
end
post '/lists' do
- unless available_labels_for(board_parent).exists?(params[:label_id])
- render_api_error!({ error: 'Label not found!' }, 400)
- end
+ authorize_list_type_resource!
authorize!(:admin_list, user_group)
diff --git a/lib/bitbucket_server/client.rb b/lib/bitbucket_server/client.rb
new file mode 100644
index 00000000000..15e59f93141
--- /dev/null
+++ b/lib/bitbucket_server/client.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Client
+ attr_reader :connection
+
+ ServerError = Class.new(StandardError)
+
+ SERVER_ERRORS = [SocketError,
+ OpenSSL::SSL::SSLError,
+ Errno::ECONNRESET,
+ Errno::ECONNREFUSED,
+ Errno::EHOSTUNREACH,
+ Net::OpenTimeout,
+ Net::ReadTimeout,
+ Gitlab::HTTP::BlockedUrlError,
+ BitbucketServer::Connection::ConnectionError].freeze
+
+ def initialize(options = {})
+ @connection = Connection.new(options)
+ end
+
+ def pull_requests(project_key, repo)
+ path = "/projects/#{project_key}/repos/#{repo}/pull-requests?state=ALL"
+ get_collection(path, :pull_request)
+ end
+
+ def activities(project_key, repo, pull_request_id)
+ path = "/projects/#{project_key}/repos/#{repo}/pull-requests/#{pull_request_id}/activities"
+ get_collection(path, :activity)
+ end
+
+ def repo(project, repo_name)
+ parsed_response = connection.get("/projects/#{project}/repos/#{repo_name}")
+ BitbucketServer::Representation::Repo.new(parsed_response)
+ end
+
+ def repos
+ path = "/repos"
+ get_collection(path, :repo)
+ end
+
+ def create_branch(project_key, repo, branch_name, sha)
+ payload = {
+ name: branch_name,
+ startPoint: sha,
+ message: 'GitLab temporary branch for import'
+ }
+
+ connection.post("/projects/#{project_key}/repos/#{repo}/branches", payload.to_json)
+ end
+
+ def delete_branch(project_key, repo, branch_name, sha)
+ payload = {
+ name: Gitlab::Git::BRANCH_REF_PREFIX + branch_name,
+ dryRun: false
+ }
+
+ connection.delete(:branches, "/projects/#{project_key}/repos/#{repo}/branches", payload.to_json)
+ end
+
+ private
+
+ def get_collection(path, type)
+ paginator = BitbucketServer::Paginator.new(connection, Addressable::URI.escape(path), type)
+ BitbucketServer::Collection.new(paginator)
+ rescue *SERVER_ERRORS => e
+ raise ServerError, e
+ end
+ end
+end
diff --git a/lib/bitbucket_server/collection.rb b/lib/bitbucket_server/collection.rb
new file mode 100644
index 00000000000..b50c5dde352
--- /dev/null
+++ b/lib/bitbucket_server/collection.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Collection < Enumerator
+ def initialize(paginator)
+ super() do |yielder|
+ loop do
+ paginator.items.each { |item| yielder << item }
+ end
+ end
+
+ lazy
+ end
+
+ def method_missing(method, *args)
+ return super unless self.respond_to?(method)
+
+ self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend
+ block_given? ? yield(item) : item
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/connection.rb b/lib/bitbucket_server/connection.rb
new file mode 100644
index 00000000000..45a437844bd
--- /dev/null
+++ b/lib/bitbucket_server/connection.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Connection
+ include ActionView::Helpers::SanitizeHelper
+
+ DEFAULT_API_VERSION = '1.0'
+ SEPARATOR = '/'
+
+ attr_reader :api_version, :base_uri, :username, :token
+
+ ConnectionError = Class.new(StandardError)
+
+ def initialize(options = {})
+ @api_version = options.fetch(:api_version, DEFAULT_API_VERSION)
+ @base_uri = options[:base_uri]
+ @username = options[:user]
+ @token = options[:password]
+ end
+
+ def get(path, extra_query = {})
+ response = Gitlab::HTTP.get(build_url(path),
+ basic_auth: auth,
+ headers: accept_headers,
+ query: extra_query)
+
+ check_errors!(response)
+
+ response.parsed_response
+ end
+
+ def post(path, body)
+ response = Gitlab::HTTP.post(build_url(path),
+ basic_auth: auth,
+ headers: post_headers,
+ body: body)
+
+ check_errors!(response)
+
+ response.parsed_response
+ end
+
+ # We need to support two different APIs for deletion:
+ #
+ # /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/branches/default
+ # /rest/branch-utils/1.0/projects/{projectKey}/repos/{repositorySlug}/branches
+ def delete(resource, path, body)
+ url = delete_url(resource, path)
+
+ response = Gitlab::HTTP.delete(url,
+ basic_auth: auth,
+ headers: post_headers,
+ body: body)
+
+ check_errors!(response)
+
+ response.parsed_response
+ end
+
+ private
+
+ def check_errors!(response)
+ raise ConnectionError, "Response is not valid JSON" unless response.parsed_response.is_a?(Hash)
+
+ return if response.code >= 200 && response.code < 300
+
+ details = sanitize(response.parsed_response.dig('errors', 0, 'message'))
+ message = "Error #{response.code}"
+ message += ": #{details}" if details
+
+ raise ConnectionError, message
+ rescue JSON::ParserError
+ raise ConnectionError, "Unable to parse the server response as JSON"
+ end
+
+ def auth
+ @auth ||= { username: username, password: token }
+ end
+
+ def accept_headers
+ @accept_headers ||= { 'Accept' => 'application/json' }
+ end
+
+ def post_headers
+ @post_headers ||= accept_headers.merge({ 'Content-Type' => 'application/json' })
+ end
+
+ def build_url(path)
+ return path if path.starts_with?(root_url)
+
+ url_join_paths(root_url, path)
+ end
+
+ def root_url
+ url_join_paths(base_uri, "/rest/api/#{api_version}")
+ end
+
+ def delete_url(resource, path)
+ if resource == :branches
+ url_join_paths(base_uri, "/rest/branch-utils/#{api_version}#{path}")
+ else
+ build_url(path)
+ end
+ end
+
+ # URI.join is stupid in that slashes are important:
+ #
+ # # URI.join('http://example.com/subpath', 'hello')
+ # => http://example.com/hello
+ #
+ # We really want http://example.com/subpath/hello
+ #
+ def url_join_paths(*paths)
+ paths.map { |path| strip_slashes(path) }.join(SEPARATOR)
+ end
+
+ def strip_slashes(path)
+ path = path[1..-1] if path.starts_with?(SEPARATOR)
+ path.chomp(SEPARATOR)
+ end
+ end
+end
diff --git a/lib/bitbucket_server/page.rb b/lib/bitbucket_server/page.rb
new file mode 100644
index 00000000000..5d9a3168876
--- /dev/null
+++ b/lib/bitbucket_server/page.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Page
+ attr_reader :attrs, :items
+
+ def initialize(raw, type)
+ @attrs = parse_attrs(raw)
+ @items = parse_values(raw, representation_class(type))
+ end
+
+ def next?
+ !attrs.fetch(:isLastPage, true)
+ end
+
+ def next
+ attrs.fetch(:nextPageStart)
+ end
+
+ private
+
+ def parse_attrs(raw)
+ raw.slice('size', 'nextPageStart', 'isLastPage').symbolize_keys
+ end
+
+ def parse_values(raw, bitbucket_rep_class)
+ return [] unless raw['values'] && raw['values'].is_a?(Array)
+
+ bitbucket_rep_class.decorate(raw['values'])
+ end
+
+ def representation_class(type)
+ BitbucketServer::Representation.const_get(type.to_s.camelize)
+ end
+ end
+end
diff --git a/lib/bitbucket_server/paginator.rb b/lib/bitbucket_server/paginator.rb
new file mode 100644
index 00000000000..c351fb2f11f
--- /dev/null
+++ b/lib/bitbucket_server/paginator.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Paginator
+ PAGE_LENGTH = 25
+
+ def initialize(connection, url, type)
+ @connection = connection
+ @type = type
+ @url = url
+ @page = nil
+ end
+
+ def items
+ raise StopIteration unless has_next_page?
+
+ @page = fetch_next_page
+ @page.items
+ end
+
+ private
+
+ attr_reader :connection, :page, :url, :type
+
+ def has_next_page?
+ page.nil? || page.next?
+ end
+
+ def next_offset
+ page.nil? ? 0 : page.next
+ end
+
+ def fetch_next_page
+ parsed_response = connection.get(@url, start: next_offset, limit: PAGE_LENGTH)
+ Page.new(parsed_response, type)
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/activity.rb b/lib/bitbucket_server/representation/activity.rb
new file mode 100644
index 00000000000..08bf30a5d1e
--- /dev/null
+++ b/lib/bitbucket_server/representation/activity.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class Activity < Representation::Base
+ def comment?
+ action == 'COMMENTED'
+ end
+
+ def inline_comment?
+ !!(comment? && comment_anchor)
+ end
+
+ def comment
+ return unless comment?
+
+ @comment ||=
+ if inline_comment?
+ PullRequestComment.new(raw)
+ else
+ Comment.new(raw)
+ end
+ end
+
+ # TODO Move this into MergeEvent
+ def merge_event?
+ action == 'MERGED'
+ end
+
+ def committer_user
+ commit.dig('committer', 'displayName')
+ end
+
+ def committer_email
+ commit.dig('committer', 'emailAddress')
+ end
+
+ def merge_timestamp
+ timestamp = commit['committerTimestamp']
+
+ self.class.convert_timestamp(timestamp)
+ end
+
+ def merge_commit
+ commit['id']
+ end
+
+ def created_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ private
+
+ def commit
+ raw.fetch('commit', {})
+ end
+
+ def action
+ raw['action']
+ end
+
+ def comment_anchor
+ raw['commentAnchor']
+ end
+
+ def created_date
+ raw['createdDate']
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/base.rb b/lib/bitbucket_server/representation/base.rb
new file mode 100644
index 00000000000..a1961bae6ef
--- /dev/null
+++ b/lib/bitbucket_server/representation/base.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class Base
+ attr_reader :raw
+
+ def initialize(raw)
+ @raw = raw
+ end
+
+ def self.decorate(entries)
+ entries.map { |entry| new(entry)}
+ end
+
+ def self.convert_timestamp(time_usec)
+ Time.at(time_usec / 1000) if time_usec.is_a?(Integer)
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/comment.rb b/lib/bitbucket_server/representation/comment.rb
new file mode 100644
index 00000000000..99b97a3b181
--- /dev/null
+++ b/lib/bitbucket_server/representation/comment.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ # A general comment with the structure:
+ # "comment": {
+ # "author": {
+ # "active": true,
+ # "displayName": "root",
+ # "emailAddress": "stanhu+bitbucket@gitlab.com",
+ # "id": 1,
+ # "links": {
+ # "self": [
+ # {
+ # "href": "http://localhost:7990/users/root"
+ # }
+ # ]
+ # },
+ # "name": "root",
+ # "slug": "root",
+ # "type": "NORMAL"
+ # }
+ # }
+ # }
+ class Comment < Representation::Base
+ attr_reader :parent_comment
+
+ CommentNode = Struct.new(:raw_comments, :parent)
+
+ def initialize(raw, parent_comment: nil)
+ super(raw)
+
+ @parent_comment = parent_comment
+ end
+
+ def id
+ raw_comment['id']
+ end
+
+ def author_username
+ author['displayName']
+ end
+
+ def author_email
+ author['emailAddress']
+ end
+
+ def note
+ raw_comment['text']
+ end
+
+ def created_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ def updated_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ # Bitbucket Server supports the ability to reply to any comment
+ # and created multiple threads. It represents these as a linked list
+ # of comments within comments. For example:
+ #
+ # "comments": [
+ # {
+ # "author" : ...
+ # "comments": [
+ # {
+ # "author": ...
+ #
+ # Since GitLab only supports a single thread, we flatten all these
+ # comments into a single discussion.
+ def comments
+ @comments ||= flatten_comments
+ end
+
+ private
+
+ # In order to provide context for each reply, we need to track
+ # the parent of each comment. This method works as follows:
+ #
+ # 1. Insert the root comment into the workset. The root element is the current note.
+ # 2. For each node in the workset:
+ # a. Examine if it has replies to that comment. If it does,
+ # insert that node into the workset.
+ # b. Parse that note into a Comment structure and add it to a flat list.
+ def flatten_comments
+ comments = raw_comment['comments']
+ workset =
+ if comments
+ [CommentNode.new(comments, self)]
+ else
+ []
+ end
+
+ all_comments = []
+
+ until workset.empty?
+ node = workset.pop
+ parent = node.parent
+
+ node.raw_comments.each do |comment|
+ new_comments = comment.delete('comments')
+ current_comment = Comment.new({ 'comment' => comment }, parent_comment: parent)
+ all_comments << current_comment
+ workset << CommentNode.new(new_comments, current_comment) if new_comments
+ end
+ end
+
+ all_comments
+ end
+
+ def raw_comment
+ raw.fetch('comment', {})
+ end
+
+ def author
+ raw_comment['author']
+ end
+
+ def created_date
+ raw_comment['createdDate']
+ end
+
+ def updated_date
+ raw_comment['updatedDate']
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/pull_request.rb b/lib/bitbucket_server/representation/pull_request.rb
new file mode 100644
index 00000000000..c3e927d8de7
--- /dev/null
+++ b/lib/bitbucket_server/representation/pull_request.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class PullRequest < Representation::Base
+ def author
+ raw.dig('author', 'user', 'name')
+ end
+
+ def author_email
+ raw.dig('author', 'user', 'emailAddress')
+ end
+
+ def description
+ raw['description']
+ end
+
+ def iid
+ raw['id']
+ end
+
+ def state
+ case raw['state']
+ when 'MERGED'
+ 'merged'
+ when 'DECLINED'
+ 'closed'
+ else
+ 'opened'
+ end
+ end
+
+ def merged?
+ state == 'merged'
+ end
+
+ def created_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ def updated_at
+ self.class.convert_timestamp(updated_date)
+ end
+
+ def title
+ raw['title']
+ end
+
+ def source_branch_name
+ raw.dig('fromRef', 'id')
+ end
+
+ def source_branch_sha
+ raw.dig('fromRef', 'latestCommit')
+ end
+
+ def target_branch_name
+ raw.dig('toRef', 'id')
+ end
+
+ def target_branch_sha
+ raw.dig('toRef', 'latestCommit')
+ end
+
+ private
+
+ def created_date
+ raw['createdDate']
+ end
+
+ def updated_date
+ raw['updatedDate']
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/pull_request_comment.rb b/lib/bitbucket_server/representation/pull_request_comment.rb
new file mode 100644
index 00000000000..a2b3873a397
--- /dev/null
+++ b/lib/bitbucket_server/representation/pull_request_comment.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ # An inline comment with the following structure that identifies
+ # the part of the diff:
+ #
+ # "commentAnchor": {
+ # "diffType": "EFFECTIVE",
+ # "fileType": "TO",
+ # "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ # "line": 1,
+ # "lineType": "ADDED",
+ # "orphaned": false,
+ # "path": "CHANGELOG.md",
+ # "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ # }
+ #
+ # More details in https://docs.atlassian.com/bitbucket-server/rest/5.12.0/bitbucket-rest.html.
+ class PullRequestComment < Comment
+ def from_sha
+ comment_anchor['fromHash']
+ end
+
+ def to_sha
+ comment_anchor['toHash']
+ end
+
+ def to?
+ file_type == 'TO'
+ end
+
+ def from?
+ file_type == 'FROM'
+ end
+
+ def added?
+ line_type == 'ADDED'
+ end
+
+ def removed?
+ line_type == 'REMOVED'
+ end
+
+ # There are three line comment types: added, removed, or context.
+ #
+ # 1. An added type means a new line was inserted, so there is no old position.
+ # 2. A removed type means a line was removed, so there is no new position.
+ # 3. A context type means the line was unmodified, so there is both a
+ # old and new position.
+ def new_pos
+ return if removed?
+ return unless line_position
+
+ line_position[1]
+ end
+
+ def old_pos
+ return if added?
+ return unless line_position
+
+ line_position[0]
+ end
+
+ def file_path
+ comment_anchor.fetch('path')
+ end
+
+ private
+
+ def file_type
+ comment_anchor['fileType']
+ end
+
+ def line_type
+ comment_anchor['lineType']
+ end
+
+ # Each comment contains the following information about the diff:
+ #
+ # hunks: [
+ # {
+ # segments: [
+ # {
+ # "lines": [
+ # {
+ # "commentIds": [ N ],
+ # "source": X,
+ # "destination": Y
+ # }, ...
+ # ] ....
+ #
+ # To determine the line position of a comment, we search all the lines
+ # entries until we find this comment ID.
+ def line_position
+ @line_position ||= diff_hunks.each do |hunk|
+ segments = hunk.fetch('segments', [])
+ segments.each do |segment|
+ lines = segment.fetch('lines', [])
+ lines.each do |line|
+ if line['commentIds']&.include?(id)
+ return [line['source'], line['destination']]
+ end
+ end
+ end
+ end
+ end
+
+ def comment_anchor
+ raw.fetch('commentAnchor', {})
+ end
+
+ def diff
+ raw.fetch('diff', {})
+ end
+
+ def diff_hunks
+ diff.fetch('hunks', [])
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/repo.rb b/lib/bitbucket_server/representation/repo.rb
new file mode 100644
index 00000000000..6c494b79166
--- /dev/null
+++ b/lib/bitbucket_server/representation/repo.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class Repo < Representation::Base
+ def initialize(raw)
+ super(raw)
+ end
+
+ def project_key
+ raw.dig('project', 'key')
+ end
+
+ def project_name
+ raw.dig('project', 'name')
+ end
+
+ def slug
+ raw['slug']
+ end
+
+ def browse_url
+ # The JSON reponse contains an array of 1 element. Not sure if there
+ # are cases where multiple links would be provided.
+ raw.dig('links', 'self').first.fetch('href')
+ end
+
+ def clone_url
+ raw['links']['clone'].find { |link| link['name'].starts_with?('http') }.fetch('href')
+ end
+
+ def description
+ project['description']
+ end
+
+ def full_name
+ "#{project_name}/#{name}"
+ end
+
+ def issues_enabled?
+ true
+ end
+
+ def name
+ raw['name']
+ end
+
+ def valid?
+ raw['scmId'] == 'git'
+ end
+
+ def visibility_level
+ if project['public']
+ Gitlab::VisibilityLevel::PUBLIC
+ else
+ Gitlab::VisibilityLevel::PRIVATE
+ end
+ end
+
+ def project
+ raw['project']
+ end
+
+ def to_s
+ full_name
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/remove_restricted_todos.rb b/lib/gitlab/background_migration/remove_restricted_todos.rb
new file mode 100644
index 00000000000..68f3fa62170
--- /dev/null
+++ b/lib/gitlab/background_migration/remove_restricted_todos.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ class RemoveRestrictedTodos
+ PRIVATE_FEATURE = 10
+ PRIVATE_PROJECT = 0
+
+ class Project < ActiveRecord::Base
+ self.table_name = 'projects'
+ end
+
+ class ProjectAuthorization < ActiveRecord::Base
+ self.table_name = 'project_authorizations'
+ end
+
+ class ProjectFeature < ActiveRecord::Base
+ self.table_name = 'project_features'
+ end
+
+ class Todo < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'todos'
+ end
+
+ class Issue < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'issues'
+ end
+
+ def perform(start_id, stop_id)
+ projects = Project.where('EXISTS (SELECT 1 FROM todos WHERE todos.project_id = projects.id)')
+ .where(id: start_id..stop_id)
+
+ projects.each do |project|
+ remove_confidential_issue_todos(project.id)
+
+ if project.visibility_level == PRIVATE_PROJECT
+ remove_non_members_todos(project.id)
+ else
+ remove_restricted_features_todos(project.id)
+ end
+ end
+ end
+
+ private
+
+ def remove_non_members_todos(project_id)
+ Todo.where(project_id: project_id)
+ .where('user_id NOT IN (?)', authorized_users(project_id))
+ .each_batch(of: 5000) do |batch|
+ batch.delete_all
+ end
+ end
+
+ def remove_confidential_issue_todos(project_id)
+ # min access level to access a confidential issue is reporter
+ min_reporters = authorized_users(project_id)
+ .select(:user_id)
+ .where('access_level >= ?', 20)
+
+ confidential_issues = Issue.select(:id, :author_id).where(confidential: true, project_id: project_id)
+ confidential_issues.each_batch(of: 100) do |batch|
+ batch.each do |issue|
+ assigned_users = IssueAssignee.select(:user_id).where(issue_id: issue.id)
+
+ todos = Todo.where(target_type: 'Issue', target_id: issue.id)
+ .where('user_id NOT IN (?)', min_reporters)
+ .where('user_id NOT IN (?)', assigned_users)
+ todos = todos.where('user_id != ?', issue.author_id) if issue.author_id
+
+ todos.delete_all
+ end
+ end
+ end
+
+ def remove_restricted_features_todos(project_id)
+ ProjectFeature.where(project_id: project_id).each do |project_features|
+ target_types = []
+ target_types << 'Issue' if private?(project_features.issues_access_level)
+ target_types << 'MergeRequest' if private?(project_features.merge_requests_access_level)
+ target_types << 'Commit' if private?(project_features.repository_access_level)
+
+ next if target_types.empty?
+
+ Todo.where(project_id: project_id)
+ .where('user_id NOT IN (?)', authorized_users(project_id))
+ .where(target_type: target_types)
+ .delete_all
+ end
+ end
+
+ def private?(feature_level)
+ feature_level == PRIVATE_FEATURE
+ end
+
+ def authorized_users(project_id)
+ ProjectAuthorization.select(:user_id).where(project_id: project_id)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb
new file mode 100644
index 00000000000..268d21a77d1
--- /dev/null
+++ b/lib/gitlab/bitbucket_server_import/importer.rb
@@ -0,0 +1,327 @@
+module Gitlab
+ module BitbucketServerImport
+ class Importer
+ include Gitlab::ShellAdapter
+ attr_reader :recover_missing_commits
+ attr_reader :project, :project_key, :repository_slug, :client, :errors, :users
+
+ REMOTE_NAME = 'bitbucket_server'.freeze
+ BATCH_SIZE = 100
+
+ TempBranch = Struct.new(:name, :sha)
+
+ def self.imports_repository?
+ true
+ end
+
+ def self.refmap
+ [:heads, :tags, '+refs/pull-requests/*/to:refs/merge-requests/*/head']
+ end
+
+ # Unlike GitHub, you can't grab the commit SHAs for pull requests that
+ # have been closed but not merged even though Bitbucket has these
+ # commits internally. We can recover these pull requests by creating a
+ # branch with the Bitbucket REST API, but by default we turn this
+ # behavior off.
+ def initialize(project, recover_missing_commits: false)
+ @project = project
+ @recover_missing_commits = recover_missing_commits
+ @project_key = project.import_data.data['project_key']
+ @repository_slug = project.import_data.data['repo_slug']
+ @client = BitbucketServer::Client.new(project.import_data.credentials)
+ @formatter = Gitlab::ImportFormatter.new
+ @errors = []
+ @users = {}
+ @temp_branches = []
+ end
+
+ def execute
+ import_repository
+ import_pull_requests
+ delete_temp_branches
+ handle_errors
+
+ true
+ end
+
+ private
+
+ def handle_errors
+ return unless errors.any?
+
+ project.update_column(:import_error, {
+ message: 'The remote data could not be fully imported.',
+ errors: errors
+ }.to_json)
+ end
+
+ def gitlab_user_id(email)
+ find_user_id(email) || project.creator_id
+ end
+
+ def find_user_id(email)
+ return nil unless email
+
+ return users[email] if users.key?(email)
+
+ user = User.find_by_any_email(email, confirmed: true)
+ users[email] = user&.id
+
+ user&.id
+ end
+
+ def repo
+ @repo ||= client.repo(project_key, repository_slug)
+ end
+
+ def sha_exists?(sha)
+ project.repository.commit(sha)
+ end
+
+ def temp_branch_name(pull_request, suffix)
+ "gitlab/import/pull-request/#{pull_request.iid}/#{suffix}"
+ end
+
+ # This method restores required SHAs that GitLab needs to create diffs
+ # into branch names as the following:
+ #
+ # gitlab/import/pull-request/N/{to,from}
+ def restore_branches(pull_requests)
+ shas_to_restore = []
+
+ pull_requests.each do |pull_request|
+ shas_to_restore << TempBranch.new(temp_branch_name(pull_request, :from),
+ pull_request.source_branch_sha)
+ shas_to_restore << TempBranch.new(temp_branch_name(pull_request, :to),
+ pull_request.target_branch_sha)
+ end
+
+ # Create the branches on the Bitbucket Server first
+ created_branches = restore_branch_shas(shas_to_restore)
+
+ @temp_branches += created_branches
+ # Now sync the repository so we get the new branches
+ import_repository unless created_branches.empty?
+ end
+
+ def restore_branch_shas(shas_to_restore)
+ shas_to_restore.each_with_object([]) do |temp_branch, branches_created|
+ branch_name = temp_branch.name
+ sha = temp_branch.sha
+
+ next if sha_exists?(sha)
+
+ begin
+ client.create_branch(project_key, repository_slug, branch_name, sha)
+ branches_created << temp_branch
+ rescue BitbucketServer::Connection::ConnectionError => e
+ Rails.logger.warn("BitbucketServerImporter: Unable to recreate branch for SHA #{sha}: #{e}")
+ end
+ end
+ end
+
+ def import_repository
+ project.ensure_repository
+ project.repository.fetch_as_mirror(project.import_url, refmap: self.class.refmap, remote_name: REMOTE_NAME)
+ rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e
+ # Expire cache to prevent scenarios such as:
+ # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true
+ # 2. Retried import, repo is broken or not imported but +exists?+ still returns true
+ project.repository.expire_content_cache if project.repository_exists?
+
+ raise e.message
+ end
+
+ # Bitbucket Server keeps tracks of references for open pull requests in
+ # refs/heads/pull-requests, but closed and merged requests get moved
+ # into hidden internal refs under stash-refs/pull-requests. Unless the
+ # SHAs involved are at the tip of a branch or tag, there is no way to
+ # retrieve the server for those commits.
+ #
+ # To avoid losing history, we use the Bitbucket API to re-create the branch
+ # on the remote server. Then we have to issue a `git fetch` to download these
+ # branches.
+ def import_pull_requests
+ pull_requests = client.pull_requests(project_key, repository_slug).to_a
+
+ # Creating branches on the server and fetching the newly-created branches
+ # may take a number of network round-trips. Do this in batches so that we can
+ # avoid doing a git fetch for every new branch.
+ pull_requests.each_slice(BATCH_SIZE) do |batch|
+ restore_branches(batch) if recover_missing_commits
+
+ batch.each do |pull_request|
+ begin
+ import_bitbucket_pull_request(pull_request)
+ rescue StandardError => e
+ errors << { type: :pull_request, iid: pull_request.iid, errors: e.message, trace: e.backtrace.join("\n"), raw_response: pull_request.raw }
+ end
+ end
+ end
+ end
+
+ def delete_temp_branches
+ @temp_branches.each do |branch|
+ begin
+ client.delete_branch(project_key, repository_slug, branch.name, branch.sha)
+ project.repository.delete_branch(branch.name)
+ rescue BitbucketServer::Connection::ConnectionError => e
+ @errors << { type: :delete_temp_branches, branch_name: branch.name, errors: e.message }
+ end
+ end
+ end
+
+ def import_bitbucket_pull_request(pull_request)
+ description = ''
+ description += @formatter.author_line(pull_request.author) unless find_user_id(pull_request.author_email)
+ description += pull_request.description if pull_request.description
+
+ source_branch_sha = pull_request.source_branch_sha
+ target_branch_sha = pull_request.target_branch_sha
+ author_id = gitlab_user_id(pull_request.author_email)
+
+ attributes = {
+ iid: pull_request.iid,
+ title: pull_request.title,
+ description: description,
+ source_project: project,
+ source_branch: Gitlab::Git.ref_name(pull_request.source_branch_name),
+ source_branch_sha: source_branch_sha,
+ target_project: project,
+ target_branch: Gitlab::Git.ref_name(pull_request.target_branch_name),
+ target_branch_sha: target_branch_sha,
+ state: pull_request.state,
+ author_id: author_id,
+ assignee_id: nil,
+ created_at: pull_request.created_at,
+ updated_at: pull_request.updated_at
+ }
+
+ merge_request = project.merge_requests.create!(attributes)
+ import_pull_request_comments(pull_request, merge_request) if merge_request.persisted?
+ end
+
+ def import_pull_request_comments(pull_request, merge_request)
+ comments, other_activities = client.activities(project_key, repository_slug, pull_request.iid).partition(&:comment?)
+
+ merge_event = other_activities.find(&:merge_event?)
+ import_merge_event(merge_request, merge_event) if merge_event
+
+ inline_comments, pr_comments = comments.partition(&:inline_comment?)
+
+ import_inline_comments(inline_comments.map(&:comment), merge_request)
+ import_standalone_pr_comments(pr_comments.map(&:comment), merge_request)
+ end
+
+ def import_merge_event(merge_request, merge_event)
+ committer = merge_event.committer_email
+
+ user_id = gitlab_user_id(committer)
+ timestamp = merge_event.merge_timestamp
+ merge_request.update({ merge_commit_sha: merge_event.merge_commit })
+ metric = MergeRequest::Metrics.find_or_initialize_by(merge_request: merge_request)
+ metric.update(merged_by_id: user_id, merged_at: timestamp)
+ end
+
+ def import_inline_comments(inline_comments, merge_request)
+ inline_comments.each do |comment|
+ position = build_position(merge_request, comment)
+ parent = create_diff_note(merge_request, comment, position)
+
+ next unless parent&.persisted?
+
+ discussion_id = parent.discussion_id
+
+ comment.comments.each do |reply|
+ create_diff_note(merge_request, reply, position, discussion_id)
+ end
+ end
+ end
+
+ def create_diff_note(merge_request, comment, position, discussion_id = nil)
+ attributes = pull_request_comment_attributes(comment)
+ attributes.merge!(position: position, type: 'DiffNote')
+ attributes[:discussion_id] = discussion_id if discussion_id
+
+ note = merge_request.notes.build(attributes)
+
+ if note.valid?
+ note.save
+ return note
+ end
+
+ # Bitbucket Server supports the ability to comment on any line, not just the
+ # line in the diff. If we can't add the note as a DiffNote, fallback to creating
+ # a regular note.
+ create_fallback_diff_note(merge_request, comment, position)
+ rescue StandardError => e
+ errors << { type: :pull_request, id: comment.id, errors: e.message }
+ nil
+ end
+
+ def create_fallback_diff_note(merge_request, comment, position)
+ attributes = pull_request_comment_attributes(comment)
+ note = "*Comment on"
+
+ note += " #{position.old_path}:#{position.old_line} -->" if position.old_line
+ note += " #{position.new_path}:#{position.new_line}" if position.new_line
+ note += "*\n\n#{comment.note}"
+
+ attributes[:note] = note
+ merge_request.notes.create!(attributes)
+ end
+
+ def build_position(merge_request, pr_comment)
+ params = {
+ diff_refs: merge_request.diff_refs,
+ old_path: pr_comment.file_path,
+ new_path: pr_comment.file_path,
+ old_line: pr_comment.old_pos,
+ new_line: pr_comment.new_pos
+ }
+
+ Gitlab::Diff::Position.new(params)
+ end
+
+ def import_standalone_pr_comments(pr_comments, merge_request)
+ pr_comments.each do |comment|
+ begin
+ merge_request.notes.create!(pull_request_comment_attributes(comment))
+
+ comment.comments.each do |replies|
+ merge_request.notes.create!(pull_request_comment_attributes(replies))
+ end
+ rescue StandardError => e
+ errors << { type: :pull_request, iid: comment.id, errors: e.message }
+ end
+ end
+ end
+
+ def pull_request_comment_attributes(comment)
+ author = find_user_id(comment.author_email)
+ note = ''
+
+ unless author
+ author = project.creator_id
+ note = "*By #{comment.author_username} (#{comment.author_email})*\n\n"
+ end
+
+ note +=
+ # Provide some context for replying
+ if comment.parent_comment
+ "> #{comment.parent_comment.note.truncate(80)}\n\n#{comment.note}"
+ else
+ comment.note
+ end
+
+ {
+ project: project,
+ note: note,
+ author_id: author,
+ created_at: comment.created_at,
+ updated_at: comment.updated_at
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bitbucket_server_import/project_creator.rb b/lib/gitlab/bitbucket_server_import/project_creator.rb
new file mode 100644
index 00000000000..35e8cd7e0ab
--- /dev/null
+++ b/lib/gitlab/bitbucket_server_import/project_creator.rb
@@ -0,0 +1,36 @@
+module Gitlab
+ module BitbucketServerImport
+ class ProjectCreator
+ attr_reader :project_key, :repo_slug, :repo, :name, :namespace, :current_user, :session_data
+
+ def initialize(project_key, repo_slug, repo, name, namespace, current_user, session_data)
+ @project_key = project_key
+ @repo_slug = repo_slug
+ @repo = repo
+ @name = name
+ @namespace = namespace
+ @current_user = current_user
+ @session_data = session_data
+ end
+
+ def execute
+ ::Projects::CreateService.new(
+ current_user,
+ name: name,
+ path: name,
+ description: repo.description,
+ namespace_id: namespace.id,
+ visibility_level: repo.visibility_level,
+ import_type: 'bitbucket_server',
+ import_source: repo.browse_url,
+ import_url: repo.clone_url,
+ import_data: {
+ credentials: session_data,
+ data: { project_key: project_key, repo_slug: repo_slug }
+ },
+ skip_wiki: true
+ ).execute
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/checks/lfs_integrity.rb b/lib/gitlab/checks/lfs_integrity.rb
index f0e5773ec3c..b816a8f00cd 100644
--- a/lib/gitlab/checks/lfs_integrity.rb
+++ b/lib/gitlab/checks/lfs_integrity.rb
@@ -1,8 +1,6 @@
module Gitlab
module Checks
class LfsIntegrity
- REV_LIST_OBJECT_LIMIT = 2_000
-
def initialize(project, newrev)
@project = project
@newrev = newrev
@@ -11,7 +9,8 @@ module Gitlab
def objects_missing?
return false unless @newrev && @project.lfs_enabled?
- new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev).new_pointers(object_limit: REV_LIST_OBJECT_LIMIT)
+ new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev)
+ .new_pointers(object_limit: ::Gitlab::Git::Repository::REV_LIST_COMMIT_LIMIT)
return false unless new_lfs_pointers.present?
diff --git a/lib/gitlab/cleanup/remote_uploads.rb b/lib/gitlab/cleanup/remote_uploads.rb
new file mode 100644
index 00000000000..45a5aea4fcd
--- /dev/null
+++ b/lib/gitlab/cleanup/remote_uploads.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+module Gitlab
+ module Cleanup
+ class RemoteUploads
+ attr_reader :logger
+
+ BATCH_SIZE = 100
+
+ def initialize(logger: nil)
+ @logger = logger || Rails.logger
+ end
+
+ def run!(dry_run: false)
+ unless configuration.enabled
+ logger.warn "Object storage not enabled. Exit".color(:yellow)
+
+ return
+ end
+
+ logger.info "Looking for orphaned remote uploads to remove#{'. Dry run' if dry_run}..."
+
+ each_orphan_file do |file|
+ info = if dry_run
+ "Can be moved to lost and found: #{file.key}"
+ else
+ new_path = move_to_lost_and_found(file)
+ "Moved to lost and found: #{file.key} -> #{new_path}"
+ end
+
+ logger.info(info)
+ end
+ end
+
+ private
+
+ def each_orphan_file
+ # we want to skip files already moved to lost_and_found directory
+ lost_dir_match = "^#{lost_and_found_dir}\/"
+
+ remote_directory.files.each_slice(BATCH_SIZE) do |remote_files|
+ remote_files.reject! { |file| file.key.match(/#{lost_dir_match}/) }
+ file_paths = remote_files.map(&:key)
+ tracked_paths = Upload
+ .where(store: ObjectStorage::Store::REMOTE, path: file_paths)
+ .pluck(:path)
+
+ remote_files.reject! { |file| tracked_paths.include?(file.key) }
+ remote_files.each do |file|
+ yield file
+ end
+ end
+ end
+
+ def move_to_lost_and_found(file)
+ new_path = "#{lost_and_found_dir}/#{file.key}"
+
+ file.copy(configuration['remote_directory'], new_path)
+ file.destroy
+
+ new_path
+ end
+
+ def lost_and_found_dir
+ 'lost_and_found'
+ end
+
+ def remote_directory
+ connection.directories.get(configuration['remote_directory'])
+ end
+
+ def connection
+ ::Fog::Storage.new(configuration['connection'].symbolize_keys)
+ end
+
+ def configuration
+ Gitlab.config.uploads.object_store
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 73151e4a4c5..de189ac6dfc 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -19,6 +19,7 @@ module Gitlab
GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE
].freeze
SEARCH_CONTEXT_LINES = 3
+ REV_LIST_COMMIT_LIMIT = 2_000
# In https://gitlab.com/gitlab-org/gitaly/merge_requests/698
# We copied these two prefixes into gitaly-go, so don't change these
# or things will break! (REBASE_WORKTREE_PREFIX and SQUASH_WORKTREE_PREFIX)
@@ -380,6 +381,16 @@ module Gitlab
end
end
+ def new_blobs(newrev)
+ return [] if newrev == ::Gitlab::Git::BLANK_SHA
+
+ strong_memoize("new_blobs_#{newrev}") do
+ wrapped_gitaly_errors do
+ gitaly_ref_client.list_new_blobs(newrev, REV_LIST_COMMIT_LIMIT)
+ end
+ end
+ end
+
def count_commits(options)
options = process_count_commits_options(options.dup)
diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb
index 41d58192818..8acc22e809e 100644
--- a/lib/gitlab/gitaly_client/ref_service.rb
+++ b/lib/gitlab/gitaly_client/ref_service.rb
@@ -82,6 +82,23 @@ module Gitlab
commits
end
+ def list_new_blobs(newrev, limit = 0)
+ request = Gitaly::ListNewBlobsRequest.new(
+ repository: @gitaly_repo,
+ commit_id: newrev,
+ limit: limit
+ )
+
+ response = GitalyClient
+ .call(@storage, :ref_service, :list_new_blobs, request, timeout: GitalyClient.medium_timeout)
+
+ response.flat_map do |msg|
+ # Returns an Array of Gitaly::NewBlobObject objects
+ # Available methods are: #size, #oid and #path
+ msg.new_blob_objects
+ end
+ end
+
def count_tag_names
tag_names.count
end
diff --git a/lib/gitlab/import_sources.rb b/lib/gitlab/import_sources.rb
index 45816bee176..f7f5c5787f6 100644
--- a/lib/gitlab/import_sources.rb
+++ b/lib/gitlab/import_sources.rb
@@ -9,15 +9,16 @@ module Gitlab
# We exclude `bare_repository` here as it has no import class associated
ImportTable = [
- ImportSource.new('github', 'GitHub', Gitlab::GithubImport::ParallelImporter),
- ImportSource.new('bitbucket', 'Bitbucket', Gitlab::BitbucketImport::Importer),
- ImportSource.new('gitlab', 'GitLab.com', Gitlab::GitlabImport::Importer),
- ImportSource.new('google_code', 'Google Code', Gitlab::GoogleCodeImport::Importer),
- ImportSource.new('fogbugz', 'FogBugz', Gitlab::FogbugzImport::Importer),
- ImportSource.new('git', 'Repo by URL', nil),
- ImportSource.new('gitlab_project', 'GitLab export', Gitlab::ImportExport::Importer),
- ImportSource.new('gitea', 'Gitea', Gitlab::LegacyGithubImport::Importer),
- ImportSource.new('manifest', 'Manifest file', nil)
+ ImportSource.new('github', 'GitHub', Gitlab::GithubImport::ParallelImporter),
+ ImportSource.new('bitbucket', 'Bitbucket Cloud', Gitlab::BitbucketImport::Importer),
+ ImportSource.new('bitbucket_server', 'Bitbucket Server', Gitlab::BitbucketServerImport::Importer),
+ ImportSource.new('gitlab', 'GitLab.com', Gitlab::GitlabImport::Importer),
+ ImportSource.new('google_code', 'Google Code', Gitlab::GoogleCodeImport::Importer),
+ ImportSource.new('fogbugz', 'FogBugz', Gitlab::FogbugzImport::Importer),
+ ImportSource.new('git', 'Repo by URL', nil),
+ ImportSource.new('gitlab_project', 'GitLab export', Gitlab::ImportExport::Importer),
+ ImportSource.new('gitea', 'Gitea', Gitlab::LegacyGithubImport::Importer),
+ ImportSource.new('manifest', 'Manifest file', nil)
].freeze
class << self
diff --git a/lib/gitlab/kubernetes/config_map.rb b/lib/gitlab/kubernetes/config_map.rb
index 8a8a59a9cd4..9e55dae137c 100644
--- a/lib/gitlab/kubernetes/config_map.rb
+++ b/lib/gitlab/kubernetes/config_map.rb
@@ -1,15 +1,15 @@
module Gitlab
module Kubernetes
class ConfigMap
- def initialize(name, values = "")
+ def initialize(name, files)
@name = name
- @values = values
+ @files = files
end
def generate
resource = ::Kubeclient::Resource.new
resource.metadata = metadata
- resource.data = { values: values }
+ resource.data = files
resource
end
@@ -19,7 +19,7 @@ module Gitlab
private
- attr_reader :name, :values
+ attr_reader :name, :files
def metadata
{
diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb
index c4de9a398cc..d65374cc23b 100644
--- a/lib/gitlab/kubernetes/helm/api.rb
+++ b/lib/gitlab/kubernetes/helm/api.rb
@@ -9,7 +9,7 @@ module Gitlab
def install(command)
namespace.ensure_exists!
- create_config_map(command) if command.config_map?
+ create_config_map(command)
kubeclient.create_pod(command.pod_resource)
end
diff --git a/lib/gitlab/kubernetes/helm/base_command.rb b/lib/gitlab/kubernetes/helm/base_command.rb
index f9ebe53d6af..afcfd109de0 100644
--- a/lib/gitlab/kubernetes/helm/base_command.rb
+++ b/lib/gitlab/kubernetes/helm/base_command.rb
@@ -1,13 +1,7 @@
module Gitlab
module Kubernetes
module Helm
- class BaseCommand
- attr_reader :name
-
- def initialize(name)
- @name = name
- end
-
+ module BaseCommand
def pod_resource
Gitlab::Kubernetes::Helm::Pod.new(self, namespace).generate
end
@@ -24,16 +18,32 @@ module Gitlab
HEREDOC
end
- def config_map?
- false
- end
-
def pod_name
"install-#{name}"
end
+ def config_map_resource
+ Gitlab::Kubernetes::ConfigMap.new(name, files).generate
+ end
+
+ def file_names
+ files.keys
+ end
+
+ def name
+ raise "Not implemented"
+ end
+
+ def files
+ raise "Not implemented"
+ end
+
private
+ def files_dir
+ "/data/helm/#{name}/config"
+ end
+
def namespace
Gitlab::Kubernetes::Helm::NAMESPACE
end
diff --git a/lib/gitlab/kubernetes/helm/certificate.rb b/lib/gitlab/kubernetes/helm/certificate.rb
new file mode 100644
index 00000000000..598714e0874
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm/certificate.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+module Gitlab
+ module Kubernetes
+ module Helm
+ class Certificate
+ INFINITE_EXPIRY = 1000.years
+ SHORT_EXPIRY = 30.minutes
+
+ attr_reader :key, :cert
+
+ def key_string
+ @key.to_s
+ end
+
+ def cert_string
+ @cert.to_pem
+ end
+
+ def self.from_strings(key_string, cert_string)
+ key = OpenSSL::PKey::RSA.new(key_string)
+ cert = OpenSSL::X509::Certificate.new(cert_string)
+ new(key, cert)
+ end
+
+ def self.generate_root
+ _issue(signed_by: nil, expires_in: INFINITE_EXPIRY, certificate_authority: true)
+ end
+
+ def issue(expires_in: SHORT_EXPIRY)
+ self.class._issue(signed_by: self, expires_in: expires_in, certificate_authority: false)
+ end
+
+ private
+
+ def self._issue(signed_by:, expires_in:, certificate_authority:)
+ key = OpenSSL::PKey::RSA.new(4096)
+ public_key = key.public_key
+
+ subject = OpenSSL::X509::Name.parse("/C=US")
+
+ cert = OpenSSL::X509::Certificate.new
+ cert.subject = subject
+
+ cert.issuer = signed_by&.cert&.subject || subject
+
+ cert.not_before = Time.now
+ cert.not_after = expires_in.from_now
+ cert.public_key = public_key
+ cert.serial = 0x0
+ cert.version = 2
+
+ if certificate_authority
+ extension_factory = OpenSSL::X509::ExtensionFactory.new
+ extension_factory.subject_certificate = cert
+ extension_factory.issuer_certificate = cert
+ cert.add_extension(extension_factory.create_extension('subjectKeyIdentifier', 'hash'))
+ cert.add_extension(extension_factory.create_extension('basicConstraints', 'CA:TRUE', true))
+ cert.add_extension(extension_factory.create_extension('keyUsage', 'cRLSign,keyCertSign', true))
+ end
+
+ cert.sign(signed_by&.key || key, OpenSSL::Digest::SHA256.new)
+
+ new(key, cert)
+ end
+
+ def initialize(key, cert)
+ @key = key
+ @cert = cert
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/helm/init_command.rb b/lib/gitlab/kubernetes/helm/init_command.rb
index a02e64561f6..a4546509515 100644
--- a/lib/gitlab/kubernetes/helm/init_command.rb
+++ b/lib/gitlab/kubernetes/helm/init_command.rb
@@ -1,7 +1,16 @@
module Gitlab
module Kubernetes
module Helm
- class InitCommand < BaseCommand
+ class InitCommand
+ include BaseCommand
+
+ attr_reader :name, :files
+
+ def initialize(name:, files:)
+ @name = name
+ @files = files
+ end
+
def generate_script
super + [
init_helm_command
@@ -11,7 +20,12 @@ module Gitlab
private
def init_helm_command
- "helm init >/dev/null"
+ tls_flags = "--tiller-tls" \
+ " --tiller-tls-verify --tls-ca-cert #{files_dir}/ca.pem" \
+ " --tiller-tls-cert #{files_dir}/cert.pem" \
+ " --tiller-tls-key #{files_dir}/key.pem"
+
+ "helm init #{tls_flags} >/dev/null"
end
end
end
diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb
index d2133a6d65b..9672f80687e 100644
--- a/lib/gitlab/kubernetes/helm/install_command.rb
+++ b/lib/gitlab/kubernetes/helm/install_command.rb
@@ -1,14 +1,16 @@
module Gitlab
module Kubernetes
module Helm
- class InstallCommand < BaseCommand
- attr_reader :name, :chart, :version, :repository, :values
+ class InstallCommand
+ include BaseCommand
- def initialize(name, chart:, values:, version: nil, repository: nil)
+ attr_reader :name, :files, :chart, :version, :repository
+
+ def initialize(name:, chart:, files:, version: nil, repository: nil)
@name = name
@chart = chart
@version = version
- @values = values
+ @files = files
@repository = repository
end
@@ -20,14 +22,6 @@ module Gitlab
].compact.join("\n")
end
- def config_map?
- true
- end
-
- def config_map_resource
- Gitlab::Kubernetes::ConfigMap.new(name, values).generate
- end
-
private
def init_command
@@ -39,14 +33,25 @@ module Gitlab
end
def script_command
- <<~HEREDOC
- helm install #{chart} --name #{name}#{optional_version_flag} --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE} -f /data/helm/#{name}/config/values.yaml >/dev/null
- HEREDOC
+ init_flags = "--name #{name}#{optional_tls_flags}#{optional_version_flag}" \
+ " --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE}" \
+ " -f /data/helm/#{name}/config/values.yaml"
+
+ "helm install #{chart} #{init_flags} >/dev/null\n"
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
diff --git a/lib/gitlab/kubernetes/helm/pod.rb b/lib/gitlab/kubernetes/helm/pod.rb
index 1e12299eefd..6e5d3388405 100644
--- a/lib/gitlab/kubernetes/helm/pod.rb
+++ b/lib/gitlab/kubernetes/helm/pod.rb
@@ -10,10 +10,8 @@ module Gitlab
def generate
spec = { containers: [container_specification], restartPolicy: 'Never' }
- if command.config_map?
- spec[:volumes] = volumes_specification
- spec[:containers][0][:volumeMounts] = volume_mounts_specification
- end
+ spec[:volumes] = volumes_specification
+ spec[:containers][0][:volumeMounts] = volume_mounts_specification
::Kubeclient::Resource.new(metadata: metadata, spec: spec)
end
@@ -61,7 +59,7 @@ module Gitlab
name: 'configuration-volume',
configMap: {
name: "values-content-configuration-#{command.name}",
- items: [{ key: 'values', path: 'values.yaml' }]
+ items: command.file_names.map { |name| { key: name, path: name } }
}
}
]
diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake
index a2feb074b1d..c8a8863443e 100644
--- a/lib/tasks/gitlab/cleanup.rake
+++ b/lib/tasks/gitlab/cleanup.rake
@@ -116,6 +116,16 @@ namespace :gitlab do
end
end
+ desc 'GitLab | Cleanup | Clean orphan remote upload files that do not exist in the db'
+ task remote_upload_files: :environment do
+ cleaner = Gitlab::Cleanup::RemoteUploads.new(logger: logger)
+ cleaner.run!(dry_run: dry_run?)
+
+ if dry_run?
+ logger.info "To cleanup these files run this command with DRY_RUN=false".color(:yellow)
+ end
+ end
+
def remove?
ENV['REMOVE'] == 'true'
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a414f0a90cc..bec60cf592a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16,6 +16,9 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+msgid " Status"
+msgstr ""
+
msgid "%d changed file"
msgid_plural "%d changed files"
msgstr[0] ""
@@ -808,6 +811,9 @@ msgstr ""
msgid "Below you will find all the groups that are public."
msgstr ""
+msgid "Bitbucket Server Import"
+msgstr ""
+
msgid "Bitbucket import"
msgstr ""
@@ -984,9 +990,6 @@ msgstr ""
msgid "CI/CD settings"
msgstr ""
-msgid "CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery."
-msgstr ""
-
msgid "CICD|Auto DevOps"
msgstr ""
@@ -999,22 +1002,13 @@ msgstr ""
msgid "CICD|Continuous deployment to production"
msgstr ""
-msgid "CICD|Deployment strategy"
-msgstr ""
-
-msgid "CICD|Deployment strategy needs a domain name to work correctly."
-msgstr ""
-
-msgid "CICD|Disable Auto DevOps"
-msgstr ""
-
-msgid "CICD|Enable Auto DevOps"
+msgid "CICD|Default to Auto DevOps pipeline"
msgstr ""
-msgid "CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}."
+msgid "CICD|Deployment strategy"
msgstr ""
-msgid "CICD|Instance default (%{state})"
+msgid "CICD|Deployment strategy needs a domain name to work correctly."
msgstr ""
msgid "CICD|Jobs"
@@ -1023,12 +1017,15 @@ msgstr ""
msgid "CICD|Learn more about Auto DevOps"
msgstr ""
-msgid "CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project."
+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."
msgstr ""
+msgid "CICD|instance enabled"
+msgstr ""
+
msgid "Callback URL"
msgstr ""
@@ -1933,6 +1930,9 @@ msgstr ""
msgid "Cron syntax"
msgstr ""
+msgid "Current Branch"
+msgstr ""
+
msgid "CurrentUser|Profile"
msgstr ""
@@ -2316,6 +2316,9 @@ msgstr ""
msgid "Ends at (UTC)"
msgstr ""
+msgid "Enter in your Bitbucket Server URL and personal access token below"
+msgstr ""
+
msgid "Environments"
msgstr ""
@@ -2409,6 +2412,9 @@ msgstr ""
msgid "Error loading branch data. Please try again."
msgstr ""
+msgid "Error loading branches."
+msgstr ""
+
msgid "Error loading last commit."
msgstr ""
@@ -2618,6 +2624,9 @@ msgstr ""
msgid "From Bitbucket"
msgstr ""
+msgid "From Bitbucket Server"
+msgstr ""
+
msgid "From FogBugz"
msgstr ""
@@ -2902,18 +2911,39 @@ msgstr ""
msgid "ID"
msgstr ""
+msgid "IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation."
+msgstr ""
+
+msgid "IDE|Back"
+msgstr ""
+
+msgid "IDE|Client side evaluation"
+msgstr ""
+
msgid "IDE|Commit"
msgstr ""
msgid "IDE|Edit"
msgstr ""
-msgid "IDE|Go back"
+msgid "IDE|Get started with Live Preview"
+msgstr ""
+
+msgid "IDE|Go to project"
+msgstr ""
+
+msgid "IDE|Live Preview"
msgstr ""
msgid "IDE|Open in file view"
msgstr ""
+msgid "IDE|Preview your web application using Web IDE client-side evaluation."
+msgstr ""
+
+msgid "IDE|Refresh preview"
+msgstr ""
+
msgid "IDE|Review"
msgstr ""
@@ -2974,6 +3004,9 @@ msgstr ""
msgid "Import projects from Bitbucket"
msgstr ""
+msgid "Import projects from Bitbucket Server"
+msgstr ""
+
msgid "Import projects from FogBugz"
msgstr ""
@@ -2983,6 +3016,9 @@ msgstr ""
msgid "Import projects from Google Code"
msgstr ""
+msgid "Import repositories from Bitbucket Server"
+msgstr ""
+
msgid "Import repositories from GitHub"
msgstr ""
@@ -3219,9 +3255,15 @@ msgstr ""
msgid "List available repositories"
msgstr ""
+msgid "List your Bitbucket Server repositories"
+msgstr ""
+
msgid "List your GitHub repositories"
msgstr ""
+msgid "Live preview"
+msgstr ""
+
msgid "Loading the GitLab IDE..."
msgstr ""
@@ -3255,6 +3297,9 @@ msgstr ""
msgid "Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki."
msgstr ""
+msgid "Manage Web IDE features"
+msgstr ""
+
msgid "Manage access"
msgstr ""
@@ -3566,6 +3611,9 @@ msgstr ""
msgid "No assignee"
msgstr ""
+msgid "No branches found"
+msgstr ""
+
msgid "No changes"
msgstr ""
@@ -3644,6 +3692,9 @@ msgstr ""
msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
msgstr ""
+msgid "Notes|Are you sure you want to cancel creating this comment?"
+msgstr ""
+
msgid "Notification events"
msgstr ""
@@ -4067,9 +4118,15 @@ msgstr ""
msgid "Profiles|Add key"
msgstr ""
+msgid "Profiles|Add status emoji"
+msgstr ""
+
msgid "Profiles|Change username"
msgstr ""
+msgid "Profiles|Clear status"
+msgstr ""
+
msgid "Profiles|Current path: %{path}"
msgstr ""
@@ -4097,7 +4154,7 @@ msgstr ""
msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?"
msgstr ""
-msgid "Profiles|This emoji and message will appear on your profile and throughout the interface. The message can contain emoji codes, too."
+msgid "Profiles|This emoji and message will appear on your profile and throughout the interface."
msgstr ""
msgid "Profiles|Type your %{confirmationValue} to confirm:"
@@ -4115,6 +4172,9 @@ msgstr ""
msgid "Profiles|Username successfully changed"
msgstr ""
+msgid "Profiles|What's your status?"
+msgstr ""
+
msgid "Profiles|You don't have access to delete this user."
msgstr ""
@@ -4124,6 +4184,9 @@ msgstr ""
msgid "Profiles|Your account is currently an owner in these groups:"
msgstr ""
+msgid "Profiles|Your status"
+msgstr ""
+
msgid "Profiles|e.g. My MacBook key"
msgstr ""
@@ -4594,12 +4657,39 @@ msgstr ""
msgid "Search milestones"
msgstr ""
+msgid "Search or jump to…"
+msgstr ""
+
msgid "Search project"
msgstr ""
msgid "Search users"
msgstr ""
+msgid "SearchAutocomplete|All GitLab"
+msgstr ""
+
+msgid "SearchAutocomplete|Issues I've created"
+msgstr ""
+
+msgid "SearchAutocomplete|Issues assigned to me"
+msgstr ""
+
+msgid "SearchAutocomplete|Merge requests I've created"
+msgstr ""
+
+msgid "SearchAutocomplete|Merge requests assigned to me"
+msgstr ""
+
+msgid "SearchAutocomplete|in all GitLab"
+msgstr ""
+
+msgid "SearchAutocomplete|in this group"
+msgstr ""
+
+msgid "SearchAutocomplete|in this project"
+msgstr ""
+
msgid "Seconds before reseting failure information"
msgstr ""
@@ -5685,7 +5775,7 @@ msgstr ""
msgid "Users"
msgstr ""
-msgid "User|Current Status"
+msgid "User|Current status"
msgstr ""
msgid "Variables"
@@ -5976,9 +6066,6 @@ msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
-msgid "You do not have any assigned merge requests"
-msgstr ""
-
msgid "You don't have any applications"
msgstr ""
@@ -5988,9 +6075,6 @@ msgstr ""
msgid "You have no permissions"
msgstr ""
-msgid "You have not created any merge requests"
-msgstr ""
-
msgid "You have reached your project limit"
msgstr ""
@@ -6131,6 +6215,9 @@ msgstr ""
msgid "here"
msgstr ""
+msgid "https://your-bitbucket-server"
+msgstr ""
+
msgid "import flow"
msgstr ""
diff --git a/package.json b/package.json
index 4e5cf05f49b..975dd2619d7 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"chart.js": "1.0.2",
"classlist-polyfill": "^1.2.0",
"clipboard": "^1.7.1",
+ "codesandbox-api": "^0.0.18",
"compression-webpack-plugin": "^1.1.11",
"core-js": "^2.4.1",
"cropper": "^2.3.0",
@@ -80,6 +81,7 @@
"sanitize-html": "^1.16.1",
"select2": "3.5.2-browserify",
"sha1": "^1.1.1",
+ "smooshpack": "^0.0.48",
"sortablejs": "^1.7.0",
"sql.js": "^0.4.0",
"stickyfilljs": "^2.0.5",
diff --git a/public/404.html b/public/404.html
index 08f328da542..68b4ab0bb34 100644
--- a/public/404.html
+++ b/public/404.html
@@ -66,8 +66,10 @@
</head>
<body>
- <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
+ <a href="/">
+ <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" />
+ </a>
<h1>
404
</h1>
diff --git a/public/422.html b/public/422.html
index a67dcd02200..a931e923efb 100644
--- a/public/422.html
+++ b/public/422.html
@@ -66,8 +66,10 @@
</head>
<body>
- <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
+ <a href="/">
+ <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" />
+ </a>
<h1>
422
</h1>
diff --git a/public/500.html b/public/500.html
index 7091d14dfc4..df7b22dc9ef 100644
--- a/public/500.html
+++ b/public/500.html
@@ -66,8 +66,10 @@
</head>
<body>
- <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
+ <a href="/">
+ <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" />
+ </a>
<h1>
500
</h1>
diff --git a/public/502.html b/public/502.html
index 82afd273248..77835767fa6 100644
--- a/public/502.html
+++ b/public/502.html
@@ -66,8 +66,10 @@
</head>
<body>
- <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
+ <a href="/">
+ <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" />
+ </a>
<h1>
502
</h1>
diff --git a/public/503.html b/public/503.html
index f1486bc3e84..ee2da9b1313 100644
--- a/public/503.html
+++ b/public/503.html
@@ -66,8 +66,10 @@
</head>
<body>
- <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
+ <a href="/">
+ <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" />
+ </a>
<h1>
503
</h1>
diff --git a/qa/qa/factory/resource/kubernetes_cluster.rb b/qa/qa/factory/resource/kubernetes_cluster.rb
index 1c9e5f94b22..ef2ea72b170 100644
--- a/qa/qa/factory/resource/kubernetes_cluster.rb
+++ b/qa/qa/factory/resource/kubernetes_cluster.rb
@@ -44,10 +44,11 @@ module QA
page.await_installed(:helm)
page.install!(:ingress) if @install_ingress
- page.await_installed(:ingress) if @install_ingress
page.install!(:prometheus) if @install_prometheus
- page.await_installed(:prometheus) if @install_prometheus
page.install!(:runner) if @install_runner
+
+ page.await_installed(:ingress) if @install_ingress
+ page.await_installed(:prometheus) if @install_prometheus
page.await_installed(:runner) if @install_runner
end
end
diff --git a/qa/qa/page/project/new.rb b/qa/qa/page/project/new.rb
index 9e812fa7c74..1fb569b0f29 100644
--- a/qa/qa/page/project/new.rb
+++ b/qa/qa/page/project/new.rb
@@ -10,7 +10,7 @@ module QA
view 'app/views/projects/_new_project_fields.html.haml' do
element :project_namespace_select
- element :project_namespace_field, /select :namespace_id.*class: 'select2/
+ element :project_namespace_field, 'namespaces_options'
element :project_path, 'text_field :path'
element :project_description, 'text_area :description'
element :project_create_button, "submit 'Create project'"
diff --git a/qa/qa/page/project/operations/kubernetes/show.rb b/qa/qa/page/project/operations/kubernetes/show.rb
index 4923304133e..e831edeb89e 100644
--- a/qa/qa/page/project/operations/kubernetes/show.rb
+++ b/qa/qa/page/project/operations/kubernetes/show.rb
@@ -16,6 +16,7 @@ module QA
def install!(application_name)
within(".js-cluster-application-row-#{application_name}") do
+ page.has_button?('Install', wait: 30)
click_on 'Install'
end
end
diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb
index 0f739f61db9..752d3d93407 100644
--- a/qa/qa/page/project/settings/ci_cd.rb
+++ b/qa/qa/page/project/settings/ci_cd.rb
@@ -12,9 +12,9 @@ module QA # rubocop:disable Naming/FileName
end
view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do
- element :enable_auto_devops_field, 'radio_button :enabled'
+ element :enable_auto_devops_field, 'check_box :enabled'
element :domain_field, 'text_field :domain'
- element :enable_auto_devops_button, "%strong= s_('CICD|Enable Auto DevOps')"
+ element :enable_auto_devops_button, "%strong= s_('CICD|Default to Auto DevOps pipeline')"
element :domain_input, "%strong= _('Domain')"
element :save_changes_button, "submit _('Save changes')"
end
@@ -33,7 +33,7 @@ module QA # rubocop:disable Naming/FileName
def enable_auto_devops_with_domain(domain)
expand_section(:autodevops_settings) do
- choose 'Enable Auto DevOps'
+ check 'Default to Auto DevOps pipeline'
fill_in 'Domain', with: domain
click_on 'Save changes'
end
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index bad7a28556c..421ab006792 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -56,6 +56,57 @@ describe ApplicationController do
end
end
+ describe '#add_gon_variables' do
+ before do
+ Gon.clear
+ sign_in user
+ end
+
+ let(:json_response) { JSON.parse(response.body) }
+
+ controller(described_class) do
+ def index
+ render json: Gon.all_variables
+ end
+ end
+
+ shared_examples 'setting gon variables' do
+ it 'sets gon variables' do
+ get :index, format: format
+
+ expect(json_response.size).not_to be_zero
+ end
+ end
+
+ shared_examples 'not setting gon variables' do
+ it 'does not set gon variables' do
+ get :index, format: format
+
+ expect(json_response.size).to be_zero
+ end
+ end
+
+ context 'with html format' do
+ let(:format) { :html }
+
+ it_behaves_like 'setting gon variables'
+
+ context 'for peek requests' do
+ before do
+ request.path = '/-/peek'
+ end
+
+ it_behaves_like 'not setting gon variables'
+ end
+ end
+
+ context 'with json format' do
+ let(:format) { :json }
+
+ it_behaves_like 'not setting gon variables'
+ end
+ end
+
describe "#authenticate_user_from_personal_access_token!" do
before do
stub_authentication_activity_metrics(debug: false)
diff --git a/spec/controllers/import/bitbucket_server_controller_spec.rb b/spec/controllers/import/bitbucket_server_controller_spec.rb
new file mode 100644
index 00000000000..5024ef71771
--- /dev/null
+++ b/spec/controllers/import/bitbucket_server_controller_spec.rb
@@ -0,0 +1,154 @@
+require 'spec_helper'
+
+describe Import::BitbucketServerController do
+ let(:user) { create(:user) }
+ let(:project_key) { 'test-project' }
+ let(:repo_slug) { 'some-repo' }
+ let(:client) { instance_double(BitbucketServer::Client) }
+
+ def assign_session_tokens
+ session[:bitbucket_server_url] = 'http://localhost:7990'
+ session[:bitbucket_server_username] = 'bitbucket'
+ session[:bitbucket_server_personal_access_token] = 'some-token'
+ end
+
+ before do
+ sign_in(user)
+ allow(controller).to receive(:bitbucket_server_import_enabled?).and_return(true)
+ end
+
+ describe 'GET new' do
+ render_views
+
+ it 'shows the input form' do
+ get :new
+
+ expect(response.body).to have_text('Bitbucket Server URL')
+ end
+ end
+
+ describe 'POST create' do
+ before do
+ allow(controller).to receive(:bitbucket_client).and_return(client)
+ repo = double(name: 'my-project')
+ allow(client).to receive(:repo).with(project_key, repo_slug).and_return(repo)
+ assign_session_tokens
+ end
+
+ set(:project) { create(:project) }
+
+ it 'returns the new project' do
+ allow(Gitlab::BitbucketServerImport::ProjectCreator)
+ .to receive(:new).with(project_key, repo_slug, anything, 'my-project', user.namespace, user, anything)
+ .and_return(double(execute: project))
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns an error when an invalid project key is used' do
+ post :create, project: 'some&project'
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it 'returns an error when an invalid repository slug is used' do
+ post :create, project: 'some-project', repository: 'try*this'
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it 'returns an error when the project cannot be found' do
+ allow(client).to receive(:repo).with(project_key, repo_slug).and_return(nil)
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it 'returns an error when the project cannot be saved' do
+ allow(Gitlab::BitbucketServerImport::ProjectCreator)
+ .to receive(:new).with(project_key, repo_slug, anything, 'my-project', user.namespace, user, anything)
+ .and_return(double(execute: build(:project)))
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it "returns an error when the server can't be contacted" do
+ expect(client).to receive(:repo).with(project_key, repo_slug).and_raise(BitbucketServer::Client::ServerError)
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+ end
+
+ describe 'POST configure' do
+ let(:token) { 'token' }
+ let(:username) { 'bitbucket-user' }
+ let(:url) { 'http://localhost:7990/bitbucket' }
+
+ it 'clears out existing session' do
+ post :configure
+
+ expect(session[:bitbucket_server_url]).to be_nil
+ expect(session[:bitbucket_server_username]).to be_nil
+ expect(session[:bitbucket_server_personal_access_token]).to be_nil
+
+ expect(response).to have_gitlab_http_status(302)
+ expect(response).to redirect_to(status_import_bitbucket_server_path)
+ end
+
+ it 'sets the session variables' do
+ post :configure, personal_access_token: token, bitbucket_username: username, bitbucket_server_url: url
+
+ expect(session[:bitbucket_server_url]).to eq(url)
+ expect(session[:bitbucket_server_username]).to eq(username)
+ expect(session[:bitbucket_server_personal_access_token]).to eq(token)
+ expect(response).to have_gitlab_http_status(302)
+ expect(response).to redirect_to(status_import_bitbucket_server_path)
+ end
+ end
+
+ describe 'GET status' do
+ render_views
+
+ before do
+ allow(controller).to receive(:bitbucket_client).and_return(client)
+
+ @repo = double(slug: 'vim', project_key: 'asd', full_name: 'asd/vim', "valid?" => true, project_name: 'asd', browse_url: 'http://test', name: 'vim')
+ @invalid_repo = double(slug: 'invalid', project_key: 'foobar', full_name: 'asd/foobar', "valid?" => false, browse_url: 'http://bad-repo')
+ assign_session_tokens
+ end
+
+ it 'assigns repository categories' do
+ created_project = create(:project, import_type: 'bitbucket_server', creator_id: user.id, import_source: 'foo/bar', import_status: 'finished')
+ expect(client).to receive(:repos).and_return([@repo, @invalid_repo])
+
+ get :status
+
+ expect(assigns(:already_added_projects)).to eq([created_project])
+ expect(assigns(:repos)).to eq([@repo])
+ expect(assigns(:incompatible_repos)).to eq([@invalid_repo])
+ end
+ end
+
+ describe 'GET jobs' do
+ before do
+ assign_session_tokens
+ end
+
+ it 'returns a list of imported projects' do
+ created_project = create(:project, import_type: 'bitbucket_server', creator_id: user.id)
+
+ get :jobs
+
+ expect(json_response.count).to eq(1)
+ expect(json_response.first['id']).to eq(created_project.id)
+ expect(json_response.first['import_status']).to eq('none')
+ end
+ end
+end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 375018e2229..d9bb3981539 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -597,6 +597,12 @@ describe Projects::MergeRequestsController do
context 'when comparison is being processed' do
let(:comparison_status) { { status: :parsing } }
+ it 'sends polling interval' do
+ expect(Gitlab::PollingInterval).to receive(:set_header)
+
+ subject
+ end
+
it 'returns 204 HTTP status' do
subject
@@ -607,6 +613,12 @@ describe Projects::MergeRequestsController do
context 'when comparison is done' do
let(:comparison_status) { { status: :parsed, data: { summary: 1 } } }
+ it 'does not send polling interval' do
+ expect(Gitlab::PollingInterval).not_to receive(:set_header)
+
+ subject
+ end
+
it 'returns 200 HTTP status' do
subject
@@ -618,6 +630,12 @@ describe Projects::MergeRequestsController do
context 'when user created corrupted test reports' do
let(:comparison_status) { { status: :error, status_reason: 'Failed to parse test reports' } }
+ it 'does not send polling interval' do
+ expect(Gitlab::PollingInterval).not_to receive(:set_header)
+
+ subject
+ end
+
it 'returns 400 HTTP status' do
subject
@@ -629,6 +647,12 @@ describe Projects::MergeRequestsController do
context 'when something went wrong on our system' do
let(:comparison_status) { {} }
+ it 'does not send polling interval' do
+ expect(Gitlab::PollingInterval).not_to receive(:set_header)
+
+ subject
+ end
+
it 'returns 500 HTTP status' do
subject
diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb
index 6c2d1c7e92b..3190f1ce9d4 100644
--- a/spec/controllers/projects/milestones_controller_spec.rb
+++ b/spec/controllers/projects/milestones_controller_spec.rb
@@ -42,16 +42,45 @@ describe Projects::MilestonesController do
describe "#index" do
context "as html" do
- before do
- get :index, namespace_id: project.namespace.id, project_id: project.id
+ def render_index(project:, page:)
+ get :index, namespace_id: project.namespace.id,
+ project_id: project.id,
+ page: page
end
it "queries only projects milestones" do
+ render_index project: project, page: 1
+
milestones = assigns(:milestones)
expect(milestones.count).to eq(1)
expect(milestones.where(project_id: nil)).to be_empty
end
+
+ it 'renders paginated milestones without missing or duplicates' do
+ allow(Milestone).to receive(:default_per_page).and_return(2)
+ create_list(:milestone, 5, project: project)
+
+ render_index project: project, page: 1
+ page_1_milestones = assigns(:milestones)
+ expect(page_1_milestones.size).to eq(2)
+
+ render_index project: project, page: 2
+ page_2_milestones = assigns(:milestones)
+ expect(page_2_milestones.size).to eq(2)
+
+ render_index project: project, page: 3
+ page_3_milestones = assigns(:milestones)
+ expect(page_3_milestones.size).to eq(2)
+
+ rendered_milestone_ids =
+ page_1_milestones.pluck(:id) +
+ page_2_milestones.pluck(:id) +
+ page_3_milestones.pluck(:id)
+
+ expect(rendered_milestone_ids)
+ .to match_array(project.milestones.pluck(:id))
+ end
end
context "as json" do
diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb
index 1ce7e84bef9..58f2817c7cc 100644
--- a/spec/controllers/projects/todos_controller_spec.rb
+++ b/spec/controllers/projects/todos_controller_spec.rb
@@ -5,10 +5,29 @@ describe Projects::TodosController do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:parent) { project }
+
+ shared_examples 'project todos actions' do
+ it_behaves_like 'todos actions'
+
+ context 'when not authorized for resource' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
+ project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
+ sign_in(user)
+ end
+
+ it "doesn't create todo" do
+ expect { post_create }.not_to change { user.todos.count }
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
context 'Issues' do
describe 'POST create' do
- def go
+ def post_create
post :create,
namespace_id: project.namespace,
project_id: project,
@@ -17,66 +36,13 @@ describe Projects::TodosController do
format: 'html'
end
- context 'when authorized' do
- before do
- sign_in(user)
- project.add_developer(user)
- end
-
- it 'creates todo for issue' do
- expect do
- go
- end.to change { user.todos.count }.by(1)
-
- expect(response).to have_gitlab_http_status(200)
- end
-
- it 'returns todo path and pending count' do
- go
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
- end
- end
-
- context 'when not authorized for project' do
- it 'does not create todo for issue that user has no access to' do
- sign_in(user)
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'does not create todo for issue when user not logged in' do
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(302)
- end
- end
-
- context 'when not authorized for issue' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
- sign_in(user)
- end
-
- it "doesn't create todo" do
- expect { go }.not_to change { user.todos.count }
- expect(response).to have_gitlab_http_status(404)
- end
- end
+ it_behaves_like 'project todos actions'
end
end
context 'Merge Requests' do
describe 'POST create' do
- def go
+ def post_create
post :create,
namespace_id: project.namespace,
project_id: project,
@@ -85,60 +51,7 @@ describe Projects::TodosController do
format: 'html'
end
- context 'when authorized' do
- before do
- sign_in(user)
- project.add_developer(user)
- end
-
- it 'creates todo for merge request' do
- expect do
- go
- end.to change { user.todos.count }.by(1)
-
- expect(response).to have_gitlab_http_status(200)
- end
-
- it 'returns todo path and pending count' do
- go
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
- end
- end
-
- context 'when not authorized for project' do
- it 'does not create todo for merge request user has no access to' do
- sign_in(user)
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'does not create todo for merge request user has no access to' do
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(302)
- end
- end
-
- context 'when not authorized for merge_request' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
- sign_in(user)
- end
-
- it "doesn't create todo" do
- expect { go }.not_to change { user.todos.count }
- expect(response).to have_gitlab_http_status(404)
- end
- end
+ it_behaves_like 'project todos actions'
end
end
end
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
index 3e4277e4ba6..7c4a440b9a9 100644
--- a/spec/factories/clusters/applications/helm.rb
+++ b/spec/factories/clusters/applications/helm.rb
@@ -32,11 +32,21 @@ FactoryBot.define do
updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago
end
- factory :clusters_applications_ingress, class: Clusters::Applications::Ingress
- factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus
- factory :clusters_applications_runner, class: Clusters::Applications::Runner
+ factory :clusters_applications_ingress, class: Clusters::Applications::Ingress do
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
+ end
+
+ factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus do
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
+ end
+
+ factory :clusters_applications_runner, class: Clusters::Applications::Runner do
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
+ end
+
factory :clusters_applications_jupyter, class: Clusters::Applications::Jupyter do
oauth_application factory: :oauth_application
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
end
end
end
diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb
index 0430762c1ff..bbeba8ce8b9 100644
--- a/spec/factories/clusters/clusters.rb
+++ b/spec/factories/clusters/clusters.rb
@@ -36,5 +36,9 @@ FactoryBot.define do
trait :production_environment do
sequence(:environment_scope) { |n| "production#{n}/*" }
end
+
+ trait :with_installed_helm do
+ application_helm factory: %i(clusters_applications_helm installed)
+ end
end
end
diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb
index 94f8caedfa6..14486c80341 100644
--- a/spec/factories/todos.rb
+++ b/spec/factories/todos.rb
@@ -1,8 +1,8 @@
FactoryBot.define do
factory :todo do
project
- author { project.creator }
- user { project.creator }
+ author { project&.creator || user }
+ user { project&.creator || user }
target factory: :issue
action { Todo::ASSIGNED }
diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb
index de406d7d966..238ea2a25bd 100644
--- a/spec/features/admin/admin_labels_spec.rb
+++ b/spec/features/admin/admin_labels_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe 'admin issues labels' do
it 'deletes all labels', :js do
page.within '.labels' do
- page.all('.btn-remove').each do |remove|
+ page.all('.remove-row').each do |remove|
accept_confirm { remove.click }
wait_for_requests
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index a852ca689e7..af1c153dec8 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -175,7 +175,7 @@ describe 'Admin updates settings' do
it 'Change CI/CD settings' do
page.within('.as-ci-cd') do
- check 'Enabled Auto DevOps for projects by default'
+ check 'Default to Auto DevOps pipeline for all projects'
fill_in 'Auto devops domain', with: 'domain.com'
click_button 'Save changes'
end
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index bf4d5396df9..2d268ecab58 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -342,8 +342,9 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
end
end
- it 'shows jump to next discussion button' do
- expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn'))
+ it 'shows jump to next discussion button, apart from the last one' do
+ expect(page).to have_selector('.discussion-reply-holder', count: 2)
+ expect(page).to have_selector('.discussion-reply-holder .discussion-next-btn', count: 1)
end
it 'displays next discussion even if hidden' do
diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb
index b6b3844f2ae..b285cd7a7ac 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -2,6 +2,7 @@ require 'rails_helper'
describe 'Merge request > User sees merge widget', :js do
include ProjectForksHelper
+ include TestReportsHelper
let(:project) { create(:project, :repository) }
let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) }
@@ -325,4 +326,229 @@ describe 'Merge request > User sees merge widget', :js do
expect(page).to have_content('This merge request is in the process of being merged')
end
end
+
+ context 'when merge request has test reports' do
+ let!(:head_pipeline) do
+ create(:ci_pipeline,
+ :success,
+ project: project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
+
+ let!(:build) { create(:ci_build, :success, pipeline: head_pipeline, project: project) }
+
+ before do
+ merge_request.update!(head_pipeline_id: head_pipeline.id)
+ end
+
+ context 'when result has not been parsed yet' do
+ let!(:job_artifact) { create(:ci_job_artifact, :junit, job: build, project: project) }
+
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows parsing status' do
+ expect(page).to have_content('Test summary results are being parsed')
+ end
+ end
+
+ context 'when result has already been parsed' do
+ context 'when JUnit xml is correctly formatted' do
+ let!(:job_artifact) { create(:ci_job_artifact, :junit, job: build, project: project) }
+
+ before do
+ allow_any_instance_of(MergeRequest).to receive(:compare_test_reports).and_return(compared_data)
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows parsed results' do
+ expect(page).to have_content('Test summary contained')
+ end
+ end
+
+ context 'when JUnit xml is corrupted' do
+ let!(:job_artifact) { create(:ci_job_artifact, :junit_with_corrupted_data, job: build, project: project) }
+
+ before do
+ allow_any_instance_of(MergeRequest).to receive(:compare_test_reports).and_return(compared_data)
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows the error state' do
+ expect(page).to have_content('Test summary failed loading results')
+ end
+ end
+
+ def compared_data
+ Ci::CompareTestReportsService.new(project).execute(nil, head_pipeline)
+ end
+ end
+
+ context 'when test reports have been parsed correctly' do
+ let(:serialized_data) do
+ {
+ status: :parsed,
+ data: TestReportsComparerSerializer
+ .new(project: project)
+ .represent(comparer)
+ }
+ end
+
+ before do
+ allow_any_instance_of(MergeRequest)
+ .to receive(:has_test_reports?).and_return(true)
+ allow_any_instance_of(MergeRequest)
+ .to receive(:compare_test_reports).and_return(serialized_data)
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ context 'when a new failures exists' do
+ let(:base_reports) do
+ Gitlab::Ci::Reports::TestReports.new.tap do |reports|
+ reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
+ reports.get_suite('junit').add_test_case(create_test_case_java_success)
+ end
+ end
+
+ let(:head_reports) do
+ Gitlab::Ci::Reports::TestReports.new.tap do |reports|
+ reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
+ reports.get_suite('junit').add_test_case(create_test_case_java_failed)
+ end
+ end
+
+ it 'shows test reports summary which includes the new failure' do
+ within(".mr-section-container") do
+ click_button 'Expand'
+
+ expect(page).to have_content('Test summary contained 1 failed test result out of 2 total tests')
+ within(".js-report-section-container") do
+ expect(page).to have_content('rspec found no changed test results out of 1 total test')
+ expect(page).to have_content('junit found 1 failed test result out of 1 total test')
+ expect(page).to have_content('New')
+ expect(page).to have_content('subtractTest')
+ end
+ end
+ end
+
+ context 'when user clicks the new failure' do
+ it 'shows the test report detail' do
+ within(".mr-section-container") do
+ click_button 'Expand'
+
+ within(".js-report-section-container") do
+ click_button 'subtractTest'
+
+ expect(page).to have_content('6.66')
+ expect(page).to have_content(sample_java_failed_message)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when an existing failure exists' do
+ let(:base_reports) do
+ Gitlab::Ci::Reports::TestReports.new.tap do |reports|
+ reports.get_suite('rspec').add_test_case(create_test_case_rspec_failed)
+ reports.get_suite('junit').add_test_case(create_test_case_java_success)
+ end
+ end
+
+ let(:head_reports) do
+ Gitlab::Ci::Reports::TestReports.new.tap do |reports|
+ reports.get_suite('rspec').add_test_case(create_test_case_rspec_failed)
+ reports.get_suite('junit').add_test_case(create_test_case_java_success)
+ end
+ end
+
+ it 'shows test reports summary which includes the existing failure' do
+ within(".mr-section-container") do
+ click_button 'Expand'
+
+ expect(page).to have_content('Test summary contained 1 failed test result out of 2 total tests')
+ within(".js-report-section-container") do
+ expect(page).to have_content('rspec found 1 failed test result out of 1 total test')
+ expect(page).to have_content('junit found no changed test results out of 1 total test')
+ expect(page).not_to have_content('New')
+ expect(page).to have_content('Test#sum when a is 2 and b is 2 returns summary')
+ end
+ end
+ end
+
+ context 'when user clicks the existing failure' do
+ it 'shows test report detail of it' do
+ within(".mr-section-container") do
+ click_button 'Expand'
+
+ within(".js-report-section-container") do
+ click_button 'Test#sum when a is 2 and b is 2 returns summary'
+
+ expect(page).to have_content('2.22')
+ expect(page).to have_content(sample_rspec_failed_message)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when a resolved failure exists' do
+ let(:base_reports) do
+ Gitlab::Ci::Reports::TestReports.new.tap do |reports|
+ reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
+ reports.get_suite('junit').add_test_case(create_test_case_java_failed)
+ end
+ end
+
+ let(:head_reports) do
+ Gitlab::Ci::Reports::TestReports.new.tap do |reports|
+ reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
+ reports.get_suite('junit').add_test_case(create_test_case_java_resolved)
+ end
+ end
+
+ let(:create_test_case_java_resolved) do
+ create_test_case_java_failed.tap do |test_case|
+ test_case.instance_variable_set("@status", Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS)
+ end
+ end
+
+ it 'shows test reports summary which includes the resolved failure' do
+ within(".mr-section-container") do
+ click_button 'Expand'
+
+ expect(page).to have_content('Test summary contained 1 fixed test result out of 2 total tests')
+ within(".js-report-section-container") do
+ expect(page).to have_content('rspec found no changed test results out of 1 total test')
+ expect(page).to have_content('junit found 1 fixed test result out of 1 total test')
+ expect(page).to have_content('subtractTest')
+ end
+ end
+ end
+
+ context 'when user clicks the resolved failure' do
+ it 'shows test report detail of it' do
+ within(".mr-section-container") do
+ click_button 'Expand'
+
+ within(".js-report-section-container") do
+ click_button 'subtractTest'
+
+ expect(page).to have_content('6.66')
+ end
+ end
+ end
+ end
+ end
+
+ def comparer
+ Gitlab::Ci::Reports::TestReportsComparer.new(base_reports, head_reports)
+ end
+ end
+ end
end
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
index 96bbe6f93f1..9e60b4995bd 100644
--- a/spec/features/profiles/user_edit_profile_spec.rb
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -8,6 +8,10 @@ describe 'User edit profile' do
visit(profile_path)
end
+ def submit_settings
+ click_button 'Update profile settings'
+ end
+
it 'changes user profile' do
fill_in 'user_skype', with: 'testskype'
fill_in 'user_linkedin', with: 'testlinkedin'
@@ -16,7 +20,7 @@ describe 'User edit profile' do
fill_in 'user_location', with: 'Ukraine'
fill_in 'user_bio', with: 'I <3 GitLab'
fill_in 'user_organization', with: 'GitLab'
- click_button 'Update profile settings'
+ submit_settings
expect(user.reload).to have_attributes(
skype: 'testskype',
@@ -34,7 +38,7 @@ describe 'User edit profile' do
context 'user avatar' do
before do
attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif'))
- click_button 'Update profile settings'
+ submit_settings
end
it 'changes user avatar' do
@@ -56,30 +60,75 @@ describe 'User edit profile' do
end
end
- context 'user status' do
- it 'hides user status when the feature is disabled' do
- stub_feature_flags(user_status_form: false)
+ context 'user status', :js do
+ def select_emoji(emoji_name)
+ toggle_button = find('.js-toggle-emoji-menu')
+ toggle_button.click
+ emoji_button = find(%Q{.js-status-emoji-menu .js-emoji-btn gl-emoji[data-name="#{emoji_name}"]})
+ emoji_button.click
+ end
+ it 'shows the user status form' do
visit(profile_path)
- expect(page).not_to have_content('Current Status')
+ expect(page).to have_content('Current status')
end
- it 'shows the status form when the feature is enabled' do
- stub_feature_flags(user_status_form: true)
+ it 'adds emoji to user status' do
+ emoji = 'biohazard'
+ visit(profile_path)
+ select_emoji(emoji)
+ submit_settings
+ visit user_path(user)
+ within('.cover-status') do
+ expect(page).to have_emoji(emoji)
+ end
+ end
+
+ 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)
+ within('.cover-status') do
+ expect(page).to have_emoji('speech_balloon')
+ expect(page).to have_content message
+ end
+ end
- expect(page).to have_content('Current Status')
+ 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)
+ within('.cover-status') do
+ expect(page).to have_emoji(emoji)
+ expect(page).to have_content message
+ end
end
- it 'shows the status form when the feature is enabled by setting a cookie', :js do
- stub_feature_flags(user_status_form: false)
- set_cookie('feature_user_status_form', 'true')
+ it 'clears the user status' do
+ user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
+
+ visit user_path(user)
+ within('.cover-status') do
+ expect(page).to have_emoji(user_status.emoji)
+ expect(page).to have_content user_status.message
+ end
visit(profile_path)
+ click_button 'js-clear-user-status-button'
+ submit_settings
- expect(page).to have_content('Current Status')
+ visit user_path(user)
+ expect(page).not_to have_selector '.cover-status'
end
end
end
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index 27589428896..1064f72c271 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -552,4 +552,33 @@ describe 'File blob', :js do
end
end
end
+
+ context 'for subgroups' do
+ let(:group) { create(:group) }
+ let(:subgroup) { create(:group, parent: group) }
+ let(:project) { create(:project, :public, :repository, group: subgroup) }
+
+ it 'renders tree table without errors' do
+ visit_blob('README.md')
+
+ expect(page).to have_selector('.file-content')
+ expect(page).not_to have_selector('.flash-alert')
+ end
+
+ it 'displays a GPG badge' do
+ visit_blob('CONTRIBUTING.md', ref: '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
+
+ expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
+ expect(page).to have_selector '.gpg-status-box.invalid'
+ end
+ end
+
+ context 'on signed merge commit' do
+ it 'displays a GPG badge' do
+ visit_blob('conflicting-file.md', ref: '6101e87e575de14b38b4e1ce180519a813671e10')
+
+ expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
+ expect(page).to have_selector '.gpg-status-box.invalid'
+ end
+ end
end
diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb
index a65ca662350..71d715237f5 100644
--- a/spec/features/projects/clusters/applications_spec.rb
+++ b/spec/features/projects/clusters/applications_spec.rb
@@ -46,12 +46,14 @@ describe 'Clusters Applications', :js do
end
end
- it 'he sees status transition' 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"
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')
+ wait_until_helm_created!
+
Clusters::Cluster.last.application_helm.make_installing!
# FE starts polling and update the buttons to "Installing"
@@ -83,7 +85,7 @@ describe 'Clusters Applications', :js do
end
end
- it 'he sees status transition' 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"
expect(page).to have_css('.js-cluster-application-install-button[disabled]')
@@ -116,4 +118,14 @@ describe 'Clusters Applications', :js do
end
end
end
+
+ def wait_until_helm_created!
+ retries = 0
+
+ while Clusters::Cluster.last.application_helm.nil?
+ raise "Timed out waiting for helm application to be created in DB" if (retries += 1) > 3
+
+ sleep(1)
+ end
+ end
end
diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb
index 742ecf82c38..30b0a5578ea 100644
--- a/spec/features/projects/settings/pipelines_settings_spec.rb
+++ b/spec/features/projects/settings/pipelines_settings_spec.rb
@@ -8,7 +8,6 @@ describe "Projects > Settings > Pipelines settings" do
before do
sign_in(user)
project.add_role(user, role)
- create(:project_auto_devops, project: project)
end
context 'for developer' do
@@ -61,19 +60,58 @@ describe "Projects > Settings > Pipelines settings" do
end
describe 'Auto DevOps' do
- it 'update auto devops settings' do
- visit project_settings_ci_cd_path(project)
+ context 'when auto devops is turned on instance-wide' do
+ before do
+ stub_application_setting(auto_devops_enabled: true)
+ end
+
+ it 'auto devops is on by default and can be manually turned off' do
+ visit project_settings_ci_cd_path(project)
- page.within '#autodevops-settings' do
- fill_in('project_auto_devops_attributes_domain', with: 'test.com')
- page.choose('project_auto_devops_attributes_enabled_false')
- click_on 'Save changes'
+ page.within '#autodevops-settings' do
+ expect(find_field('project_auto_devops_attributes_enabled')).to be_checked
+ expect(page).to have_content('instance enabled')
+ uncheck 'Default to Auto DevOps pipeline'
+ click_on 'Save changes'
+ end
+
+ expect(page.status_code).to eq(200)
+ expect(project.auto_devops).to be_present
+ expect(project.auto_devops).not_to be_enabled
+
+ page.within '#autodevops-settings' do
+ expect(find_field('project_auto_devops_attributes_enabled')).not_to be_checked
+ expect(page).not_to have_content('instance enabled')
+ end
end
+ end
- expect(page.status_code).to eq(200)
- expect(project.auto_devops).to be_present
- expect(project.auto_devops).not_to be_enabled
- expect(project.auto_devops.domain).to eq('test.com')
+ context 'when auto devops is not turned on instance-wide' do
+ before do
+ stub_application_setting(auto_devops_enabled: false)
+ end
+
+ it 'auto devops is off by default and can be manually turned on' do
+ visit project_settings_ci_cd_path(project)
+
+ page.within '#autodevops-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
+ expect(page).not_to have_content('instance enabled')
+ end
+ end
end
context 'when there is a cluster with ingress and external_ip' do
diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb
index d3aa4912099..9e58280b868 100644
--- a/spec/features/projects/tree/create_directory_spec.rb
+++ b/spec/features/projects/tree/create_directory_spec.rb
@@ -22,7 +22,7 @@ describe 'Multi-file editor new directory', :js do
end
it 'creates directory in current directory' do
- all('.ide-tree-header button').last.click
+ all('.ide-tree-actions button').last.click
page.within('.modal') do
find('.form-control').set('folder name')
@@ -30,7 +30,7 @@ describe 'Multi-file editor new directory', :js do
click_button('Create directory')
end
- first('.ide-tree-header button').click
+ first('.ide-tree-actions button').click
page.within('.modal-dialog') do
find('.form-control').set('file name')
diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb
index f836783cbff..a04d3566a7e 100644
--- a/spec/features/projects/tree/create_file_spec.rb
+++ b/spec/features/projects/tree/create_file_spec.rb
@@ -22,7 +22,7 @@ describe 'Multi-file editor new file', :js do
end
it 'creates file in current directory' do
- first('.ide-tree-header button').click
+ first('.ide-tree-actions button').click
page.within('.modal') do
find('.form-control').set('file name')
diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb
index 9e15163fd72..8ae036cd29f 100644
--- a/spec/features/projects/tree/tree_show_spec.rb
+++ b/spec/features/projects/tree/tree_show_spec.rb
@@ -1,42 +1,86 @@
require 'spec_helper'
-describe 'Projects tree' do
+describe 'Projects tree', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
before do
project.add_maintainer(user)
sign_in(user)
+ end
+ it 'renders tree table without errors' do
visit project_tree_path(project, 'master')
- end
+ wait_for_requests
- it 'renders tree table' do
expect(page).to have_selector('.tree-item')
expect(page).not_to have_selector('.label-lfs', text: 'LFS')
+ expect(page).not_to have_selector('.flash-alert')
end
- context 'LFS' do
- before do
- visit project_tree_path(project, File.join('master', 'files/lfs'))
+ context 'for signed commit' do
+ it 'displays a GPG badge' do
+ visit project_tree_path(project, '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
+ wait_for_requests
+
+ expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
+ expect(page).to have_selector '.gpg-status-box.invalid'
end
+ context 'on a directory that has not changed recently' do
+ it 'displays a GPG badge' do
+ tree_path = File.join('eee736adc74341c5d3e26cd0438bc697f26a7575', 'subdir')
+ visit project_tree_path(project, tree_path)
+ wait_for_requests
+
+ expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
+ expect(page).to have_selector '.gpg-status-box.invalid'
+ end
+ end
+ end
+
+ context 'LFS' do
it 'renders LFS badge on blob item' do
+ visit project_tree_path(project, File.join('master', 'files/lfs'))
+
expect(page).to have_selector('.label-lfs', text: 'LFS')
end
end
- context 'web IDE', :js do
- before do
+ context 'web IDE' do
+ it 'opens folder in IDE' do
visit project_tree_path(project, File.join('master', 'bar'))
click_link 'Web IDE'
+ wait_for_requests
find('.ide-file-list')
+ wait_for_requests
+ expect(page).to have_selector('.is-open', text: 'bar')
end
+ end
- it 'opens folder in IDE' do
- expect(page).to have_selector('.is-open', text: 'bar')
+ context 'for subgroups' do
+ let(:group) { create(:group) }
+ let(:subgroup) { create(:group, parent: group) }
+ let(:project) { create(:project, :repository, group: subgroup) }
+
+ it 'renders tree table without errors' do
+ visit project_tree_path(project, 'master')
+ wait_for_requests
+
+ expect(page).to have_selector('.tree-item')
+ expect(page).not_to have_selector('.flash-alert')
+ end
+
+ context 'for signed commit' do
+ it 'displays a GPG badge' do
+ visit project_tree_path(project, '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
+ wait_for_requests
+
+ expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
+ expect(page).to have_selector '.gpg-status-box.invalid'
+ end
end
end
end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 39b47d99040..56ed0c936a6 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -197,6 +197,49 @@ describe 'Project' do
expect(page.status_code).to eq(200)
end
+
+ context 'for signed commit on default branch', :js do
+ before do
+ project.change_head('33f3729a45c02fc67d00adb1b8bca394b0e761d9')
+ end
+
+ it 'displays a GPG badge' do
+ visit project_path(project)
+ wait_for_requests
+
+ expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
+ expect(page).to have_selector '.gpg-status-box.invalid'
+ end
+ end
+
+ context 'for subgroups', :js do
+ let(:group) { create(:group) }
+ let(:subgroup) { create(:group, parent: group) }
+ let(:project) { create(:project, :repository, group: subgroup) }
+
+ it 'renders tree table without errors' do
+ wait_for_requests
+
+ expect(page).to have_selector('.tree-item')
+ expect(page).not_to have_selector('.flash-alert')
+ end
+
+ context 'for signed commit' do
+ before do
+ repository = project.repository
+ repository.write_ref("refs/heads/#{project.default_branch}", '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
+ repository.expire_branches_cache
+ end
+
+ it 'displays a GPG badge' do
+ visit project_path(project)
+ wait_for_requests
+
+ expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
+ expect(page).to have_selector '.gpg-status-box.invalid'
+ end
+ end
+ end
end
describe 'activity view' do
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index a9128104b87..af38f77c0c6 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -62,10 +62,6 @@ describe 'User uses header search field' do
end
end
- it 'contains location badge' do
- expect(page).to have_selector('.has-location-badge')
- end
-
context 'when clicking the search field', :js do
before do
page.find('#search').click
diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb
index 5003eb508c2..ef0e55a1468 100644
--- a/spec/features/signed_commits_spec.rb
+++ b/spec/features/signed_commits_spec.rb
@@ -1,6 +1,7 @@
require 'spec_helper'
describe 'GPG signed commits', :js do
+ set(:ref) { :'2d1096e3a0ecf1d2baf6dee036cc80775d4940ba' }
let(:project) { create(:project, :repository) }
it 'changes from unverified to verified when the user changes his email to match the gpg key' do
@@ -13,7 +14,7 @@ describe 'GPG signed commits', :js do
sign_in(user)
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within '#commits-list' do
expect(page).to have_content 'Unverified'
@@ -26,7 +27,7 @@ describe 'GPG signed commits', :js do
user.update!(email: GpgHelpers::User1.emails.first)
end
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within '#commits-list' do
expect(page).to have_content 'Unverified'
@@ -40,7 +41,7 @@ describe 'GPG signed commits', :js do
sign_in(user)
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within '#commits-list' do
expect(page).to have_content 'Unverified'
@@ -52,7 +53,7 @@ describe 'GPG signed commits', :js do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within '#commits-list' do
expect(page).to have_content 'Unverified'
@@ -92,7 +93,7 @@ describe 'GPG signed commits', :js do
end
it 'unverified signature' do
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within(find('.commit', text: 'signed commit by bette cartwright')) do
click_on 'Unverified'
@@ -107,7 +108,7 @@ describe 'GPG signed commits', :js do
it 'unverified signature: user email does not match the committer email, but is the same user' do
user_2_key
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within(find('.commit', text: 'signed and authored commit by bette cartwright, different email')) do
click_on 'Unverified'
@@ -124,7 +125,7 @@ describe 'GPG signed commits', :js do
it 'unverified signature: user email does not match the committer email' do
user_2_key
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within(find('.commit', text: 'signed commit by bette cartwright')) do
click_on 'Unverified'
@@ -141,7 +142,7 @@ describe 'GPG signed commits', :js do
it 'verified and the gpg user has a gitlab profile' do
user_1_key
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
click_on 'Verified'
@@ -158,7 +159,7 @@ describe 'GPG signed commits', :js do
it "verified and the gpg user's profile doesn't exist anymore" do
user_1_key
- visit project_commits_path(project, :'signed-commits')
+ visit project_commits_path(project, ref)
# wait for the signature to get generated
within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index 4db73fccfb6..48f8b8bf77e 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -15,7 +15,7 @@ describe 'User uploads avatar to profile' do
visit user_path(user)
- expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"]))
+ expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=90"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb
index 9747b9402a7..7f7cfb2cb98 100644
--- a/spec/finders/todos_finder_spec.rb
+++ b/spec/finders/todos_finder_spec.rb
@@ -5,12 +5,50 @@ describe TodosFinder do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
+ let(:issue) { create(:issue, project: project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
let(:finder) { described_class }
before do
group.add_developer(user)
end
+ describe '#execute' do
+ context 'filtering' do
+ let!(:todo1) { create(:todo, user: user, project: project, target: issue) }
+ let!(:todo2) { create(:todo, user: user, group: group, target: merge_request) }
+
+ it 'returns correct todos when filtered by a project' do
+ todos = finder.new(user, { project_id: project.id }).execute
+
+ expect(todos).to match_array([todo1])
+ end
+
+ it 'returns correct todos when filtered by a group' do
+ todos = finder.new(user, { group_id: group.id }).execute
+
+ expect(todos).to match_array([todo1, todo2])
+ end
+
+ it 'returns correct todos when filtered by a type' do
+ todos = finder.new(user, { type: 'Issue' }).execute
+
+ expect(todos).to match_array([todo1])
+ end
+
+ context 'with subgroups', :nested_groups do
+ let(:subgroup) { create(:group, parent: group) }
+ let!(:todo3) { create(:todo, user: user, group: subgroup, target: issue) }
+
+ it 'returns todos from subgroups when filtered by a group' do
+ todos = finder.new(user, { group_id: group.id }).execute
+
+ expect(todos).to match_array([todo1, todo2, todo3])
+ end
+ end
+ end
+ end
+
describe '#sort' do
context 'by date' do
let!(:todo1) { create(:todo, user: user, project: project) }
diff --git a/spec/fixtures/importers/bitbucket_server/activities.json b/spec/fixtures/importers/bitbucket_server/activities.json
new file mode 100644
index 00000000000..09adfca9f31
--- /dev/null
+++ b/spec/fixtures/importers/bitbucket_server/activities.json
@@ -0,0 +1,1121 @@
+{
+ "isLastPage": true,
+ "limit": 25,
+ "size": 8,
+ "start": 0,
+ "values": [
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530164016725,
+ "id": 11,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [
+ {
+ "anchor": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "createdDate": 1530164016725,
+ "id": 11,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "text": "Ok",
+ "type": "COMMENT",
+ "updatedDate": 1530164016725,
+ "version": 0
+ },
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "createdDate": 1530164026000,
+ "id": 1,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true,
+ "transitionable": true
+ },
+ "state": "OPEN",
+ "text": "here's a task"
+ }
+ ],
+ "text": "Ok",
+ "updatedDate": 1530164016725,
+ "version": 0
+ },
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530165543990,
+ "id": 12,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "hi",
+ "updatedDate": 1530165543990,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530164013718,
+ "id": 10,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "Hello world",
+ "updatedDate": 1530164013718,
+ "version": 0
+ },
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530165549932,
+ "id": 13,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "hello",
+ "updatedDate": 1530165549932,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530161499144,
+ "id": 9,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "is this a new line?",
+ "updatedDate": 1530161499144,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "commentAnchor": {
+ "diffType": "EFFECTIVE",
+ "fileType": "TO",
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "line": 1,
+ "lineType": "ADDED",
+ "orphaned": false,
+ "path": "CHANGELOG.md",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "createdDate": 1530161499144,
+ "diff": {
+ "destination": {
+ "components": [
+ "CHANGELOG.md"
+ ],
+ "extension": "md",
+ "name": "CHANGELOG.md",
+ "parent": "",
+ "toString": "CHANGELOG.md"
+ },
+ "hunks": [
+ {
+ "destinationLine": 1,
+ "destinationSpan": 11,
+ "segments": [
+ {
+ "lines": [
+ {
+ "commentIds": [
+ 9
+ ],
+ "destination": 1,
+ "line": "# Edit 1",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 2,
+ "line": "",
+ "source": 1,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "ADDED"
+ },
+ {
+ "lines": [
+ {
+ "destination": 3,
+ "line": "# ChangeLog",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 4,
+ "line": "",
+ "source": 2,
+ "truncated": false
+ },
+ {
+ "destination": 5,
+ "line": "This log summarizes the changes in each released version of rouge. The versioning scheme",
+ "source": 3,
+ "truncated": false
+ },
+ {
+ "destination": 6,
+ "line": "we use is semver, although we will often release new lexers in minor versions, as a",
+ "source": 4,
+ "truncated": false
+ },
+ {
+ "destination": 7,
+ "line": "practical matter.",
+ "source": 5,
+ "truncated": false
+ },
+ {
+ "destination": 8,
+ "line": "",
+ "source": 6,
+ "truncated": false
+ },
+ {
+ "destination": 9,
+ "line": "## version TBD: (unreleased)",
+ "source": 7,
+ "truncated": false
+ },
+ {
+ "destination": 10,
+ "line": "",
+ "source": 8,
+ "truncated": false
+ },
+ {
+ "destination": 11,
+ "line": "* General",
+ "source": 9,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "CONTEXT"
+ }
+ ],
+ "sourceLine": 1,
+ "sourceSpan": 9,
+ "truncated": false
+ }
+ ],
+ "properties": {
+ "current": true,
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "source": null,
+ "truncated": false
+ },
+ "id": 19,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530053198463,
+ "id": 7,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "What about this line?",
+ "updatedDate": 1530053198463,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "commentAnchor": {
+ "diffType": "EFFECTIVE",
+ "fileType": "FROM",
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "line": 9,
+ "lineType": "CONTEXT",
+ "orphaned": false,
+ "path": "CHANGELOG.md",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "createdDate": 1530053198463,
+ "diff": {
+ "destination": {
+ "components": [
+ "CHANGELOG.md"
+ ],
+ "extension": "md",
+ "name": "CHANGELOG.md",
+ "parent": "",
+ "toString": "CHANGELOG.md"
+ },
+ "hunks": [
+ {
+ "destinationLine": 1,
+ "destinationSpan": 12,
+ "segments": [
+ {
+ "lines": [
+ {
+ "destination": 1,
+ "line": "# Edit 1",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 2,
+ "line": "",
+ "source": 1,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "ADDED"
+ },
+ {
+ "lines": [
+ {
+ "destination": 3,
+ "line": "# ChangeLog",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 4,
+ "line": "",
+ "source": 2,
+ "truncated": false
+ },
+ {
+ "destination": 5,
+ "line": "This log summarizes the changes in each released version of rouge. The versioning scheme",
+ "source": 3,
+ "truncated": false
+ },
+ {
+ "destination": 6,
+ "line": "we use is semver, although we will often release new lexers in minor versions, as a",
+ "source": 4,
+ "truncated": false
+ },
+ {
+ "destination": 7,
+ "line": "practical matter.",
+ "source": 5,
+ "truncated": false
+ },
+ {
+ "destination": 8,
+ "line": "",
+ "source": 6,
+ "truncated": false
+ },
+ {
+ "destination": 9,
+ "line": "## version TBD: (unreleased)",
+ "source": 7,
+ "truncated": false
+ },
+ {
+ "destination": 10,
+ "line": "",
+ "source": 8,
+ "truncated": false
+ },
+ {
+ "commentIds": [
+ 7
+ ],
+ "destination": 11,
+ "line": "* General",
+ "source": 9,
+ "truncated": false
+ },
+ {
+ "destination": 12,
+ "line": " * Load pastie theme ([#809](https://github.com/jneen/rouge/pull/809) by rramsden)",
+ "source": 10,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "CONTEXT"
+ }
+ ],
+ "sourceLine": 1,
+ "sourceSpan": 10,
+ "truncated": false
+ }
+ ],
+ "properties": {
+ "current": true,
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "source": null,
+ "truncated": false
+ },
+ "id": 14,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530143330513,
+ "id": 8,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "How about this?",
+ "updatedDate": 1530143330513,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530053193795,
+ "id": 6,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "It does.",
+ "updatedDate": 1530053193795,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530053187904,
+ "id": 5,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "Does this line make sense?",
+ "updatedDate": 1530053187904,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "commentAnchor": {
+ "diffType": "EFFECTIVE",
+ "fileType": "FROM",
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "line": 3,
+ "lineType": "CONTEXT",
+ "orphaned": false,
+ "path": "CHANGELOG.md",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "createdDate": 1530053187904,
+ "diff": {
+ "destination": {
+ "components": [
+ "CHANGELOG.md"
+ ],
+ "extension": "md",
+ "name": "CHANGELOG.md",
+ "parent": "",
+ "toString": "CHANGELOG.md"
+ },
+ "hunks": [
+ {
+ "destinationLine": 1,
+ "destinationSpan": 12,
+ "segments": [
+ {
+ "lines": [
+ {
+ "destination": 1,
+ "line": "# Edit 1",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 2,
+ "line": "",
+ "source": 1,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "ADDED"
+ },
+ {
+ "lines": [
+ {
+ "destination": 3,
+ "line": "# ChangeLog",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 4,
+ "line": "",
+ "source": 2,
+ "truncated": false
+ },
+ {
+ "commentIds": [
+ 5
+ ],
+ "destination": 5,
+ "line": "This log summarizes the changes in each released version of rouge. The versioning scheme",
+ "source": 3,
+ "truncated": false
+ },
+ {
+ "destination": 6,
+ "line": "we use is semver, although we will often release new lexers in minor versions, as a",
+ "source": 4,
+ "truncated": false
+ },
+ {
+ "destination": 7,
+ "line": "practical matter.",
+ "source": 5,
+ "truncated": false
+ },
+ {
+ "destination": 8,
+ "line": "",
+ "source": 6,
+ "truncated": false
+ },
+ {
+ "destination": 9,
+ "line": "## version TBD: (unreleased)",
+ "source": 7,
+ "truncated": false
+ },
+ {
+ "destination": 10,
+ "line": "",
+ "source": 8,
+ "truncated": false
+ },
+ {
+ "destination": 11,
+ "line": "* General",
+ "source": 9,
+ "truncated": false
+ },
+ {
+ "destination": 12,
+ "line": " * Load pastie theme ([#809](https://github.com/jneen/rouge/pull/809) by rramsden)",
+ "source": 10,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "CONTEXT"
+ }
+ ],
+ "sourceLine": 1,
+ "sourceSpan": 10,
+ "truncated": false
+ }
+ ],
+ "properties": {
+ "current": true,
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "source": null,
+ "truncated": false
+ },
+ "id": 12,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1529813304164,
+ "id": 4,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "Hello world",
+ "updatedDate": 1529813304164,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "createdDate": 1529813304164,
+ "id": 11,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "MERGED",
+ "commit": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "authorTimestamp": 1529727872000,
+ "committer": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "committerTimestamp": 1529727872000,
+ "displayId": "839fa9a2d43",
+ "id": "839fa9a2d434eb697815b8fcafaecc51accfdbbc",
+ "message": "Merge pull request #1 in TEST/rouge from root/CHANGELOGmd-1529725646923 to master\n\n* commit '66fbe6a097803f0acb7342b19563f710657ce5a2':\n CHANGELOG.md edited online with Bitbucket",
+ "parents": [
+ {
+ "author": {
+ "emailAddress": "dblessing@users.noreply.github.com",
+ "name": "Drew Blessing"
+ },
+ "authorTimestamp": 1529604583000,
+ "committer": {
+ "emailAddress": "noreply@github.com",
+ "name": "GitHub"
+ },
+ "committerTimestamp": 1529604583000,
+ "displayId": "c5f4288162e",
+ "id": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "message": "Merge pull request #949 from jneen/dblessing-patch-1\n\nAdd 'obj-c', 'obj_c' as ObjectiveC aliases",
+ "parents": [
+ {
+ "displayId": "ea7675f741e",
+ "id": "ea7675f741ee28f3f177ff32a9bde192742ffc59"
+ },
+ {
+ "displayId": "386b95a977b",
+ "id": "386b95a977b331e267497aa5206861774656f0c5"
+ }
+ ]
+ },
+ {
+ "author": {
+ "emailAddress": "test.user@example.com",
+ "name": "root"
+ },
+ "authorTimestamp": 1529725651000,
+ "committer": {
+ "emailAddress": "test.user@example.com",
+ "name": "root"
+ },
+ "committerTimestamp": 1529725651000,
+ "displayId": "66fbe6a0978",
+ "id": "66fbe6a097803f0acb7342b19563f710657ce5a2",
+ "message": "CHANGELOG.md edited online with Bitbucket",
+ "parents": [
+ {
+ "displayId": "c5f4288162e",
+ "id": "c5f4288162e2e6218180779c7f6ac1735bb56eab"
+ }
+ ]
+ }
+ ]
+ },
+ "createdDate": 1529727872302,
+ "id": 7,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1529813297478,
+ "id": 3,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "This is a thread",
+ "updatedDate": 1529813297478,
+ "version": 0
+ }
+ ],
+ "createdDate": 1529725692591,
+ "id": 2,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "What about this?",
+ "updatedDate": 1529725692591,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "createdDate": 1529725692591,
+ "id": 6,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1529725685910,
+ "id": 1,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "This is a test.\n\n[analyze.json](attachment:1/1f32f09d97%2Fanalyze.json)\n",
+ "updatedDate": 1529725685910,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "createdDate": 1529725685910,
+ "id": 5,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "OPENED",
+ "createdDate": 1529725657542,
+ "id": 4,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ }
+ ]
+}
diff --git a/spec/fixtures/importers/bitbucket_server/pull_request.json b/spec/fixtures/importers/bitbucket_server/pull_request.json
new file mode 100644
index 00000000000..6c7fcf3b04c
--- /dev/null
+++ b/spec/fixtures/importers/bitbucket_server/pull_request.json
@@ -0,0 +1,146 @@
+{
+ "author":{
+ "approved":false,
+ "role":"AUTHOR",
+ "status":"UNAPPROVED",
+ "user":{
+ "active":true,
+ "displayName":"root",
+ "emailAddress":"joe.montana@49ers.com",
+ "id":1,
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name":"root",
+ "slug":"root",
+ "type":"NORMAL"
+ }
+ },
+ "closed":true,
+ "closedDate":1530600648850,
+ "createdDate":1530600635690,
+ "description":"Test",
+ "fromRef":{
+ "displayId":"root/CODE_OF_CONDUCTmd-1530600625006",
+ "id":"refs/heads/root/CODE_OF_CONDUCTmd-1530600625006",
+ "latestCommit":"074e2b4dddc5b99df1bf9d4a3f66cfc15481fdc8",
+ "repository":{
+ "forkable":true,
+ "id":1,
+ "links":{
+ "clone":[
+ {
+ "href":"http://root@localhost:7990/scm/test/rouge.git",
+ "name":"http"
+ },
+ {
+ "href":"ssh://git@localhost:7999/test/rouge.git",
+ "name":"ssh"
+ }
+ ],
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST/repos/rouge/browse"
+ }
+ ]
+ },
+ "name":"rouge",
+ "project":{
+ "description":"Test",
+ "id":1,
+ "key":"TEST",
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST"
+ }
+ ]
+ },
+ "name":"test",
+ "public":false,
+ "type":"NORMAL"
+ },
+ "public":false,
+ "scmId":"git",
+ "slug":"rouge",
+ "state":"AVAILABLE",
+ "statusMessage":"Available"
+ }
+ },
+ "id":7,
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST/repos/rouge/pull-requests/7"
+ }
+ ]
+ },
+ "locked":false,
+ "open":false,
+ "participants":[
+
+ ],
+ "properties":{
+ "commentCount":1,
+ "openTaskCount":0,
+ "resolvedTaskCount":0
+ },
+ "reviewers":[
+
+ ],
+ "state":"MERGED",
+ "title":"Added a new line",
+ "toRef":{
+ "displayId":"master",
+ "id":"refs/heads/master",
+ "latestCommit":"839fa9a2d434eb697815b8fcafaecc51accfdbbc",
+ "repository":{
+ "forkable":true,
+ "id":1,
+ "links":{
+ "clone":[
+ {
+ "href":"http://root@localhost:7990/scm/test/rouge.git",
+ "name":"http"
+ },
+ {
+ "href":"ssh://git@localhost:7999/test/rouge.git",
+ "name":"ssh"
+ }
+ ],
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST/repos/rouge/browse"
+ }
+ ]
+ },
+ "name":"rouge",
+ "project":{
+ "description":"Test",
+ "id":1,
+ "key":"TEST",
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST"
+ }
+ ]
+ },
+ "name":"test",
+ "public":false,
+ "type":"NORMAL"
+ },
+ "public":false,
+ "scmId":"git",
+ "slug":"rouge",
+ "state":"AVAILABLE",
+ "statusMessage":"Available"
+ }
+ },
+ "updatedDate":1530600648850,
+ "version":2
+}
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 77410e0070c..f76ed4bfda4 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -21,6 +21,27 @@ describe IssuablesHelper do
end
end
+ describe '#group_dropdown_label' do
+ let(:group) { create(:group) }
+ let(:default) { 'default label' }
+
+ it 'returns default group label when group_id is nil' do
+ expect(group_dropdown_label(nil, default)).to eq('default label')
+ end
+
+ it 'returns "any group" when group_id is 0' do
+ expect(group_dropdown_label('0', default)).to eq('Any group')
+ end
+
+ it 'returns group full path when a group was found for the provided id' do
+ expect(group_dropdown_label(group.id, default)).to eq(group.full_name)
+ end
+
+ it 'returns default label when a group was not found for the provided id' do
+ expect(group_dropdown_label(9999, default)).to eq('default label')
+ end
+ end
+
describe '#issuable_labels_tooltip' do
it 'returns label text with no labels' do
expect(issuable_labels_tooltip([])).to eq("Labels")
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index 343e140f5fb..234690e742b 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -31,6 +31,44 @@ describe NamespacesHelper do
expect(options).to include(user.name)
end
+ it 'avoids duplicate groups when extra_group is used' do
+ allow(helper).to receive(:current_user).and_return(admin)
+
+ options = helper.namespaces_options(user_group.id, display_path: true, extra_group: build(:group, name: admin_group.name))
+
+ expect(options.scan("data-name=\"#{admin_group.name}\"").count).to eq(1)
+ expect(options).to include(admin_group.name)
+ end
+
+ it 'selects existing group' do
+ allow(helper).to receive(:current_user).and_return(admin)
+
+ options = helper.namespaces_options(:extra_group, display_path: true, extra_group: user_group)
+
+ expect(options).to include("selected=\"selected\" value=\"#{user_group.id}\"")
+ expect(options).to include(admin_group.name)
+ end
+
+ it 'selects the new group by default' do
+ allow(helper).to receive(:current_user).and_return(user)
+
+ options = helper.namespaces_options(:extra_group, display_path: true, extra_group: build(:group, name: 'new-group'))
+
+ expect(options).to include(user_group.name)
+ expect(options).not_to include(admin_group.name)
+ expect(options).to include("selected=\"selected\" value=\"-1\"")
+ end
+
+ it 'falls back to current user selection' do
+ allow(helper).to receive(:current_user).and_return(user)
+
+ options = helper.namespaces_options(:extra_group, display_path: true, extra_group: build(:group, name: admin_group.name))
+
+ expect(options).to include(user_group.name)
+ expect(options).not_to include(admin_group.name)
+ expect(options).to include("selected=\"selected\" value=\"#{user.namespace.id}\"")
+ end
+
it 'returns only groups if groups_only option is true' do
allow(helper).to receive(:current_user).and_return(user)
diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js
index 38ae5b7e00c..dcb1c781591 100644
--- a/spec/javascripts/autosave_spec.js
+++ b/spec/javascripts/autosave_spec.js
@@ -59,12 +59,10 @@ describe('Autosave', () => {
Autosave.prototype.restore.call(autosave);
- expect(
- field.trigger,
- ).toHaveBeenCalled();
+ expect(field.trigger).toHaveBeenCalled();
});
- it('triggers native event', (done) => {
+ it('triggers native event', done => {
autosave.field.get(0).addEventListener('change', () => {
done();
});
@@ -81,9 +79,7 @@ describe('Autosave', () => {
it('does not trigger event', () => {
spyOn(field, 'trigger').and.callThrough();
- expect(
- field.trigger,
- ).not.toHaveBeenCalled();
+ expect(field.trigger).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js
index 7a32e84bced..b6c61e7bad7 100644
--- a/spec/javascripts/boards/issue_card_spec.js
+++ b/spec/javascripts/boards/issue_card_spec.js
@@ -69,109 +69,100 @@ describe('Issue card component', () => {
});
it('renders issue title', () => {
- expect(
- component.$el.querySelector('.board-card-title').textContent,
- ).toContain(issue.title);
+ expect(component.$el.querySelector('.board-card-title').textContent).toContain(issue.title);
});
it('includes issue base in link', () => {
- expect(
- component.$el.querySelector('.board-card-title a').getAttribute('href'),
- ).toContain('/test');
+ expect(component.$el.querySelector('.board-card-title a').getAttribute('href')).toContain(
+ '/test',
+ );
});
it('includes issue title on link', () => {
- expect(
- component.$el.querySelector('.board-card-title a').getAttribute('title'),
- ).toBe(issue.title);
+ expect(component.$el.querySelector('.board-card-title a').getAttribute('title')).toBe(
+ issue.title,
+ );
});
it('does not render confidential icon', () => {
- expect(
- component.$el.querySelector('.fa-eye-flash'),
- ).toBeNull();
+ expect(component.$el.querySelector('.fa-eye-flash')).toBeNull();
});
- it('renders confidential icon', (done) => {
+ it('renders confidential icon', done => {
component.issue.confidential = true;
Vue.nextTick(() => {
- expect(
- component.$el.querySelector('.confidential-icon'),
- ).not.toBeNull();
+ expect(component.$el.querySelector('.confidential-icon')).not.toBeNull();
done();
});
});
it('renders issue ID with #', () => {
- expect(
- component.$el.querySelector('.board-card-number').textContent,
- ).toContain(`#${issue.id}`);
+ expect(component.$el.querySelector('.board-card-number').textContent).toContain(`#${issue.id}`);
});
describe('assignee', () => {
it('does not render assignee', () => {
- expect(
- component.$el.querySelector('.board-card-assignee .avatar'),
- ).toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee .avatar')).toBeNull();
});
describe('exists', () => {
- beforeEach((done) => {
+ beforeEach(done => {
component.issue.assignees = [user];
Vue.nextTick(() => done());
});
it('renders assignee', () => {
- expect(
- component.$el.querySelector('.board-card-assignee .avatar'),
- ).not.toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee .avatar')).not.toBeNull();
});
it('sets title', () => {
expect(
- component.$el.querySelector('.board-card-assignee img').getAttribute('data-original-title'),
+ component.$el
+ .querySelector('.board-card-assignee img')
+ .getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`);
});
it('sets users path', () => {
- expect(
- component.$el.querySelector('.board-card-assignee a').getAttribute('href'),
- ).toBe('/test');
+ expect(component.$el.querySelector('.board-card-assignee a').getAttribute('href')).toBe(
+ '/test',
+ );
});
it('renders avatar', () => {
- expect(
- component.$el.querySelector('.board-card-assignee img'),
- ).not.toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
});
});
describe('assignee default avatar', () => {
- beforeEach((done) => {
- component.issue.assignees = [new ListAssignee({
- id: 1,
- name: 'testing 123',
- username: 'test',
- }, 'default_avatar')];
+ beforeEach(done => {
+ component.issue.assignees = [
+ new ListAssignee(
+ {
+ id: 1,
+ name: 'testing 123',
+ username: 'test',
+ },
+ 'default_avatar',
+ ),
+ ];
Vue.nextTick(done);
});
it('displays defaults avatar if users avatar is null', () => {
- expect(
- component.$el.querySelector('.board-card-assignee img'),
- ).not.toBeNull();
- expect(
- component.$el.querySelector('.board-card-assignee img').getAttribute('src'),
- ).toBe('default_avatar');
+ expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe(
+ 'default_avatar?width=20',
+ );
});
});
});
describe('multiple assignees', () => {
- beforeEach((done) => {
+ beforeEach(done => {
component.issue.assignees = [
user,
new ListAssignee({
@@ -191,7 +182,8 @@ describe('Issue card component', () => {
name: 'user4',
username: 'user4',
avatar: 'test_image',
- })];
+ }),
+ ];
Vue.nextTick(() => done());
});
@@ -201,26 +193,30 @@ describe('Issue card component', () => {
});
describe('more than four assignees', () => {
- beforeEach((done) => {
- component.issue.assignees.push(new ListAssignee({
- id: 5,
- name: 'user5',
- username: 'user5',
- avatar: 'test_image',
- }));
+ beforeEach(done => {
+ component.issue.assignees.push(
+ new ListAssignee({
+ id: 5,
+ name: 'user5',
+ username: 'user5',
+ avatar: 'test_image',
+ }),
+ );
Vue.nextTick(() => done());
});
it('renders more avatar counter', () => {
- expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('+2');
+ expect(
+ component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
+ ).toEqual('+2');
});
it('renders three assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3);
});
- it('renders 99+ avatar counter', (done) => {
+ it('renders 99+ avatar counter', done => {
for (let i = 5; i < 104; i += 1) {
const u = new ListAssignee({
id: i,
@@ -232,7 +228,9 @@ describe('Issue card component', () => {
}
Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('99+');
+ expect(
+ component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
+ ).toEqual('99+');
done();
});
});
@@ -240,59 +238,51 @@ describe('Issue card component', () => {
});
describe('labels', () => {
- beforeEach((done) => {
+ beforeEach(done => {
component.issue.addLabel(label1);
Vue.nextTick(() => done());
});
it('renders list label', () => {
- expect(
- component.$el.querySelectorAll('.badge').length,
- ).toBe(2);
+ expect(component.$el.querySelectorAll('.badge').length).toBe(2);
});
it('renders label', () => {
const nodes = [];
- component.$el.querySelectorAll('.badge').forEach((label) => {
+ component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.getAttribute('data-original-title'));
});
- expect(
- nodes.includes(label1.description),
- ).toBe(true);
+ expect(nodes.includes(label1.description)).toBe(true);
});
it('sets label description as title', () => {
- expect(
- component.$el.querySelector('.badge').getAttribute('data-original-title'),
- ).toContain(label1.description);
+ expect(component.$el.querySelector('.badge').getAttribute('data-original-title')).toContain(
+ label1.description,
+ );
});
it('sets background color of button', () => {
const nodes = [];
- component.$el.querySelectorAll('.badge').forEach((label) => {
+ component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.style.backgroundColor);
});
- expect(
- nodes.includes(label1.color),
- ).toBe(true);
+ expect(nodes.includes(label1.color)).toBe(true);
});
- it('does not render label if label does not have an ID', (done) => {
- component.issue.addLabel(new ListLabel({
- title: 'closed',
- }));
+ it('does not render label if label does not have an ID', done => {
+ component.issue.addLabel(
+ new ListLabel({
+ title: 'closed',
+ }),
+ );
Vue.nextTick()
.then(() => {
- expect(
- component.$el.querySelectorAll('.badge').length,
- ).toBe(2);
- expect(
- component.$el.textContent,
- ).not.toContain('closed');
+ expect(component.$el.querySelectorAll('.badge').length).toBe(2);
+ expect(component.$el.textContent).not.toContain('closed');
done();
})
diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js
index 839b8a06b48..d0e0b214509 100644
--- a/spec/javascripts/clusters/clusters_bundle_spec.js
+++ b/spec/javascripts/clusters/clusters_bundle_spec.js
@@ -1,11 +1,9 @@
import Clusters from '~/clusters/clusters_bundle';
import {
- APPLICATION_INSTALLABLE,
- APPLICATION_INSTALLING,
- APPLICATION_INSTALLED,
REQUEST_LOADING,
REQUEST_SUCCESS,
REQUEST_FAILURE,
+ APPLICATION_STATUS,
} from '~/clusters/constants';
import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
@@ -84,7 +82,7 @@ describe('Clusters', () => {
it('does not show alert when things transition from initial null state to something', () => {
cluster.checkForNewInstalls(INITIAL_APP_MAP, {
...INITIAL_APP_MAP,
- helm: { status: APPLICATION_INSTALLABLE, title: 'Helm Tiller' },
+ helm: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Helm Tiller' },
});
const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text');
@@ -94,10 +92,10 @@ describe('Clusters', () => {
it('shows an alert when something gets newly installed', () => {
cluster.checkForNewInstalls({
...INITIAL_APP_MAP,
- helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' },
+ helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' },
}, {
...INITIAL_APP_MAP,
- helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' },
+ helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' },
});
const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text');
@@ -108,12 +106,12 @@ describe('Clusters', () => {
it('shows an alert when multiple things gets newly installed', () => {
cluster.checkForNewInstalls({
...INITIAL_APP_MAP,
- helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' },
- ingress: { status: APPLICATION_INSTALLABLE, title: 'Ingress' },
+ helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' },
+ ingress: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Ingress' },
}, {
...INITIAL_APP_MAP,
- helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' },
- ingress: { status: APPLICATION_INSTALLED, title: 'Ingress' },
+ helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' },
+ ingress: { status: APPLICATION_STATUS.INSTALLED, title: 'Ingress' },
});
const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text');
diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js
index c83cbe90a57..9da5c248371 100644
--- a/spec/javascripts/clusters/components/application_row_spec.js
+++ b/spec/javascripts/clusters/components/application_row_spec.js
@@ -1,12 +1,7 @@
import Vue from 'vue';
import eventHub from '~/clusters/event_hub';
import {
- APPLICATION_NOT_INSTALLABLE,
- APPLICATION_SCHEDULED,
- APPLICATION_INSTALLABLE,
- APPLICATION_INSTALLING,
- APPLICATION_INSTALLED,
- APPLICATION_ERROR,
+ APPLICATION_STATUS,
REQUEST_LOADING,
REQUEST_SUCCESS,
REQUEST_FAILURE,
@@ -62,10 +57,10 @@ describe('Application Row', () => {
expect(vm.installButtonLabel).toBeUndefined();
});
- it('has disabled "Install" when APPLICATION_NOT_INSTALLABLE', () => {
+ it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_NOT_INSTALLABLE,
+ status: APPLICATION_STATUS.NOT_INSTALLABLE,
});
expect(vm.installButtonLabel).toEqual('Install');
@@ -73,10 +68,10 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(true);
});
- it('has enabled "Install" when APPLICATION_INSTALLABLE', () => {
+ it('has enabled "Install" when APPLICATION_STATUS.INSTALLABLE', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
});
expect(vm.installButtonLabel).toEqual('Install');
@@ -84,10 +79,10 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(false);
});
- it('has loading "Installing" when APPLICATION_SCHEDULED', () => {
+ it('has loading "Installing" when APPLICATION_STATUS.SCHEDULED', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_SCHEDULED,
+ status: APPLICATION_STATUS.SCHEDULED,
});
expect(vm.installButtonLabel).toEqual('Installing');
@@ -95,10 +90,10 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(true);
});
- it('has loading "Installing" when APPLICATION_INSTALLING', () => {
+ it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLING,
+ status: APPLICATION_STATUS.INSTALLING,
});
expect(vm.installButtonLabel).toEqual('Installing');
@@ -106,10 +101,10 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(true);
});
- it('has disabled "Installed" when APPLICATION_INSTALLED', () => {
+ it('has disabled "Installed" when APPLICATION_STATUS.INSTALLED', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLED,
+ status: APPLICATION_STATUS.INSTALLED,
});
expect(vm.installButtonLabel).toEqual('Installed');
@@ -117,10 +112,10 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(true);
});
- it('has enabled "Install" when APPLICATION_ERROR', () => {
+ it('has enabled "Install" when APPLICATION_STATUS.ERROR', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_ERROR,
+ status: APPLICATION_STATUS.ERROR,
});
expect(vm.installButtonLabel).toEqual('Install');
@@ -131,7 +126,7 @@ describe('Application Row', () => {
it('has loading "Install" when REQUEST_LOADING', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
requestStatus: REQUEST_LOADING,
});
@@ -143,7 +138,7 @@ describe('Application Row', () => {
it('has disabled "Install" when REQUEST_SUCCESS', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
requestStatus: REQUEST_SUCCESS,
});
@@ -155,7 +150,7 @@ describe('Application Row', () => {
it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
requestStatus: REQUEST_FAILURE,
});
@@ -168,7 +163,7 @@ describe('Application Row', () => {
spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
});
const installButton = vm.$el.querySelector('.js-cluster-application-install-button');
@@ -184,7 +179,7 @@ describe('Application Row', () => {
spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
installApplicationRequestParams: { hostname: 'jupyter' },
});
const installButton = vm.$el.querySelector('.js-cluster-application-install-button');
@@ -201,7 +196,7 @@ describe('Application Row', () => {
spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLING,
+ status: APPLICATION_STATUS.INSTALLING,
});
const installButton = vm.$el.querySelector('.js-cluster-application-install-button');
@@ -225,11 +220,11 @@ describe('Application Row', () => {
expect(generalErrorMessage).toBeNull();
});
- it('shows status reason when APPLICATION_ERROR', () => {
+ it('shows status reason when APPLICATION_STATUS.ERROR', () => {
const statusReason = 'We broke it 0.0';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_ERROR,
+ status: APPLICATION_STATUS.ERROR,
statusReason,
});
const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message');
@@ -243,7 +238,7 @@ describe('Application Row', () => {
const requestReason = 'We broke thre request 0.0';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
requestStatus: REQUEST_FAILURE,
requestReason,
});
diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js
index b2b0ebf840b..c7c1412e1c6 100644
--- a/spec/javascripts/clusters/services/mock_data.js
+++ b/spec/javascripts/clusters/services/mock_data.js
@@ -1,9 +1,4 @@
-import {
- APPLICATION_INSTALLED,
- APPLICATION_INSTALLABLE,
- APPLICATION_INSTALLING,
- APPLICATION_ERROR,
-} from '~/clusters/constants';
+import { APPLICATION_STATUS } from '~/clusters/constants';
const CLUSTERS_MOCK_DATA = {
GET: {
@@ -13,25 +8,25 @@ const CLUSTERS_MOCK_DATA = {
status_reason: 'Failed to request to CloudPlatform.',
applications: [{
name: 'helm',
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
status_reason: null,
}, {
name: 'ingress',
- status: APPLICATION_ERROR,
+ status: APPLICATION_STATUS.ERROR,
status_reason: 'Cannot connect',
external_ip: null,
}, {
name: 'runner',
- status: APPLICATION_INSTALLING,
+ status: APPLICATION_STATUS.INSTALLING,
status_reason: null,
},
{
name: 'prometheus',
- status: APPLICATION_ERROR,
+ status: APPLICATION_STATUS.ERROR,
status_reason: 'Cannot connect',
}, {
name: 'jupyter',
- status: APPLICATION_INSTALLING,
+ status: APPLICATION_STATUS.INSTALLING,
status_reason: 'Cannot connect',
}],
},
@@ -42,25 +37,25 @@ const CLUSTERS_MOCK_DATA = {
status_reason: 'Failed to request to CloudPlatform.',
applications: [{
name: 'helm',
- status: APPLICATION_INSTALLED,
+ status: APPLICATION_STATUS.INSTALLED,
status_reason: null,
}, {
name: 'ingress',
- status: APPLICATION_INSTALLED,
+ status: APPLICATION_STATUS.INSTALLED,
status_reason: 'Cannot connect',
external_ip: '1.1.1.1',
}, {
name: 'runner',
- status: APPLICATION_INSTALLING,
+ status: APPLICATION_STATUS.INSTALLING,
status_reason: null,
},
{
name: 'prometheus',
- status: APPLICATION_ERROR,
+ status: APPLICATION_STATUS.ERROR,
status_reason: 'Cannot connect',
}, {
name: 'jupyter',
- status: APPLICATION_INSTALLABLE,
+ status: APPLICATION_STATUS.INSTALLABLE,
status_reason: 'Cannot connect',
}],
},
diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js
index 9e43552f740..104a064bdd3 100644
--- a/spec/javascripts/clusters/stores/clusters_store_spec.js
+++ b/spec/javascripts/clusters/stores/clusters_store_spec.js
@@ -1,5 +1,5 @@
import ClustersStore from '~/clusters/stores/clusters_store';
-import { APPLICATION_INSTALLING } from '~/clusters/constants';
+import { APPLICATION_STATUS } from '~/clusters/constants';
import { CLUSTERS_MOCK_DATA } from '../services/mock_data';
describe('Clusters Store', () => {
@@ -35,7 +35,7 @@ describe('Clusters Store', () => {
it('should store new request status', () => {
expect(store.state.applications.helm.requestStatus).toEqual(null);
- const newStatus = APPLICATION_INSTALLING;
+ const newStatus = APPLICATION_STATUS.INSTALLING;
store.updateAppProperty('helm', 'requestStatus', newStatus);
expect(store.state.applications.helm.requestStatus).toEqual(newStatus);
diff --git a/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js b/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js
index 2d136a63c52..a1a37b342b7 100644
--- a/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js
+++ b/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js
@@ -48,7 +48,11 @@ describe('DiffLineGutterContent', () => {
it('should return discussions for the given lineCode', () => {
const { lineCode } = getDiffFileMock().highlightedDiffLines[1];
- const component = createComponent({ lineCode, showCommentButton: true });
+ const component = createComponent({
+ lineCode,
+ showCommentButton: true,
+ discussions: getDiscussionsMockData(),
+ });
setDiscussions(component);
diff --git a/spec/javascripts/diffs/components/diff_line_note_form_spec.js b/spec/javascripts/diffs/components/diff_line_note_form_spec.js
index 4600aaea70b..6fe5fdaf7f9 100644
--- a/spec/javascripts/diffs/components/diff_line_note_form_spec.js
+++ b/spec/javascripts/diffs/components/diff_line_note_form_spec.js
@@ -3,6 +3,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue';
import store from '~/mr_notes/stores';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import diffFileMockData from '../mock_data/diff_file';
+import { noteableDataMock } from '../../notes/mock_data';
describe('DiffLineNoteForm', () => {
let component;
@@ -21,10 +22,9 @@ describe('DiffLineNoteForm', () => {
noteTargetLine: diffLines[0],
});
- Object.defineProperty(component, 'isLoggedIn', {
- get() {
- return true;
- },
+ Object.defineProperties(component, {
+ noteableData: { value: noteableDataMock },
+ isLoggedIn: { value: true },
});
component.$mount();
@@ -32,12 +32,37 @@ describe('DiffLineNoteForm', () => {
describe('methods', () => {
describe('handleCancelCommentForm', () => {
- it('should call cancelCommentForm with lineCode', () => {
+ it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => {
+ spyOn(window, 'confirm').and.returnValue(false);
+
+ component.handleCancelCommentForm(true, true);
+ expect(window.confirm).toHaveBeenCalled();
+ });
+
+ it('should ask for confirmation when one of the params false', () => {
+ spyOn(window, 'confirm').and.returnValue(false);
+
+ component.handleCancelCommentForm(true, false);
+ expect(window.confirm).not.toHaveBeenCalled();
+
+ component.handleCancelCommentForm(false, true);
+ expect(window.confirm).not.toHaveBeenCalled();
+ });
+
+ it('should call cancelCommentForm with lineCode', done => {
+ spyOn(window, 'confirm');
spyOn(component, 'cancelCommentForm');
+ spyOn(component, 'resetAutoSave');
component.handleCancelCommentForm();
- expect(component.cancelCommentForm).toHaveBeenCalledWith({
- lineCode: diffLines[0].lineCode,
+ expect(window.confirm).not.toHaveBeenCalled();
+ component.$nextTick(() => {
+ expect(component.cancelCommentForm).toHaveBeenCalledWith({
+ lineCode: diffLines[0].lineCode,
+ });
+ expect(component.resetAutoSave).toHaveBeenCalled();
+
+ done();
});
});
});
@@ -66,7 +91,7 @@ describe('DiffLineNoteForm', () => {
describe('mounted', () => {
it('should init autosave', () => {
- const key = 'autosave/Note/issue///DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1';
+ const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1';
expect(component.autosave).toBeDefined();
expect(component.autosave.key).toEqual(key);
diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js
index 7706c32d24d..a59b26b2634 100644
--- a/spec/javascripts/diffs/store/getters_spec.js
+++ b/spec/javascripts/diffs/store/getters_spec.js
@@ -184,6 +184,104 @@ describe('Diffs Module Getters', () => {
});
});
+ describe('singleDiscussionByLineCode', () => {
+ it('returns found discussion per line Code', () => {
+ const discussionsMock = {};
+ discussionsMock.ABC = discussionMock;
+
+ expect(
+ getters.singleDiscussionByLineCode(localState, {}, null, {
+ discussionsByLineCode: () => discussionsMock,
+ })('DEF'),
+ ).toEqual([]);
+ });
+
+ it('returns empty array when no discussions match', () => {
+ expect(
+ getters.singleDiscussionByLineCode(localState, {}, null, {
+ discussionsByLineCode: () => {},
+ })('DEF'),
+ ).toEqual([]);
+ });
+ });
+
+ describe('shouldRenderParallelCommentRow', () => {
+ let line;
+
+ beforeEach(() => {
+ line = {};
+
+ line.left = {
+ lineCode: 'ABC',
+ };
+
+ line.right = {
+ lineCode: 'DEF',
+ };
+ });
+
+ it('returns true when discussion is expanded', () => {
+ discussionMock.expanded = true;
+
+ expect(
+ getters.shouldRenderParallelCommentRow(localState, {
+ singleDiscussionByLineCode: () => [discussionMock],
+ })(line),
+ ).toEqual(true);
+ });
+
+ it('returns false when no discussion was found', () => {
+ localState.diffLineCommentForms.ABC = false;
+ localState.diffLineCommentForms.DEF = false;
+
+ expect(
+ getters.shouldRenderParallelCommentRow(localState, {
+ singleDiscussionByLineCode: () => [],
+ })(line),
+ ).toEqual(false);
+ });
+
+ it('returns true when discussionForm was found', () => {
+ localState.diffLineCommentForms.ABC = {};
+
+ expect(
+ getters.shouldRenderParallelCommentRow(localState, {
+ singleDiscussionByLineCode: () => [discussionMock],
+ })(line),
+ ).toEqual(true);
+ });
+ });
+
+ describe('shouldRenderInlineCommentRow', () => {
+ it('returns true when diffLineCommentForms has form', () => {
+ localState.diffLineCommentForms.ABC = {};
+
+ expect(
+ getters.shouldRenderInlineCommentRow(localState)({
+ lineCode: 'ABC',
+ }),
+ ).toEqual(true);
+ });
+
+ it('returns false when no line discussions were found', () => {
+ expect(
+ getters.shouldRenderInlineCommentRow(localState, {
+ singleDiscussionByLineCode: () => [],
+ })('DEF'),
+ ).toEqual(false);
+ });
+
+ it('returns true if all found discussions are expanded', () => {
+ discussionMock.expanded = true;
+
+ expect(
+ getters.shouldRenderInlineCommentRow(localState, {
+ singleDiscussionByLineCode: () => [discussionMock],
+ })('ABC'),
+ ).toEqual(true);
+ });
+ });
+
describe('getDiffFileDiscussions', () => {
it('returns an array with discussions when fileHash matches and the discussion belongs to a diff', () => {
discussionMock.diff_file.file_hash = diffFileMock.fileHash;
diff --git a/spec/javascripts/fixtures/search_autocomplete.html.haml b/spec/javascripts/fixtures/search_autocomplete.html.haml
index 0421ed2182f..4aa54da9411 100644
--- a/spec/javascripts/fixtures/search_autocomplete.html.haml
+++ b/spec/javascripts/fixtures/search_autocomplete.html.haml
@@ -1,8 +1,6 @@
-.search.search-form.has-location-badge
- %form.navbar-form
+.search.search-form
+ %form.form-inline
.search-input-container
- %div.location-badge
- This project
.search-input-wrap
.dropdown
%input#search.search-input.dropdown-menu-toggle
diff --git a/spec/javascripts/helpers/vuex_action_helper.js b/spec/javascripts/helpers/vuex_action_helper.js
index dd9174194a1..1972408356e 100644
--- a/spec/javascripts/helpers/vuex_action_helper.js
+++ b/spec/javascripts/helpers/vuex_action_helper.js
@@ -84,7 +84,7 @@ export default (
done();
};
- const result = action({ commit, state, dispatch, rootState: state }, payload);
+ const result = action({ commit, state, dispatch, rootState: state, rootGetters: state }, payload);
return new Promise(resolve => {
setImmediate(resolve);
diff --git a/spec/javascripts/ide/components/activity_bar_spec.js b/spec/javascripts/ide/components/activity_bar_spec.js
index 946c7e8e9c8..4d878e633fe 100644
--- a/spec/javascripts/ide/components/activity_bar_spec.js
+++ b/spec/javascripts/ide/components/activity_bar_spec.js
@@ -24,26 +24,6 @@ describe('IDE activity bar', () => {
resetStore(vm.$store);
});
- describe('goBackUrl', () => {
- it('renders the Go Back link with the referrer when present', () => {
- const fakeReferrer = '/example/README.md';
- spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
-
- vm.$mount();
-
- expect(vm.goBackUrl).toEqual(fakeReferrer);
- });
-
- it('renders the Go Back link with the project url when referrer is not present', () => {
- const fakeReferrer = '';
- spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
-
- vm.$mount();
-
- expect(vm.goBackUrl).toEqual('testing');
- });
- });
-
describe('updateActivityBarView', () => {
beforeEach(() => {
spyOn(vm, 'updateActivityBarView');
diff --git a/spec/javascripts/ide/components/branches/item_spec.js b/spec/javascripts/ide/components/branches/item_spec.js
new file mode 100644
index 00000000000..8b756c8f168
--- /dev/null
+++ b/spec/javascripts/ide/components/branches/item_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import mountCompontent from 'spec/helpers/vue_mount_component_helper';
+import router from '~/ide/ide_router';
+import Item from '~/ide/components/branches/item.vue';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { projectData } from '../../mock_data';
+
+const TEST_BRANCH = {
+ name: 'master',
+ committedDate: '2018-01-05T05:50Z',
+};
+const TEST_PROJECT_ID = projectData.name_with_namespace;
+
+describe('IDE branch item', () => {
+ const Component = Vue.extend(Item);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountCompontent(Component, {
+ item: { ...TEST_BRANCH },
+ projectId: TEST_PROJECT_ID,
+ isActive: false,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders branch name and timeago', () => {
+ const timeText = getTimeago().format(TEST_BRANCH.committedDate);
+ expect(vm.$el).toContainText(TEST_BRANCH.name);
+ expect(vm.$el.querySelector('time')).toHaveText(timeText);
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
+ });
+
+ it('renders link to branch', () => {
+ const expectedHref = router.resolve(`/project/${TEST_PROJECT_ID}/edit/${TEST_BRANCH.name}`).href;
+ expect(vm.$el).toMatch('a');
+ expect(vm.$el).toHaveAttr('href', expectedHref);
+ });
+
+ it('renders icon if isActive', done => {
+ vm.isActive = true;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/javascripts/ide/components/branches/search_list_spec.js b/spec/javascripts/ide/components/branches/search_list_spec.js
new file mode 100644
index 00000000000..c3f84ba1c24
--- /dev/null
+++ b/spec/javascripts/ide/components/branches/search_list_spec.js
@@ -0,0 +1,79 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import * as types from '~/ide/stores/modules/branches/mutation_types';
+import List from '~/ide/components/branches/search_list.vue';
+import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
+import { branches as testBranches } from '../../mock_data';
+import { resetStore } from '../../helpers';
+
+describe('IDE branches search list', () => {
+ const Component = Vue.extend(List);
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponentWithStore(Component, store, {});
+
+ spyOn(vm, 'fetchBranches');
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(store);
+ });
+
+ it('calls fetch on mounted', () => {
+ expect(vm.fetchBranches).toHaveBeenCalledWith({
+ search: '',
+ });
+ });
+
+ it('renders loading icon', done => {
+ vm.$store.state.branches.isLoading = true;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el).toContainElement('.loading-container');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders branches not found when search is not empty', done => {
+ vm.search = 'testing';
+
+ vm.$nextTick(() => {
+ expect(vm.$el).toContainText('No branches found');
+
+ done();
+ });
+ });
+
+ describe('with branches', () => {
+ const currentBranch = testBranches[1];
+
+ beforeEach(done => {
+ vm.$store.state.currentBranchId = currentBranch.name;
+ vm.$store.commit(`branches/${types.RECEIVE_BRANCHES_SUCCESS}`, testBranches);
+
+ vm.$nextTick(done);
+ });
+
+ it('renders list', () => {
+ const elementText = Array.from(vm.$el.querySelectorAll('li strong'))
+ .map(x => x.textContent.trim());
+
+ expect(elementText).toEqual(testBranches.map(x => x.name));
+ });
+
+ it('renders check next to active branch', () => {
+ const checkedText = Array.from(vm.$el.querySelectorAll('li'))
+ .filter(x => x.querySelector('.ide-search-list-current-icon svg'))
+ .map(x => x.querySelector('strong').textContent.trim());
+
+ expect(checkedText).toEqual([currentBranch.name]);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/merge_requests/dropdown_spec.js b/spec/javascripts/ide/components/merge_requests/dropdown_spec.js
deleted file mode 100644
index 74884c9a362..00000000000
--- a/spec/javascripts/ide/components/merge_requests/dropdown_spec.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import Vue from 'vue';
-import { createStore } from '~/ide/stores';
-import Dropdown from '~/ide/components/merge_requests/dropdown.vue';
-import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
-import { mergeRequests } from '../../mock_data';
-
-describe('IDE merge requests dropdown', () => {
- const Component = Vue.extend(Dropdown);
- let vm;
-
- beforeEach(() => {
- const store = createStore();
-
- vm = createComponentWithStore(Component, store, { show: false }).$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('does not render tabs when show is false', () => {
- expect(vm.$el.querySelector('.nav-links')).toBe(null);
- });
-
- describe('when show is true', () => {
- beforeEach(done => {
- vm.show = true;
- vm.$store.state.mergeRequests.assigned.mergeRequests.push(mergeRequests[0]);
-
- vm.$nextTick(done);
- });
-
- it('renders tabs', () => {
- expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
- });
-
- it('renders count for assigned & created data', () => {
- expect(vm.$el.querySelector('.nav-links a').textContent).toContain('Created by me');
- expect(vm.$el.querySelector('.nav-links a .badge').textContent).toContain('0');
-
- expect(vm.$el.querySelectorAll('.nav-links a')[1].textContent).toContain('Assigned to me');
- expect(
- vm.$el.querySelectorAll('.nav-links a')[1].querySelector('.badge').textContent,
- ).toContain('1');
- });
- });
-});
diff --git a/spec/javascripts/ide/components/merge_requests/item_spec.js b/spec/javascripts/ide/components/merge_requests/item_spec.js
index 51c4cddef2f..750948cae3c 100644
--- a/spec/javascripts/ide/components/merge_requests/item_spec.js
+++ b/spec/javascripts/ide/components/merge_requests/item_spec.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import router from '~/ide/ide_router';
import Item from '~/ide/components/merge_requests/item.vue';
import mountCompontent from '../../../helpers/vue_mount_component_helper';
@@ -27,6 +28,12 @@ describe('IDE merge request item', () => {
expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1');
});
+ it('renders link with href', () => {
+ const expectedHref = router.resolve(`/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`).href;
+ expect(vm.$el).toMatch('a');
+ expect(vm.$el).toHaveAttr('href', expectedHref);
+ });
+
it('renders icon if ID matches currentId', () => {
expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
});
@@ -50,12 +57,4 @@ describe('IDE merge request item', () => {
done();
});
});
-
- it('emits click event on click', () => {
- spyOn(vm, '$emit');
-
- vm.$el.click();
-
- expect(vm.$emit).toHaveBeenCalledWith('click', vm.item);
- });
});
diff --git a/spec/javascripts/ide/components/merge_requests/list_spec.js b/spec/javascripts/ide/components/merge_requests/list_spec.js
index f4b393778dc..c761315444c 100644
--- a/spec/javascripts/ide/components/merge_requests/list_spec.js
+++ b/spec/javascripts/ide/components/merge_requests/list_spec.js
@@ -10,10 +10,7 @@ describe('IDE merge requests list', () => {
let vm;
beforeEach(() => {
- vm = createComponentWithStore(Component, store, {
- type: 'created',
- emptyText: 'empty text',
- });
+ vm = createComponentWithStore(Component, store, {});
spyOn(vm, 'fetchMergeRequests');
@@ -28,13 +25,13 @@ describe('IDE merge requests list', () => {
it('calls fetch on mounted', () => {
expect(vm.fetchMergeRequests).toHaveBeenCalledWith({
- type: 'created',
search: '',
+ type: '',
});
});
it('renders loading icon', done => {
- vm.$store.state.mergeRequests.created.isLoading = true;
+ vm.$store.state.mergeRequests.isLoading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
@@ -43,10 +40,6 @@ describe('IDE merge requests list', () => {
});
});
- it('renders empty text when no merge requests exist', () => {
- expect(vm.$el.textContent).toContain('empty text');
- });
-
it('renders no search results text when search is not empty', done => {
vm.search = 'testing';
@@ -57,9 +50,29 @@ describe('IDE merge requests list', () => {
});
});
+ it('clicking on search type, sets currentSearchType and loads merge requests', done => {
+ vm.onSearchFocus();
+
+ vm.$nextTick()
+ .then(() => {
+ vm.$el.querySelector('li button').click();
+
+ return vm.$nextTick();
+ })
+ .then(() => {
+ expect(vm.currentSearchType).toEqual(vm.$options.searchTypes[0]);
+ expect(vm.fetchMergeRequests).toHaveBeenCalledWith({
+ type: vm.currentSearchType.type,
+ search: '',
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
describe('with merge requests', () => {
beforeEach(done => {
- vm.$store.state.mergeRequests.created.mergeRequests.push({
+ vm.$store.state.mergeRequests.mergeRequests.push({
...mergeRequests[0],
projectPathWithNamespace: 'gitlab-org/gitlab-ce',
});
@@ -71,35 +84,6 @@ describe('IDE merge requests list', () => {
expect(vm.$el.querySelectorAll('li').length).toBe(1);
expect(vm.$el.querySelector('li').textContent).toContain(mergeRequests[0].title);
});
-
- it('calls openMergeRequest when clicking merge request', done => {
- spyOn(vm, 'openMergeRequest');
- vm.$el.querySelector('li button').click();
-
- vm.$nextTick(() => {
- expect(vm.openMergeRequest).toHaveBeenCalledWith({
- projectPath: 'gitlab-org/gitlab-ce',
- id: 1,
- });
-
- done();
- });
- });
- });
-
- describe('focusSearch', () => {
- it('focuses search input when loading is false', done => {
- spyOn(vm.$refs.searchInput, 'focus');
-
- vm.$store.state.mergeRequests.created.isLoading = false;
- vm.focusSearch();
-
- vm.$nextTick(() => {
- expect(vm.$refs.searchInput.focus).toHaveBeenCalled();
-
- done();
- });
- });
});
describe('searchMergeRequests', () => {
@@ -123,4 +107,52 @@ describe('IDE merge requests list', () => {
expect(vm.loadMergeRequests).toHaveBeenCalled();
});
});
+
+ describe('onSearchFocus', () => {
+ it('shows search types', done => {
+ vm.$el.querySelector('input').dispatchEvent(new Event('focus'));
+
+ expect(vm.hasSearchFocus).toBe(true);
+ expect(vm.showSearchTypes).toBe(true);
+
+ vm.$nextTick()
+ .then(() => {
+ const expectedSearchTypes = vm.$options.searchTypes.map(x => x.label);
+ const renderedSearchTypes = Array.from(vm.$el.querySelectorAll('li'))
+ .map(x => x.textContent.trim());
+
+ expect(renderedSearchTypes).toEqual(expectedSearchTypes);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not show search types, if already has search value', () => {
+ vm.search = 'lorem ipsum';
+ vm.$el.querySelector('input').dispatchEvent(new Event('focus'));
+
+ expect(vm.hasSearchFocus).toBe(true);
+ expect(vm.showSearchTypes).toBe(false);
+ });
+
+ it('does not show search types, if already has a search type', () => {
+ vm.currentSearchType = {};
+ vm.$el.querySelector('input').dispatchEvent(new Event('focus'));
+
+ expect(vm.hasSearchFocus).toBe(true);
+ expect(vm.showSearchTypes).toBe(false);
+ });
+
+ it('resets hasSearchFocus when search changes', done => {
+ vm.hasSearchFocus = true;
+ vm.search = 'something else';
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.hasSearchFocus).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/ide/components/nav_dropdown_button_spec.js b/spec/javascripts/ide/components/nav_dropdown_button_spec.js
new file mode 100644
index 00000000000..0a58e260280
--- /dev/null
+++ b/spec/javascripts/ide/components/nav_dropdown_button_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue';
+import store from '~/ide/stores';
+import { trimText } from 'spec/helpers/vue_component_helper';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from '../helpers';
+
+describe('NavDropdown', () => {
+ const TEST_BRANCH_ID = 'lorem-ipsum-dolar';
+ const TEST_MR_ID = '12345';
+ const Component = Vue.extend(NavDropdownButton);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, { store });
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(store);
+ });
+
+ it('renders empty placeholders, if state is falsey', () => {
+ expect(trimText(vm.$el.textContent)).toEqual('- -');
+ });
+
+ it('renders branch name, if state has currentBranchId', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders mr id, if state has currentMergeRequestId', done => {
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders branch and mr, if state has both', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/javascripts/ide/components/nav_dropdown_spec.js b/spec/javascripts/ide/components/nav_dropdown_spec.js
new file mode 100644
index 00000000000..af6665bcd62
--- /dev/null
+++ b/spec/javascripts/ide/components/nav_dropdown_spec.js
@@ -0,0 +1,50 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import store from '~/ide/stores';
+import NavDropdown from '~/ide/components/nav_dropdown.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+
+describe('IDE NavDropdown', () => {
+ const Component = Vue.extend(NavDropdown);
+ let vm;
+ let $dropdown;
+
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, { store });
+ $dropdown = $(vm.$el);
+
+ // block dispatch from doing anything
+ spyOn(vm.$store, 'dispatch');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders nothing initially', () => {
+ expect(vm.$el).not.toContainElement('.ide-nav-form');
+ });
+
+ it('renders nav form when show.bs.dropdown', done => {
+ $dropdown.trigger('show.bs.dropdown');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el).toContainElement('.ide-nav-form');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('destroys nav form when closed', done => {
+ $dropdown.trigger('show.bs.dropdown');
+ $dropdown.trigger('hide.bs.dropdown');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el).not.toContainElement('.ide-nav-form');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/javascripts/ide/components/panes/right_spec.js b/spec/javascripts/ide/components/panes/right_spec.js
index 99879fb0930..c75975d2af6 100644
--- a/spec/javascripts/ide/components/panes/right_spec.js
+++ b/spec/javascripts/ide/components/panes/right_spec.js
@@ -69,4 +69,17 @@ describe('IDE right pane', () => {
});
});
});
+
+ describe('live preview', () => {
+ it('renders live preview button', done => {
+ Vue.set(vm.$store.state.entries, 'package.json', { name: 'package.json' });
+ vm.$store.state.clientsidePreviewEnabled = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('button[aria-label="Live preview"]')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
});
diff --git a/spec/javascripts/ide/components/preview/clientside_spec.js b/spec/javascripts/ide/components/preview/clientside_spec.js
new file mode 100644
index 00000000000..3ec65882418
--- /dev/null
+++ b/spec/javascripts/ide/components/preview/clientside_spec.js
@@ -0,0 +1,362 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import Clientside from '~/ide/components/preview/clientside.vue';
+import timeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+import { resetStore, file } from '../../helpers';
+
+describe('IDE clientside preview', () => {
+ let vm;
+ let Component;
+
+ beforeAll(() => {
+ Component = Vue.extend(Clientside);
+ });
+
+ beforeEach(done => {
+ const store = createStore();
+
+ Vue.set(store.state.entries, 'package.json', {
+ ...file('package.json'),
+ });
+ Vue.set(store.state, 'currentProjectId', 'gitlab-ce');
+ Vue.set(store.state.projects, 'gitlab-ce', {
+ visibility: 'public',
+ });
+
+ vm = createComponentWithStore(Component, store);
+
+ spyOn(vm, 'getFileData').and.returnValue(Promise.resolve());
+ spyOn(vm, 'getRawFileData').and.returnValue(Promise.resolve(''));
+ spyOn(vm, 'initManager');
+
+ vm.$mount();
+
+ timeoutPromise()
+ .then(() => vm.$nextTick())
+ .then(done)
+ .catch(done.fail);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+ });
+
+ describe('without main entry', () => {
+ it('creates sandpack manager', () => {
+ expect(vm.initManager).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with main entry', () => {
+ beforeEach(done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+
+ vm
+ .$nextTick()
+ .then(() => vm.initPreview())
+ .then(vm.$nextTick)
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('creates sandpack manager', () => {
+ expect(vm.initManager).toHaveBeenCalledWith(
+ '#ide-preview',
+ {
+ files: jasmine.any(Object),
+ entry: '/index.js',
+ showOpenInCodeSandbox: true,
+ },
+ {
+ fileResolver: {
+ isFile: jasmine.any(Function),
+ readFile: jasmine.any(Function),
+ },
+ },
+ );
+ });
+ });
+
+ describe('computed', () => {
+ describe('normalizedEntries', () => {
+ beforeEach(done => {
+ vm.$store.state.entries['index.js'] = {
+ ...file('index.js'),
+ type: 'blob',
+ raw: 'test',
+ };
+ vm.$store.state.entries['index2.js'] = {
+ ...file('index2.js'),
+ type: 'blob',
+ content: 'content',
+ };
+ vm.$store.state.entries.tree = {
+ ...file('tree'),
+ type: 'tree',
+ };
+ vm.$store.state.entries.empty = {
+ ...file('empty'),
+ type: 'blob',
+ };
+
+ vm.$nextTick(done);
+ });
+
+ it('returns flattened list of blobs with content', () => {
+ expect(vm.normalizedEntries).toEqual({
+ '/index.js': {
+ code: 'test',
+ },
+ '/index2.js': {
+ code: 'content',
+ },
+ });
+ });
+ });
+
+ describe('mainEntry', () => {
+ it('returns false when package.json is empty', () => {
+ expect(vm.mainEntry).toBe(false);
+ });
+
+ it('returns main key from package.json', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+
+ vm.$nextTick(() => {
+ expect(vm.mainEntry).toBe('index.js');
+
+ done();
+ });
+ });
+ });
+
+ describe('showPreview', () => {
+ it('returns false if no mainEntry', () => {
+ expect(vm.showPreview).toBe(false);
+ });
+
+ it('returns false if loading', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.showPreview).toBe(false);
+
+ done();
+ });
+ });
+
+ it('returns true if not loading and mainEntry exists', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.showPreview).toBe(true);
+
+ done();
+ });
+ });
+ });
+
+ describe('showEmptyState', () => {
+ it('returns true if no mainEnry exists', () => {
+ expect(vm.showEmptyState).toBe(true);
+ });
+
+ it('returns false if loading', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.showEmptyState).toBe(false);
+
+ done();
+ });
+ });
+
+ it('returns false if not loading and mainEntry exists', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.showEmptyState).toBe(false);
+
+ done();
+ });
+ });
+ });
+
+ describe('showOpenInCodeSandbox', () => {
+ it('returns true when visiblity is public', () => {
+ expect(vm.showOpenInCodeSandbox).toBe(true);
+ });
+
+ it('returns false when visiblity is private', done => {
+ vm.$store.state.projects['gitlab-ce'].visibility = 'private';
+
+ vm.$nextTick(() => {
+ expect(vm.showOpenInCodeSandbox).toBe(false);
+
+ done();
+ });
+ });
+ });
+
+ describe('sandboxOpts', () => {
+ beforeEach(done => {
+ vm.$store.state.entries['index.js'] = {
+ ...file('index.js'),
+ type: 'blob',
+ raw: 'test',
+ };
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+
+ vm.$nextTick(done);
+ });
+
+ it('returns sandbox options', () => {
+ expect(vm.sandboxOpts).toEqual({
+ files: {
+ '/index.js': {
+ code: 'test',
+ },
+ '/package.json': {
+ code: '{"main":"index.js"}',
+ },
+ },
+ entry: '/index.js',
+ showOpenInCodeSandbox: true,
+ });
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('loadFileContent', () => {
+ it('calls getFileData', () => {
+ expect(vm.getFileData).toHaveBeenCalledWith({
+ path: 'package.json',
+ makeFileActive: false,
+ });
+ });
+
+ it('calls getRawFileData', () => {
+ expect(vm.getRawFileData).toHaveBeenCalledWith({ path: 'package.json' });
+ });
+ });
+
+ describe('update', () => {
+ beforeEach(() => {
+ jasmine.clock().install();
+ vm.manager.updatePreview = jasmine.createSpy('updatePreview');
+ vm.manager.listener = jasmine.createSpy('updatePreview');
+ });
+
+ afterEach(() => {
+ jasmine.clock().uninstall();
+ });
+
+ it('calls initPreview if manager is empty', () => {
+ spyOn(vm, 'initPreview');
+ vm.manager = {};
+
+ vm.update();
+
+ jasmine.clock().tick(500);
+
+ expect(vm.initPreview).toHaveBeenCalled();
+ });
+
+ it('calls updatePreview', () => {
+ vm.update();
+
+ jasmine.clock().tick(500);
+
+ expect(vm.manager.updatePreview).toHaveBeenCalledWith(vm.sandboxOpts);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders ide-preview element when showPreview is true', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('#ide-preview')).not.toBe(null);
+ done();
+ });
+ });
+
+ it('renders empty state', done => {
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.textContent).toContain(
+ 'Preview your web application using Web IDE client-side evaluation.',
+ );
+
+ done();
+ });
+ });
+
+ it('renders loading icon', done => {
+ vm.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/preview/navigator_spec.js b/spec/javascripts/ide/components/preview/navigator_spec.js
new file mode 100644
index 00000000000..576d2fae003
--- /dev/null
+++ b/spec/javascripts/ide/components/preview/navigator_spec.js
@@ -0,0 +1,185 @@
+import Vue from 'vue';
+import ClientsideNavigator from '~/ide/components/preview/navigator.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('IDE clientside preview navigator', () => {
+ let vm;
+ let Component;
+ let manager;
+
+ beforeAll(() => {
+ Component = Vue.extend(ClientsideNavigator);
+ });
+
+ beforeEach(() => {
+ manager = {
+ bundlerURL: gl.TEST_HOST,
+ iframe: { src: '' },
+ };
+
+ vm = mountComponent(Component, {
+ manager,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders readonly URL bar', () => {
+ expect(vm.$el.querySelector('input[readonly]').value).toBe('/');
+ });
+
+ it('disables back button when navigationStack is empty', () => {
+ expect(vm.$el.querySelector('.ide-navigator-btn')).toHaveAttr('disabled');
+ expect(vm.$el.querySelector('.ide-navigator-btn').classList).toContain('disabled-content');
+ });
+
+ it('disables forward button when forwardNavigationStack is empty', () => {
+ vm.forwardNavigationStack = [];
+
+ expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1]).toHaveAttr('disabled');
+ expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1].classList).toContain(
+ 'disabled-content',
+ );
+ });
+
+ it('calls back method when clicking back button', done => {
+ vm.navigationStack.push('/test');
+ vm.navigationStack.push('/test2');
+ spyOn(vm, 'back');
+
+ vm.$nextTick(() => {
+ vm.$el.querySelector('.ide-navigator-btn').click();
+
+ expect(vm.back).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls forward method when clicking forward button', done => {
+ vm.forwardNavigationStack.push('/test');
+ spyOn(vm, 'forward');
+
+ vm.$nextTick(() => {
+ vm.$el.querySelectorAll('.ide-navigator-btn')[1].click();
+
+ expect(vm.forward).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ describe('onUrlChange', () => {
+ it('updates the path', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.path).toBe('/url');
+ });
+
+ it('sets currentBrowsingIndex 0 if not already set', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.currentBrowsingIndex).toBe(0);
+ });
+
+ it('increases currentBrowsingIndex if path doesnt match', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url2`,
+ });
+
+ expect(vm.currentBrowsingIndex).toBe(1);
+ });
+
+ it('does not increase currentBrowsingIndex if path matches', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.currentBrowsingIndex).toBe(0);
+ });
+
+ it('pushes path into navigation stack', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.navigationStack).toEqual(['/url']);
+ });
+ });
+
+ describe('back', () => {
+ beforeEach(() => {
+ vm.path = '/test2';
+ vm.currentBrowsingIndex = 1;
+ vm.navigationStack.push('/test');
+ vm.navigationStack.push('/test2');
+
+ spyOn(vm, 'visitPath');
+
+ vm.back();
+ });
+
+ it('visits the last entry in navigationStack', () => {
+ expect(vm.visitPath).toHaveBeenCalledWith('/test');
+ });
+
+ it('adds last entry to forwardNavigationStack', () => {
+ expect(vm.forwardNavigationStack).toEqual(['/test2']);
+ });
+
+ it('clears navigation stack if currentBrowsingIndex is 1', () => {
+ expect(vm.navigationStack).toEqual([]);
+ });
+
+ it('sets currentBrowsingIndex to null is currentBrowsingIndex is 1', () => {
+ expect(vm.currentBrowsingIndex).toBe(null);
+ });
+ });
+
+ describe('forward', () => {
+ it('calls visitPath with first entry in forwardNavigationStack', () => {
+ spyOn(vm, 'visitPath');
+
+ vm.forwardNavigationStack.push('/test');
+ vm.forwardNavigationStack.push('/test2');
+
+ vm.forward();
+
+ expect(vm.visitPath).toHaveBeenCalledWith('/test');
+ });
+ });
+
+ describe('refresh', () => {
+ it('calls refresh with current path', () => {
+ spyOn(vm, 'visitPath');
+
+ vm.path = '/test';
+
+ vm.refresh();
+
+ expect(vm.visitPath).toHaveBeenCalledWith('/test');
+ });
+ });
+
+ describe('visitPath', () => {
+ it('updates iframe src with passed in path', () => {
+ vm.visitPath('/testpath');
+
+ expect(manager.iframe.src).toBe(`${gl.TEST_HOST}/testpath`);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/shared/tokened_input_spec.js b/spec/javascripts/ide/components/shared/tokened_input_spec.js
new file mode 100644
index 00000000000..09940fe8c6a
--- /dev/null
+++ b/spec/javascripts/ide/components/shared/tokened_input_spec.js
@@ -0,0 +1,132 @@
+import Vue from 'vue';
+import TokenedInput from '~/ide/components/shared/tokened_input.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const TEST_PLACEHOLDER = 'Searching in test';
+const TEST_TOKENS = [
+ { label: 'lorem', id: 1 },
+ { label: 'ipsum', id: 2 },
+ { label: 'dolar', id: 3 },
+];
+const TEST_VALUE = 'lorem';
+
+function getTokenElements(vm) {
+ return Array.from(vm.$el.querySelectorAll('.filtered-search-token button'));
+}
+
+function createBackspaceEvent() {
+ const e = new Event('keyup');
+ e.keyCode = 8;
+ e.which = e.keyCode;
+ e.altKey = false;
+ e.ctrlKey = true;
+ e.shiftKey = false;
+ e.metaKey = false;
+ return e;
+}
+
+describe('IDE shared/TokenedInput', () => {
+ const Component = Vue.extend(TokenedInput);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ tokens: TEST_TOKENS,
+ placeholder: TEST_PLACEHOLDER,
+ value: TEST_VALUE,
+ });
+
+ spyOn(vm, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders tokens', () => {
+ const renderedTokens = getTokenElements(vm)
+ .map(x => x.textContent.trim());
+
+ expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label));
+ });
+
+ it('renders input', () => {
+ expect(vm.$refs.input).toBeTruthy();
+ expect(vm.$refs.input).toHaveValue(TEST_VALUE);
+ });
+
+ it('renders placeholder, when tokens are empty', done => {
+ vm.tokens = [];
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('triggers "removeToken" on token click', () => {
+ getTokenElements(vm)[0].click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]);
+ });
+
+ it('when input triggers backspace event, it calls "onBackspace"', () => {
+ spyOn(vm, 'onBackspace');
+
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+
+ expect(vm.onBackspace).toHaveBeenCalledTimes(2);
+ });
+
+ it('triggers "removeToken" on backspaces when value is empty', () => {
+ vm.value = '';
+
+ vm.onBackspace();
+ expect(vm.$emit).not.toHaveBeenCalled();
+ expect(vm.backspaceCount).toEqual(1);
+
+ vm.onBackspace();
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]);
+ expect(vm.backspaceCount).toEqual(0);
+ });
+
+ it('does not trigger "removeToken" on backspaces when value is not empty', () => {
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('does not trigger "removeToken" on backspaces when tokens are empty', () => {
+ vm.tokens = [];
+
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('triggers "focus" on input focus', () => {
+ vm.$refs.input.dispatchEvent(new Event('focus'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('focus');
+ });
+
+ it('triggers "blur" on input blur', () => {
+ vm.$refs.input.dispatchEvent(new Event('blur'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('blur');
+ });
+
+ it('triggers "input" with value on input change', () => {
+ vm.$refs.input.value = 'something-else';
+ vm.$refs.input.dispatchEvent(new Event('input'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else');
+ });
+});
diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js
index 569fa5c7aae..c11c482fef8 100644
--- a/spec/javascripts/ide/helpers.js
+++ b/spec/javascripts/ide/helpers.js
@@ -4,6 +4,7 @@ import state from '~/ide/stores/state';
import commitState from '~/ide/stores/modules/commit/state';
import mergeRequestsState from '~/ide/stores/modules/merge_requests/state';
import pipelinesState from '~/ide/stores/modules/pipelines/state';
+import branchesState from '~/ide/stores/modules/branches/state';
export const resetStore = store => {
const newState = {
@@ -11,6 +12,7 @@ export const resetStore = store => {
commit: commitState(),
mergeRequests: mergeRequestsState(),
pipelines: pipelinesState(),
+ branches: branchesState(),
};
store.replaceState(newState);
};
diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js
index 7be450a0df7..4fe826943b2 100644
--- a/spec/javascripts/ide/mock_data.js
+++ b/spec/javascripts/ide/mock_data.js
@@ -165,3 +165,33 @@ export const mergeRequests = [
web_url: `${gl.TEST_HOST}/namespace/project-path/merge_requests/1`,
},
];
+
+export const branches = [
+ {
+ id: 1,
+ name: 'master',
+ commit: {
+ message: 'Update master branch',
+ committed_date: '2018-08-01T00:20:05Z',
+ },
+ can_push: true,
+ },
+ {
+ id: 2,
+ name: 'feature/lorem-ipsum',
+ commit: {
+ message: 'Update some stuff',
+ committed_date: '2018-08-02T00:00:05Z',
+ },
+ can_push: true,
+ },
+ {
+ id: 3,
+ name: 'feature/dolar-amit',
+ commit: {
+ message: 'Update some more stuff',
+ committed_date: '2018-06-30T00:20:05Z',
+ },
+ can_push: true,
+ },
+];
diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js
index 70883e16b0d..9c135661997 100644
--- a/spec/javascripts/ide/stores/getters_spec.js
+++ b/spec/javascripts/ide/stores/getters_spec.js
@@ -179,4 +179,14 @@ describe('IDE store getters', () => {
});
});
});
+
+ describe('packageJson', () => {
+ it('returns package.json entry', () => {
+ localState.entries['package.json'] = { name: 'package.json' };
+
+ expect(getters.packageJson(localState)).toEqual({
+ name: 'package.json',
+ });
+ });
+ });
});
diff --git a/spec/javascripts/ide/stores/modules/branches/actions_spec.js b/spec/javascripts/ide/stores/modules/branches/actions_spec.js
new file mode 100644
index 00000000000..a0fce578958
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/branches/actions_spec.js
@@ -0,0 +1,193 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import state from '~/ide/stores/modules/branches/state';
+import * as types from '~/ide/stores/modules/branches/mutation_types';
+import testAction from 'spec/helpers/vuex_action_helper';
+import {
+ requestBranches,
+ receiveBranchesError,
+ receiveBranchesSuccess,
+ fetchBranches,
+ resetBranches,
+ openBranch,
+} from '~/ide/stores/modules/branches/actions';
+import { branches, projectData } from '../../../mock_data';
+
+describe('IDE branches actions', () => {
+ const TEST_SEARCH = 'foosearch';
+ let mockedContext;
+ let mockedState;
+ let mock;
+
+ beforeEach(() => {
+ mockedContext = {
+ dispatch() {},
+ rootState: {
+ currentProjectId: projectData.name_with_namespace,
+ },
+ rootGetters: {
+ currentProject: projectData,
+ },
+ state: state(),
+ };
+
+ // testAction looks for rootGetters in state,
+ // so they need to be concatenated here.
+ mockedState = {
+ ...mockedContext.state,
+ ...mockedContext.rootGetters,
+ ...mockedContext.rootState,
+ };
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('requestBranches', () => {
+ it('should commit request', done => {
+ testAction(
+ requestBranches,
+ null,
+ mockedContext.state,
+ [{ type: types.REQUEST_BRANCHES }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveBranchesError', () => {
+ it('should should commit error', done => {
+
+ testAction(
+ receiveBranchesError,
+ { search: TEST_SEARCH },
+ mockedContext.state,
+ [{ type: types.RECEIVE_BRANCHES_ERROR }],
+ [
+ {
+ type: 'setErrorMessage',
+ payload: {
+ text: 'Error loading branches.',
+ action: jasmine.any(Function),
+ actionText: 'Please try again',
+ actionPayload: { search: TEST_SEARCH },
+ },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('receiveBranchesSuccess', () => {
+ it('should commit received data', done => {
+ testAction(
+ receiveBranchesSuccess,
+ branches,
+ mockedContext.state,
+ [{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: branches }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchBranches', () => {
+ beforeEach(() => {
+ gon.api_version = 'v4';
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(200, branches);
+ });
+
+ it('calls API with params', () => {
+ const apiSpy = spyOn(axios, 'get').and.callThrough();
+
+ fetchBranches(mockedContext, { search: TEST_SEARCH });
+
+ expect(apiSpy).toHaveBeenCalledWith(jasmine.anything(), {
+ params: jasmine.objectContaining({
+ search: TEST_SEARCH,
+ sort: 'updated_desc',
+ }),
+ });
+ });
+
+ it('dispatches success with received data', done => {
+ testAction(
+ fetchBranches,
+ { search: TEST_SEARCH },
+ mockedState,
+ [],
+ [
+ { type: 'requestBranches' },
+ { type: 'resetBranches' },
+ {
+ type: 'receiveBranchesSuccess',
+ payload: branches,
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(500);
+ });
+
+ it('dispatches error', done => {
+ testAction(
+ fetchBranches,
+ { search: TEST_SEARCH },
+ mockedState,
+ [],
+ [
+ { type: 'requestBranches' },
+ { type: 'resetBranches' },
+ {
+ type: 'receiveBranchesError',
+ payload: { search: TEST_SEARCH },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('resetBranches', () => {
+ it('commits reset', done => {
+ testAction(
+ resetBranches,
+ null,
+ mockedContext.state,
+ [{ type: types.RESET_BRANCHES }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('openBranch', () => {
+ it('dispatches goToRoute action with path', done => {
+ const branchId = branches[0].name;
+ const expectedPath = `/project/${projectData.name_with_namespace}/edit/${branchId}`;
+ testAction(
+ openBranch,
+ branchId,
+ mockedState,
+ [],
+ [{ type: 'goToRoute', payload: expectedPath }],
+ done,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/branches/mutations_spec.js b/spec/javascripts/ide/stores/modules/branches/mutations_spec.js
new file mode 100644
index 00000000000..be91440f119
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/branches/mutations_spec.js
@@ -0,0 +1,51 @@
+import state from '~/ide/stores/modules/branches/state';
+import mutations from '~/ide/stores/modules/branches/mutations';
+import * as types from '~/ide/stores/modules/branches/mutation_types';
+import { branches } from '../../../mock_data';
+
+describe('IDE branches mutations', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe(types.REQUEST_BRANCHES, () => {
+ it('sets loading to true', () => {
+ mutations[types.REQUEST_BRANCHES](mockedState);
+
+ expect(mockedState.isLoading).toBe(true);
+ });
+ });
+
+ describe(types.RECEIVE_BRANCHES_ERROR, () => {
+ it('sets loading to false', () => {
+ mutations[types.RECEIVE_BRANCHES_ERROR](mockedState);
+
+ expect(mockedState.isLoading).toBe(false);
+ });
+ });
+
+ describe(types.RECEIVE_BRANCHES_SUCCESS, () => {
+ it('sets branches', () => {
+ const expectedBranches = branches.map(branch => ({
+ name: branch.name,
+ committedDate: branch.commit.committed_date,
+ }));
+
+ mutations[types.RECEIVE_BRANCHES_SUCCESS](mockedState, branches);
+
+ expect(mockedState.branches).toEqual(expectedBranches);
+ });
+ });
+
+ describe(types.RESET_BRANCHES, () => {
+ it('clears branches array', () => {
+ mockedState.branches = ['test'];
+
+ mutations[types.RESET_BRANCHES](mockedState);
+
+ expect(mockedState.branches).toEqual([]);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js b/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js
index d063f1ea860..62699143a91 100644
--- a/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js
+++ b/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js
@@ -8,9 +8,7 @@ import {
receiveMergeRequestsSuccess,
fetchMergeRequests,
resetMergeRequests,
- openMergeRequest,
} from '~/ide/stores/modules/merge_requests/actions';
-import router from '~/ide/ide_router';
import { mergeRequests } from '../../../mock_data';
import testAction from '../../../../helpers/vuex_action_helper';
@@ -28,12 +26,12 @@ describe('IDE merge requests actions', () => {
});
describe('requestMergeRequests', () => {
- it('should should commit request', done => {
+ it('should commit request', done => {
testAction(
requestMergeRequests,
- 'created',
+ null,
mockedState,
- [{ type: types.REQUEST_MERGE_REQUESTS, payload: 'created' }],
+ [{ type: types.REQUEST_MERGE_REQUESTS }],
[],
done,
);
@@ -46,7 +44,7 @@ describe('IDE merge requests actions', () => {
receiveMergeRequestsError,
{ type: 'created', search: '' },
mockedState,
- [{ type: types.RECEIVE_MERGE_REQUESTS_ERROR, payload: 'created' }],
+ [{ type: types.RECEIVE_MERGE_REQUESTS_ERROR }],
[
{
type: 'setErrorMessage',
@@ -67,12 +65,12 @@ describe('IDE merge requests actions', () => {
it('should commit received data', done => {
testAction(
receiveMergeRequestsSuccess,
- { type: 'created', data: 'data' },
+ mergeRequests,
mockedState,
[
{
type: types.RECEIVE_MERGE_REQUESTS_SUCCESS,
- payload: { type: 'created', data: 'data' },
+ payload: mergeRequests,
},
],
[],
@@ -129,11 +127,11 @@ describe('IDE merge requests actions', () => {
mockedState,
[],
[
- { type: 'requestMergeRequests', payload: 'created' },
- { type: 'resetMergeRequests', payload: 'created' },
+ { type: 'requestMergeRequests' },
+ { type: 'resetMergeRequests' },
{
type: 'receiveMergeRequestsSuccess',
- payload: { type: 'created', data: mergeRequests },
+ payload: mergeRequests,
},
],
done,
@@ -149,12 +147,12 @@ describe('IDE merge requests actions', () => {
it('dispatches error', done => {
testAction(
fetchMergeRequests,
- { type: 'created' },
+ { type: 'created', search: '' },
mockedState,
[],
[
- { type: 'requestMergeRequests', payload: 'created' },
- { type: 'resetMergeRequests', payload: 'created' },
+ { type: 'requestMergeRequests' },
+ { type: 'resetMergeRequests' },
{ type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } },
],
done,
@@ -167,59 +165,12 @@ describe('IDE merge requests actions', () => {
it('commits reset', done => {
testAction(
resetMergeRequests,
- 'created',
+ null,
mockedState,
- [{ type: types.RESET_MERGE_REQUESTS, payload: 'created' }],
+ [{ type: types.RESET_MERGE_REQUESTS }],
[],
done,
);
});
});
-
- describe('openMergeRequest', () => {
- beforeEach(() => {
- spyOn(router, 'push');
- });
-
- it('commits reset mutations and actions', done => {
- const commit = jasmine.createSpy();
- const dispatch = jasmine.createSpy().and.returnValue(Promise.resolve());
- openMergeRequest({ commit, dispatch }, { projectPath: 'gitlab-org/gitlab-ce', id: '1' });
-
- setTimeout(() => {
- expect(commit.calls.argsFor(0)).toEqual(['CLEAR_PROJECTS', null, { root: true }]);
- expect(commit.calls.argsFor(1)).toEqual(['SET_CURRENT_MERGE_REQUEST', '1', { root: true }]);
- expect(commit.calls.argsFor(2)).toEqual(['RESET_OPEN_FILES', null, { root: true }]);
-
- expect(dispatch.calls.argsFor(0)).toEqual(['setCurrentBranchId', '', { root: true }]);
- expect(dispatch.calls.argsFor(1)).toEqual([
- 'pipelines/stopPipelinePolling',
- null,
- { root: true },
- ]);
- expect(dispatch.calls.argsFor(2)).toEqual(['setRightPane', null, { root: true }]);
- expect(dispatch.calls.argsFor(3)).toEqual([
- 'pipelines/resetLatestPipeline',
- null,
- { root: true },
- ]);
- expect(dispatch.calls.argsFor(4)).toEqual([
- 'pipelines/clearEtagPoll',
- null,
- { root: true },
- ]);
-
- done();
- });
- });
-
- it('pushes new route', () => {
- openMergeRequest(
- { commit() {}, dispatch: () => Promise.resolve() },
- { projectPath: 'gitlab-org/gitlab-ce', id: '1' },
- );
-
- expect(router.push).toHaveBeenCalledWith('/project/gitlab-org/gitlab-ce/merge_requests/1');
- });
- });
});
diff --git a/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js b/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js
index ea03131d90d..664d3914564 100644
--- a/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js
+++ b/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js
@@ -12,29 +12,26 @@ describe('IDE merge requests mutations', () => {
describe(types.REQUEST_MERGE_REQUESTS, () => {
it('sets loading to true', () => {
- mutations[types.REQUEST_MERGE_REQUESTS](mockedState, 'created');
+ mutations[types.REQUEST_MERGE_REQUESTS](mockedState);
- expect(mockedState.created.isLoading).toBe(true);
+ expect(mockedState.isLoading).toBe(true);
});
});
describe(types.RECEIVE_MERGE_REQUESTS_ERROR, () => {
it('sets loading to false', () => {
- mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](mockedState, 'created');
+ mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](mockedState);
- expect(mockedState.created.isLoading).toBe(false);
+ expect(mockedState.isLoading).toBe(false);
});
});
describe(types.RECEIVE_MERGE_REQUESTS_SUCCESS, () => {
it('sets merge requests', () => {
gon.gitlab_url = gl.TEST_HOST;
- mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, {
- type: 'created',
- data: mergeRequests,
- });
+ mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, mergeRequests);
- expect(mockedState.created.mergeRequests).toEqual([
+ expect(mockedState.mergeRequests).toEqual([
{
id: 1,
iid: 1,
@@ -50,9 +47,9 @@ describe('IDE merge requests mutations', () => {
it('clears merge request array', () => {
mockedState.mergeRequests = ['test'];
- mutations[types.RESET_MERGE_REQUESTS](mockedState, 'created');
+ mutations[types.RESET_MERGE_REQUESTS](mockedState);
- expect(mockedState.created.mergeRequests).toEqual([]);
+ expect(mockedState.mergeRequests).toEqual([]);
});
});
});
diff --git a/spec/javascripts/notes/components/discussion_counter_spec.js b/spec/javascripts/notes/components/discussion_counter_spec.js
index a3869cc6498..d09bc5037ef 100644
--- a/spec/javascripts/notes/components/discussion_counter_spec.js
+++ b/spec/javascripts/notes/components/discussion_counter_spec.js
@@ -46,7 +46,7 @@ describe('DiscussionCounter component', () => {
discussions,
});
setFixtures(`
- <div data-discussion-id="${firstDiscussionId}"></div>
+ <div class="discussion" data-discussion-id="${firstDiscussionId}"></div>
`);
vm.jumpToFirstUnresolvedDiscussion();
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index 7da931fd9cb..2a01bd85520 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -14,6 +14,7 @@ describe('noteable_discussion component', () => {
preloadFixtures(discussionWithTwoUnresolvedNotes);
beforeEach(() => {
+ window.mrTabs = {};
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
@@ -46,10 +47,15 @@ describe('noteable_discussion component', () => {
it('should toggle reply form', done => {
vm.$el.querySelector('.js-vue-discussion-reply').click();
+
Vue.nextTick(() => {
- expect(vm.$refs.noteForm).not.toBeNull();
expect(vm.isReplying).toEqual(true);
- done();
+
+ // There is a watcher for `isReplying` which will init autosave in the next tick
+ Vue.nextTick(() => {
+ expect(vm.$refs.noteForm).not.toBeNull();
+ done();
+ });
});
});
@@ -101,33 +107,29 @@ describe('noteable_discussion component', () => {
describe('methods', () => {
describe('jumpToNextDiscussion', () => {
- it('expands next unresolved discussion', () => {
- spyOn(vm, 'expandDiscussion').and.stub();
- const discussions = [
- discussionMock,
- {
- ...discussionMock,
- id: discussionMock.id + 1,
- notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }],
- },
- {
- ...discussionMock,
- id: discussionMock.id + 2,
- notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }],
- },
- ];
- const nextDiscussionId = discussionMock.id + 2;
- store.replaceState({
- ...store.state,
- discussions,
- });
- setFixtures(`
- <div data-discussion-id="${nextDiscussionId}"></div>
- `);
+ it('expands next unresolved discussion', done => {
+ const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
+ discussion2.resolved = false;
+ discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to)
+ vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]);
+ window.mrTabs.currentAction = 'show';
+
+ Vue.nextTick()
+ .then(() => {
+ spyOn(vm, 'expandDiscussion').and.stub();
+
+ const nextDiscussionId = discussion2.id;
+
+ setFixtures(`
+ <div class="discussion" data-discussion-id="${nextDiscussionId}"></div>
+ `);
- vm.jumpToNextDiscussion();
+ vm.jumpToNextDiscussion();
- expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId });
+ expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId });
+ })
+ .then(done)
+ .catch(done.fail);
});
});
});
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index be2a8ba67fe..67f6a9629d9 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -1168,3 +1168,87 @@ export const collapsedSystemNotes = [
diff_discussion: false,
},
];
+
+export const discussion1 = {
+ id: 'abc1',
+ resolvable: true,
+ resolved: false,
+ diff_file: {
+ file_path: 'about.md',
+ },
+ position: {
+ formatter: {
+ new_line: 50,
+ old_line: null,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T16:25:41.749Z',
+ },
+ ],
+};
+
+export const resolvedDiscussion1 = {
+ id: 'abc1',
+ resolvable: true,
+ resolved: true,
+ diff_file: {
+ file_path: 'about.md',
+ },
+ position: {
+ formatter: {
+ new_line: 50,
+ old_line: null,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T16:25:41.749Z',
+ },
+ ],
+};
+
+export const discussion2 = {
+ id: 'abc2',
+ resolvable: true,
+ resolved: false,
+ diff_file: {
+ file_path: 'README.md',
+ },
+ position: {
+ formatter: {
+ new_line: null,
+ old_line: 20,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T12:05:41.749Z',
+ },
+ ],
+};
+
+export const discussion3 = {
+ id: 'abc3',
+ resolvable: true,
+ resolved: false,
+ diff_file: {
+ file_path: 'README.md',
+ },
+ position: {
+ formatter: {
+ new_line: 21,
+ old_line: null,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-05T17:25:41.749Z',
+ },
+ ],
+};
+
+export const unresolvableDiscussion = {
+ resolvable: false,
+};
diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js
index 41599e00122..7f8ede51508 100644
--- a/spec/javascripts/notes/stores/getters_spec.js
+++ b/spec/javascripts/notes/stores/getters_spec.js
@@ -5,6 +5,11 @@ import {
noteableDataMock,
individualNote,
collapseNotesMock,
+ discussion1,
+ discussion2,
+ discussion3,
+ resolvedDiscussion1,
+ unresolvableDiscussion,
} from '../mock_data';
const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json';
@@ -109,4 +114,154 @@ describe('Getters Notes Store', () => {
expect(getters.isNotesFetched(state)).toBeFalsy();
});
});
+
+ describe('allResolvableDiscussions', () => {
+ it('should return only resolvable discussions in same order', () => {
+ const localGetters = {
+ allDiscussions: [
+ discussion3,
+ unresolvableDiscussion,
+ discussion1,
+ unresolvableDiscussion,
+ discussion2,
+ ],
+ };
+
+ expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([
+ discussion3,
+ discussion1,
+ discussion2,
+ ]);
+ });
+
+ it('should return empty array if there are no resolvable discussions', () => {
+ const localGetters = {
+ allDiscussions: [unresolvableDiscussion, unresolvableDiscussion],
+ };
+
+ expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([]);
+ });
+ });
+
+ describe('unresolvedDiscussionsIdsByDiff', () => {
+ it('should return all discussions IDs in diff order', () => {
+ const localGetters = {
+ allResolvableDiscussions: [discussion3, discussion1, discussion2],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([
+ 'abc1',
+ 'abc2',
+ 'abc3',
+ ]);
+ });
+
+ it('should return empty array if all discussions have been resolved', () => {
+ const localGetters = {
+ allResolvableDiscussions: [resolvedDiscussion1],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([]);
+ });
+ });
+
+ describe('unresolvedDiscussionsIdsByDate', () => {
+ it('should return all discussions in date ascending order', () => {
+ const localGetters = {
+ allResolvableDiscussions: [discussion3, discussion1, discussion2],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([
+ 'abc2',
+ 'abc1',
+ 'abc3',
+ ]);
+ });
+
+ it('should return empty array if all discussions have been resolved', () => {
+ const localGetters = {
+ allResolvableDiscussions: [resolvedDiscussion1],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([]);
+ });
+ });
+
+ describe('unresolvedDiscussionsIdsOrdered', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsByDate: ['123', '456'],
+ unresolvedDiscussionsIdsByDiff: ['abc', 'def'],
+ };
+
+ it('should return IDs ordered by diff when diffOrder param is true', () => {
+ expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(true)).toEqual([
+ 'abc',
+ 'def',
+ ]);
+ });
+
+ it('should return IDs ordered by date when diffOrder param is not true', () => {
+ expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(false)).toEqual([
+ '123',
+ '456',
+ ]);
+ expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(undefined)).toEqual([
+ '123',
+ '456',
+ ]);
+ });
+ });
+
+ describe('isLastUnresolvedDiscussion', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'],
+ };
+
+ it('should return true if the discussion id provided is the last', () => {
+ expect(getters.isLastUnresolvedDiscussion(state, localGetters)('789')).toBe(true);
+ });
+
+ it('should return false if the discussion id provided is not the last', () => {
+ expect(getters.isLastUnresolvedDiscussion(state, localGetters)('123')).toBe(false);
+ expect(getters.isLastUnresolvedDiscussion(state, localGetters)('456')).toBe(false);
+ });
+ });
+
+ describe('nextUnresolvedDiscussionId', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'],
+ };
+
+ it('should return the ID of the discussion after the ID provided', () => {
+ expect(getters.nextUnresolvedDiscussionId(state, localGetters)('123')).toBe('456');
+ expect(getters.nextUnresolvedDiscussionId(state, localGetters)('456')).toBe('789');
+ expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe(undefined);
+ });
+ });
+
+ describe('firstUnresolvedDiscussionId', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsByDate: ['123', '456'],
+ unresolvedDiscussionsIdsByDiff: ['abc', 'def'],
+ };
+
+ it('should return the first discussion id by diff when diffOrder param is true', () => {
+ expect(getters.firstUnresolvedDiscussionId(state, localGetters)(true)).toBe('abc');
+ });
+
+ it('should return the first discussion id by date when diffOrder param is not true', () => {
+ expect(getters.firstUnresolvedDiscussionId(state, localGetters)(false)).toBe('123');
+ expect(getters.firstUnresolvedDiscussionId(state, localGetters)(undefined)).toBe('123');
+ });
+
+ it('should be falsy if all discussions are resolved', () => {
+ const localGettersFalsy = {
+ unresolvedDiscussionsIdsByDiff: [],
+ unresolvedDiscussionsIdsByDate: [],
+ };
+
+ expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(true)).toBeFalsy();
+ expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(false)).toBeFalsy();
+ });
+ });
});
diff --git a/spec/javascripts/pages/profiles/show/emoji_menu_spec.js b/spec/javascripts/pages/profiles/show/emoji_menu_spec.js
new file mode 100644
index 00000000000..b70368fc92f
--- /dev/null
+++ b/spec/javascripts/pages/profiles/show/emoji_menu_spec.js
@@ -0,0 +1,117 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import EmojiMenu from '~/pages/profiles/show/emoji_menu';
+import { TEST_HOST } from 'spec/test_constants';
+
+describe('EmojiMenu', () => {
+ const dummyEmojiTag = '<dummy></tag>';
+ const dummyToggleButtonSelector = '.toggle-button-selector';
+ const dummyMenuClass = 'dummy-menu-class';
+
+ let emojiMenu;
+ let dummySelectEmojiCallback;
+ let dummyEmojiList;
+
+ beforeEach(() => {
+ dummySelectEmojiCallback = jasmine.createSpy('dummySelectEmojiCallback');
+ dummyEmojiList = {
+ glEmojiTag() {
+ return dummyEmojiTag;
+ },
+ normalizeEmojiName(emoji) {
+ return emoji;
+ },
+ isEmojiNameValid() {
+ return true;
+ },
+ getEmojiCategoryMap() {
+ return { dummyCategory: [] };
+ },
+ };
+
+ emojiMenu = new EmojiMenu(
+ dummyEmojiList,
+ dummyToggleButtonSelector,
+ dummyMenuClass,
+ dummySelectEmojiCallback,
+ );
+ });
+
+ afterEach(() => {
+ emojiMenu.destroy();
+ });
+
+ describe('addAward', () => {
+ const dummyAwardUrl = `${TEST_HOST}/award/url`;
+ const dummyEmoji = 'tropical_fish';
+ const dummyVotesBlock = () => $('<div />');
+
+ it('calls selectEmojiCallback', done => {
+ expect(dummySelectEmojiCallback).not.toHaveBeenCalled();
+
+ emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => {
+ expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag);
+ done();
+ });
+ });
+
+ it('does not make an axios requst', done => {
+ spyOn(axios, 'request').and.stub();
+
+ emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => {
+ expect(axios.request).not.toHaveBeenCalled();
+ done();
+ });
+ });
+ });
+
+ describe('bindEvents', () => {
+ beforeEach(() => {
+ spyOn(emojiMenu, 'registerEventListener').and.stub();
+ });
+
+ it('binds event listeners to custom toggle button', () => {
+ emojiMenu.bindEvents();
+
+ expect(emojiMenu.registerEventListener).toHaveBeenCalledWith(
+ 'one',
+ jasmine.anything(),
+ 'mouseenter focus',
+ dummyToggleButtonSelector,
+ 'mouseenter focus',
+ jasmine.anything(),
+ );
+ expect(emojiMenu.registerEventListener).toHaveBeenCalledWith(
+ 'on',
+ jasmine.anything(),
+ 'click',
+ dummyToggleButtonSelector,
+ jasmine.anything(),
+ );
+ });
+
+ it('binds event listeners to custom menu class', () => {
+ emojiMenu.bindEvents();
+
+ expect(emojiMenu.registerEventListener).toHaveBeenCalledWith(
+ 'on',
+ jasmine.anything(),
+ 'click',
+ `.js-awards-block .js-emoji-btn, .${dummyMenuClass} .js-emoji-btn`,
+ jasmine.anything(),
+ );
+ });
+ });
+
+ describe('createEmojiMenu', () => {
+ it('renders the menu with custom menu class', () => {
+ const menuElement = () =>
+ document.body.querySelector(`.emoji-menu.${dummyMenuClass} .emoji-menu-content`);
+ expect(menuElement()).toBe(null);
+
+ emojiMenu.createEmojiMenu();
+
+ expect(menuElement()).not.toBe(null);
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js
index 4a4f2259d23..ddd580ae8b7 100644
--- a/spec/javascripts/pipelines/pipeline_url_spec.js
+++ b/spec/javascripts/pipelines/pipeline_url_spec.js
@@ -35,7 +35,9 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
- expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo');
+ expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual(
+ 'foo',
+ );
expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1');
});
@@ -61,11 +63,11 @@ describe('Pipeline Url Component', () => {
const image = component.$el.querySelector('.js-pipeline-url-user img');
- expect(
- component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
- ).toEqual(mockData.pipeline.user.web_url);
+ expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual(
+ mockData.pipeline.user.web_url,
+ );
expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
- expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
+ expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`);
});
it('should render "API" when no user is provided', () => {
@@ -100,7 +102,9 @@ describe('Pipeline Url Component', () => {
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-latest').textContent).toContain('latest');
- expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid');
+ expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain(
+ 'yaml invalid',
+ );
expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
});
@@ -121,9 +125,9 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
- expect(
- component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim(),
- ).toEqual('Auto DevOps');
+ expect(component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim()).toEqual(
+ 'Auto DevOps',
+ );
});
it('should render error badge when pipeline has a failure reason set', () => {
@@ -142,6 +146,8 @@ describe('Pipeline Url Component', () => {
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-failure').textContent).toContain('error');
- expect(component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title')).toContain('some reason');
+ expect(
+ component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title'),
+ ).toContain('some reason');
});
});
diff --git a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js
index d86e565036c..333cefe5f8a 100644
--- a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js
+++ b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js
@@ -7,6 +7,7 @@ import mountComponent from '../../helpers/vue_mount_component_helper';
import newFailedTestReports from '../mock_data/new_failures_report.json';
import successTestReports from '../mock_data/no_failures_report.json';
import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
+import resolvedFailures from '../mock_data/resolved_failures.json';
describe('Grouped Test Reports App', () => {
let vm;
@@ -123,6 +124,41 @@ describe('Grouped Test Reports App', () => {
});
});
+ describe('with resolved failures', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, resolvedFailures, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders summary text', done => {
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 2 fixed test results out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found 2 fixed test results out of 8 total tests',
+ );
+ done();
+ }, 0);
+ });
+
+ it('renders resolved failures', done => {
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain(
+ resolvedFailures.suites[0].resolved_failures[0].name,
+ );
+ expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain(
+ resolvedFailures.suites[0].resolved_failures[1].name,
+ );
+ done();
+ }, 0);
+ });
+ });
+
describe('with error', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(500, {}, {});
diff --git a/spec/javascripts/reports/mock_data/resolved_failures.json b/spec/javascripts/reports/mock_data/resolved_failures.json
new file mode 100644
index 00000000000..d1f347ce5e6
--- /dev/null
+++ b/spec/javascripts/reports/mock_data/resolved_failures.json
@@ -0,0 +1,37 @@
+{
+ "status": "success",
+ "summary": { "total": 11, "resolved": 2, "failed": 0 },
+ "suites": [
+ {
+ "name": "rspec:pg",
+ "status": "success",
+ "summary": { "total": 8, "resolved": 2, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [
+ {
+ "status": "success",
+ "name": "Test#sum when a is 1 and b is 2 returns summary",
+ "execution_time": 0.000411,
+ "system_output": null,
+ "stack_trace": null
+ },
+ {
+ "status": "success",
+ "name": "Test#sum when a is 100 and b is 200 returns summary",
+ "execution_time": 7.6e-5,
+ "system_output": null,
+ "stack_trace": null
+ }
+ ],
+ "existing_failures": []
+ },
+ {
+ "name": "java ant",
+ "status": "success",
+ "summary": { "total": 3, "resolved": 0, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": []
+ }
+ ]
+}
diff --git a/spec/javascripts/reports/store/mutations_spec.js b/spec/javascripts/reports/store/mutations_spec.js
index 8f99d2675a5..7d19b16efb9 100644
--- a/spec/javascripts/reports/store/mutations_spec.js
+++ b/spec/javascripts/reports/store/mutations_spec.js
@@ -72,6 +72,10 @@ describe('Reports Store Mutations', () => {
expect(stateCopy.isLoading).toEqual(false);
});
+ it('should reset hasError', () => {
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
it('should set summary counts', () => {
expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total);
expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved);
diff --git a/spec/javascripts/sidebar/todo_spec.js b/spec/javascripts/sidebar/todo_spec.js
new file mode 100644
index 00000000000..a929b804a29
--- /dev/null
+++ b/spec/javascripts/sidebar/todo_spec.js
@@ -0,0 +1,158 @@
+import Vue from 'vue';
+
+import SidebarTodos from '~/sidebar/components/todo_toggle/todo.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = ({
+ issuableId = 1,
+ issuableType = 'epic',
+ isTodo,
+ isActionActive,
+ collapsed,
+}) => {
+ const Component = Vue.extend(SidebarTodos);
+
+ return mountComponent(Component, {
+ issuableId,
+ issuableType,
+ isTodo,
+ isActionActive,
+ collapsed,
+ });
+};
+
+describe('SidebarTodo', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent({});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('buttonClasses', () => {
+ it('returns todo button classes for when `collapsed` prop is `false`', () => {
+ expect(vm.buttonClasses).toBe('btn btn-default btn-todo issuable-header-btn float-right');
+ });
+
+ it('returns todo button classes for when `collapsed` prop is `true`', done => {
+ vm.collapsed = true;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.buttonClasses).toBe('btn-blank btn-todo sidebar-collapsed-icon dont-change-state');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('buttonLabel', () => {
+ it('returns todo button text for marking todo as done when `isTodo` prop is `true`', () => {
+ expect(vm.buttonLabel).toBe('Mark todo as done');
+ });
+
+ it('returns todo button text for add todo when `isTodo` prop is `false`', done => {
+ vm.isTodo = false;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.buttonLabel).toBe('Add todo');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('collapsedButtonIconClasses', () => {
+ it('returns collapsed button icon class when `isTodo` prop is `true`', () => {
+ expect(vm.collapsedButtonIconClasses).toBe('todo-undone');
+ });
+
+ it('returns empty string when `isTodo` prop is `false`', done => {
+ vm.isTodo = false;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.collapsedButtonIconClasses).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('collapsedButtonIcon', () => {
+ it('returns button icon name when `isTodo` prop is `true`', () => {
+ expect(vm.collapsedButtonIcon).toBe('todo-done');
+ });
+
+ it('returns button icon name when `isTodo` prop is `false`', done => {
+ vm.isTodo = false;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.collapsedButtonIcon).toBe('todo-add');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it('emits `toggleTodo` event on component', () => {
+ spyOn(vm, '$emit');
+ vm.handleButtonClick();
+ expect(vm.$emit).toHaveBeenCalledWith('toggleTodo');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element', () => {
+ const dataAttributes = {
+ issuableId: '1',
+ issuableType: 'epic',
+ originalTitle: 'Mark todo as done',
+ placement: 'left',
+ container: 'body',
+ boundary: 'viewport',
+ };
+ expect(vm.$el.nodeName).toBe('BUTTON');
+
+ const elDataAttrs = vm.$el.dataset;
+ Object.keys(elDataAttrs).forEach((attr) => {
+ expect(elDataAttrs[attr]).toBe(dataAttributes[attr]);
+ });
+ });
+
+ it('renders button label element when `collapsed` prop is `false`', () => {
+ const buttonLabelEl = vm.$el.querySelector('span.issuable-todo-inner');
+ expect(buttonLabelEl).not.toBeNull();
+ expect(buttonLabelEl.innerText.trim()).toBe('Mark todo as done');
+ });
+
+ it('renders button icon when `collapsed` prop is `true`', done => {
+ vm.collapsed = true;
+ Vue.nextTick()
+ .then(() => {
+ const buttonIconEl = vm.$el.querySelector('svg');
+ expect(buttonIconEl).not.toBeNull();
+ expect(buttonIconEl.querySelector('use').getAttribute('xlink:href')).toContain('todo-done');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders loading icon when `isActionActive` prop is true', done => {
+ vm.isActionActive = true;
+ Vue.nextTick()
+ .then(() => {
+ const loadingEl = vm.$el.querySelector('span.loading-container');
+ expect(loadingEl).not.toBeNull();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index 5f4f4c26d74..4452c470b82 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -95,7 +95,7 @@ beforeEach(() => {
let longRunningTestTimeoutHandle;
-beforeEach((done) => {
+beforeEach(done => {
longRunningTestTimeoutHandle = setTimeout(() => {
done.fail('Test is running too long!');
}, 2000);
diff --git a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
index b878286ae3f..dde49b4a5d7 100644
--- a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
+++ b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
@@ -170,8 +170,6 @@ describe('ImageDiffViewer', () => {
vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
vm.$nextTick(() => {
- expect(vm.$el.querySelector('.dragger').style.left).toBe('100px');
-
dragSlider(vm.$el.querySelector('.dragger'));
vm.$nextTick(() => {
diff --git a/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js
index ba897f4660d..2796cd088c6 100644
--- a/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js
+++ b/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js
@@ -2,15 +2,15 @@ import Vue from 'vue';
import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
const defaultLabel = 'Select';
const customLabel = 'Select project';
-const createComponent = config => {
+const createComponent = (props, slots = {}) => {
const Component = Vue.extend(dropdownButtonComponent);
- return mountComponent(Component, config);
+ return mountComponentWithSlots(Component, { props, slots });
};
describe('DropdownButtonComponent', () => {
@@ -65,5 +65,14 @@ describe('DropdownButtonComponent', () => {
expect(dropdownIconEl).not.toBeNull();
expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
});
+
+ it('renders slot, if default slot exists', () => {
+ vm = createComponent({}, {
+ default: ['Lorem Ipsum Dolar'],
+ });
+
+ expect(vm.$el).not.toContainElement('.dropdown-toggle-text');
+ expect(vm.$el).toHaveText('Lorem Ipsum Dolar');
+ });
});
});
diff --git a/spec/javascripts/vue_shared/components/icon_spec.js b/spec/javascripts/vue_shared/components/icon_spec.js
index 882420e602e..01f4649339e 100644
--- a/spec/javascripts/vue_shared/components/icon_spec.js
+++ b/spec/javascripts/vue_shared/components/icon_spec.js
@@ -13,6 +13,7 @@ describe('Sprite Icon Component', function () {
name: 'commit',
size: 32,
cssClasses: 'extraclasses',
+ tabIndex: '0',
});
});
@@ -58,5 +59,9 @@ describe('Sprite Icon Component', function () {
it('`name` validator should return false for existing icons', () => {
expect(Icon.props.name.validator('commit')).toBe(true);
});
+
+ it('should contain `tabindex` attribute on svg element when `tabIndex` prop is defined', () => {
+ expect(icon.$el.getAttribute('tabindex')).toBe('0');
+ });
});
});
diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js b/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js
index 7e57c51bf29..db665fdaad3 100644
--- a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js
+++ b/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js
@@ -27,7 +27,7 @@ describe('issue placeholder system note component', () => {
userDataMock.path,
);
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(
- userDataMock.avatar_url,
+ `${userDataMock.avatar_url}?width=40`,
);
});
});
diff --git a/spec/javascripts/vue_shared/components/project_avatar/default_spec.js b/spec/javascripts/vue_shared/components/project_avatar/default_spec.js
new file mode 100644
index 00000000000..5fed3f4b892
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/project_avatar/default_spec.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { projectData } from 'spec/ide/mock_data';
+import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
+import { TEST_HOST } from 'spec/test_constants';
+
+describe('ProjectAvatarDefault component', () => {
+ const Component = Vue.extend(ProjectAvatarDefault);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ project: projectData,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders identicon if project has no avatar_url', done => {
+ const expectedText = getFirstCharacterCapitalized(projectData.name);
+
+ vm.project = {
+ ...vm.project,
+ avatar_url: null,
+ };
+
+ vm.$nextTick()
+ .then(() => {
+ const identiconEl = vm.$el.querySelector('.identicon');
+
+ expect(identiconEl).not.toBe(null);
+ expect(identiconEl.textContent.trim()).toEqual(expectedText);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders avatar image if project has avatar_url', done => {
+ const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`;
+
+ vm.project = {
+ ...vm.project,
+ avatar_url: avatarUrl,
+ };
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el).toContainElement('.avatar');
+ expect(vm.$el).not.toContainElement('.identicon');
+ expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
index 656b57d764e..dc7652c77f7 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -12,7 +12,7 @@ const DEFAULT_PROPS = {
tooltipPlacement: 'bottom',
};
-describe('User Avatar Image Component', function () {
+describe('User Avatar Image Component', function() {
let vm;
let UserAvatarImage;
@@ -20,37 +20,37 @@ describe('User Avatar Image Component', function () {
UserAvatarImage = Vue.extend(userAvatarImage);
});
- describe('Initialization', function () {
- beforeEach(function () {
+ describe('Initialization', function() {
+ beforeEach(function() {
vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS,
}).$mount();
});
- it('should return a defined Vue component', function () {
+ it('should return a defined Vue component', function() {
expect(vm).toBeDefined();
});
- it('should have <img> as a child element', function () {
+ it('should have <img> as a child element', function() {
expect(vm.$el.tagName).toBe('IMG');
- expect(vm.$el.getAttribute('src')).toBe(DEFAULT_PROPS.imgSrc);
- expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc);
+ expect(vm.$el.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
+ expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt);
});
- it('should properly compute tooltipContainer', function () {
+ it('should properly compute tooltipContainer', function() {
expect(vm.tooltipContainer).toBe('body');
});
- it('should properly render tooltipContainer', function () {
+ it('should properly render tooltipContainer', function() {
expect(vm.$el.getAttribute('data-container')).toBe('body');
});
- it('should properly compute avatarSizeClass', function () {
+ it('should properly compute avatarSizeClass', function() {
expect(vm.avatarSizeClass).toBe('s99');
});
- it('should properly render img css', function () {
+ it('should properly render img css', function() {
const { classList } = vm.$el;
const containsAvatar = classList.contains('avatar');
const containsSizeClass = classList.contains('s99');
@@ -64,21 +64,21 @@ describe('User Avatar Image Component', function () {
});
});
- describe('Initialization when lazy', function () {
- beforeEach(function () {
+ describe('Initialization when lazy', function() {
+ beforeEach(function() {
vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS,
lazy: true,
}).$mount();
});
- it('should add lazy attributes', function () {
+ it('should add lazy attributes', function() {
const { classList } = vm.$el;
const lazyClass = classList.contains('lazy');
expect(lazyClass).toBe(true);
expect(vm.$el.getAttribute('src')).toBe(placeholderImage);
- expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc);
+ expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
});
});
});
diff --git a/spec/lib/bitbucket_server/client_spec.rb b/spec/lib/bitbucket_server/client_spec.rb
new file mode 100644
index 00000000000..f926ae963a4
--- /dev/null
+++ b/spec/lib/bitbucket_server/client_spec.rb
@@ -0,0 +1,88 @@
+require 'spec_helper'
+
+describe BitbucketServer::Client do
+ let(:base_uri) { 'https://test:7990/stash/' }
+ let(:options) { { base_uri: base_uri, user: 'bitbucket', password: 'mypassword' } }
+ let(:project) { 'SOME-PROJECT' }
+ let(:repo_slug) { 'my-repo' }
+ let(:headers) { { "Content-Type" => "application/json" } }
+
+ subject { described_class.new(options) }
+
+ describe '#pull_requests' do
+ let(:path) { "/projects/#{project}/repos/#{repo_slug}/pull-requests?state=ALL" }
+
+ it 'requests a collection' do
+ expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :pull_request)
+
+ subject.pull_requests(project, repo_slug)
+ end
+
+ it 'throws an exception when connection fails' do
+ allow(BitbucketServer::Collection).to receive(:new).and_raise(OpenSSL::SSL::SSLError)
+
+ expect { subject.pull_requests(project, repo_slug) }.to raise_error(described_class::ServerError)
+ end
+ end
+
+ describe '#activities' do
+ let(:path) { "/projects/#{project}/repos/#{repo_slug}/pull-requests/1/activities" }
+
+ it 'requests a collection' do
+ expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :activity)
+
+ subject.activities(project, repo_slug, 1)
+ end
+ end
+
+ describe '#repo' do
+ let(:path) { "/projects/#{project}/repos/#{repo_slug}" }
+ let(:url) { "#{base_uri}rest/api/1.0/projects/SOME-PROJECT/repos/my-repo" }
+
+ it 'requests a specific repository' do
+ stub_request(:get, url).to_return(status: 200, headers: headers, body: '{}')
+
+ subject.repo(project, repo_slug)
+
+ expect(WebMock).to have_requested(:get, url)
+ end
+ end
+
+ describe '#repos' do
+ let(:path) { "/repos" }
+
+ it 'requests a collection' do
+ expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :repo)
+
+ subject.repos
+ end
+ end
+
+ describe '#create_branch' do
+ let(:branch) { 'test-branch' }
+ let(:sha) { '12345678' }
+ let(:url) { "#{base_uri}rest/api/1.0/projects/SOME-PROJECT/repos/my-repo/branches" }
+
+ it 'requests Bitbucket to create a branch' do
+ stub_request(:post, url).to_return(status: 204, headers: headers, body: '{}')
+
+ subject.create_branch(project, repo_slug, branch, sha)
+
+ expect(WebMock).to have_requested(:post, url)
+ end
+ end
+
+ describe '#delete_branch' do
+ let(:branch) { 'test-branch' }
+ let(:sha) { '12345678' }
+ let(:url) { "#{base_uri}rest/branch-utils/1.0/projects/SOME-PROJECT/repos/my-repo/branches" }
+
+ it 'requests Bitbucket to create a branch' do
+ stub_request(:delete, url).to_return(status: 204, headers: headers, body: '{}')
+
+ subject.delete_branch(project, repo_slug, branch, sha)
+
+ expect(WebMock).to have_requested(:delete, url)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/connection_spec.rb b/spec/lib/bitbucket_server/connection_spec.rb
new file mode 100644
index 00000000000..b5da4cb1a49
--- /dev/null
+++ b/spec/lib/bitbucket_server/connection_spec.rb
@@ -0,0 +1,68 @@
+require 'spec_helper'
+
+describe BitbucketServer::Connection do
+ let(:options) { { base_uri: 'https://test:7990', user: 'bitbucket', password: 'mypassword' } }
+ let(:payload) { { 'test' => 1 } }
+ let(:headers) { { "Content-Type" => "application/json" } }
+ let(:url) { 'https://test:7990/rest/api/1.0/test?something=1' }
+
+ subject { described_class.new(options) }
+
+ describe '#get' do
+ it 'returns JSON body' do
+ WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: payload.to_json, status: 200, headers: headers)
+
+ expect(subject.get(url, { something: 1 })).to eq(payload)
+ end
+
+ it 'throws an exception if the response is not 200' do
+ WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: payload.to_json, status: 500, headers: headers)
+
+ expect { subject.get(url) }.to raise_error(described_class::ConnectionError)
+ end
+
+ it 'throws an exception if the response is not JSON' do
+ WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: 'bad data', status: 200, headers: headers)
+
+ expect { subject.get(url) }.to raise_error(described_class::ConnectionError)
+ end
+ end
+
+ describe '#post' do
+ let(:headers) { { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } }
+
+ it 'returns JSON body' do
+ WebMock.stub_request(:post, url).with(headers: headers).to_return(body: payload.to_json, status: 200, headers: headers)
+
+ expect(subject.post(url, payload)).to eq(payload)
+ end
+
+ it 'throws an exception if the response is not 200' do
+ WebMock.stub_request(:post, url).with(headers: headers).to_return(body: payload.to_json, status: 500, headers: headers)
+
+ expect { subject.post(url, payload) }.to raise_error(described_class::ConnectionError)
+ end
+ end
+
+ describe '#delete' do
+ let(:headers) { { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } }
+
+ context 'branch API' do
+ let(:branch_path) { '/projects/foo/repos/bar/branches' }
+ let(:branch_url) { 'https://test:7990/rest/branch-utils/1.0/projects/foo/repos/bar/branches' }
+ let(:path) { }
+
+ it 'returns JSON body' do
+ WebMock.stub_request(:delete, branch_url).with(headers: headers).to_return(body: payload.to_json, status: 200, headers: headers)
+
+ expect(subject.delete(:branches, branch_path, payload)).to eq(payload)
+ end
+
+ it 'throws an exception if the response is not 200' do
+ WebMock.stub_request(:delete, branch_url).with(headers: headers).to_return(body: payload.to_json, status: 500, headers: headers)
+
+ expect { subject.delete(:branches, branch_path, payload) }.to raise_error(described_class::ConnectionError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/page_spec.rb b/spec/lib/bitbucket_server/page_spec.rb
new file mode 100644
index 00000000000..cf419a9045b
--- /dev/null
+++ b/spec/lib/bitbucket_server/page_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe BitbucketServer::Page do
+ let(:response) { { 'values' => [{ 'description' => 'Test' }], 'isLastPage' => false, 'nextPageStart' => 2 } }
+
+ before do
+ # Autoloading hack
+ BitbucketServer::Representation::PullRequest.new({})
+ end
+
+ describe '#items' do
+ it 'returns collection of needed objects' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.items.first).to be_a(BitbucketServer::Representation::PullRequest)
+ expect(page.items.count).to eq(1)
+ end
+ end
+
+ describe '#attrs' do
+ it 'returns attributes' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.attrs.keys).to include(:isLastPage, :nextPageStart)
+ end
+ end
+
+ describe '#next?' do
+ it 'returns true' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.next?).to be_truthy
+ end
+
+ it 'returns false' do
+ response['isLastPage'] = true
+ response.delete('nextPageStart')
+ page = described_class.new(response, :pull_request)
+
+ expect(page.next?).to be_falsey
+ end
+ end
+
+ describe '#next' do
+ it 'returns next attribute' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.next).to eq(2)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/paginator_spec.rb b/spec/lib/bitbucket_server/paginator_spec.rb
new file mode 100644
index 00000000000..2de50eba3c4
--- /dev/null
+++ b/spec/lib/bitbucket_server/paginator_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+describe BitbucketServer::Paginator do
+ let(:last_page) { double(:page, next?: false, items: ['item_2']) }
+ let(:first_page) { double(:page, next?: true, next: last_page, items: ['item_1']) }
+ let(:connection) { instance_double(BitbucketServer::Connection) }
+
+ describe '#items' do
+ let(:paginator) { described_class.new(connection, 'http://more-data', :pull_request) }
+ let(:page_attrs) { { 'isLastPage' => false, 'nextPageStart' => 1 } }
+
+ it 'returns items and raises StopIteration in the end' do
+ allow(paginator).to receive(:fetch_next_page).and_return(first_page)
+ expect(paginator.items).to match(['item_1'])
+
+ allow(paginator).to receive(:fetch_next_page).and_return(last_page)
+ expect(paginator.items).to match(['item_2'])
+
+ allow(paginator).to receive(:fetch_next_page).and_return(nil)
+ expect { paginator.items }.to raise_error(StopIteration)
+ end
+
+ it 'calls the connection with different offsets' do
+ expect(connection).to receive(:get).with('http://more-data', start: 0, limit: BitbucketServer::Paginator::PAGE_LENGTH).and_return(page_attrs)
+
+ expect(paginator.items).to eq([])
+
+ expect(connection).to receive(:get).with('http://more-data', start: 1, limit: BitbucketServer::Paginator::PAGE_LENGTH).and_return({})
+
+ expect(paginator.items).to eq([])
+
+ expect { paginator.items }.to raise_error(StopIteration)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/activity_spec.rb b/spec/lib/bitbucket_server/representation/activity_spec.rb
new file mode 100644
index 00000000000..15c50e40472
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/activity_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::Activity do
+ let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:inline_comment) { activities.first }
+ let(:comment) { activities[3] }
+ let(:merge_event) { activities[4] }
+
+ describe 'regular comment' do
+ subject { described_class.new(comment) }
+
+ it { expect(subject.comment?).to be_truthy }
+ it { expect(subject.inline_comment?).to be_falsey }
+ it { expect(subject.comment).to be_a(BitbucketServer::Representation::Comment) }
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe 'inline comment' do
+ subject { described_class.new(inline_comment) }
+
+ it { expect(subject.comment?).to be_truthy }
+ it { expect(subject.inline_comment?).to be_truthy }
+ it { expect(subject.comment).to be_a(BitbucketServer::Representation::PullRequestComment) }
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe 'merge event' do
+ subject { described_class.new(merge_event) }
+
+ it { expect(subject.comment?).to be_falsey }
+ it { expect(subject.inline_comment?).to be_falsey }
+ it { expect(subject.committer_user).to eq('root') }
+ it { expect(subject.committer_email).to eq('test.user@example.com') }
+ it { expect(subject.merge_timestamp).to be_a(Time) }
+ it { expect(subject.created_at).to be_a(Time) }
+ it { expect(subject.merge_commit).to eq('839fa9a2d434eb697815b8fcafaecc51accfdbbc') }
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/comment_spec.rb b/spec/lib/bitbucket_server/representation/comment_spec.rb
new file mode 100644
index 00000000000..53a20a1d80a
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/comment_spec.rb
@@ -0,0 +1,55 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::Comment do
+ let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:comment) { activities.first }
+
+ subject { described_class.new(comment) }
+
+ describe '#id' do
+ it { expect(subject.id).to eq(9) }
+ end
+
+ describe '#author_username' do
+ it { expect(subject.author_username).to eq('root' ) }
+ end
+
+ describe '#author_email' do
+ it { expect(subject.author_email).to eq('test.user@example.com' ) }
+ end
+
+ describe '#note' do
+ it { expect(subject.note).to eq('is this a new line?') }
+ end
+
+ describe '#created_at' do
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe '#updated_at' do
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe '#comments' do
+ it { expect(subject.comments.count).to eq(4) }
+ it { expect(subject.comments).to all( be_a(described_class) ) }
+ it { expect(subject.comments.map(&:note)).to match_array(["Hello world", "Ok", "hello", "hi"]) }
+
+ # The thread should look like:
+ #
+ # is this a new line? (subject)
+ # -> Hello world (first)
+ # -> Ok (third)
+ # -> Hi (fourth)
+ # -> hello (second)
+ it 'comments have the right parent' do
+ first, second, third, fourth = subject.comments[0..4]
+
+ expect(subject.parent_comment).to be_nil
+ expect(first.parent_comment).to eq(subject)
+ expect(second.parent_comment).to eq(subject)
+ expect(third.parent_comment).to eq(first)
+ expect(fourth.parent_comment).to eq(first)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb
new file mode 100644
index 00000000000..bd7e3597486
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::PullRequestComment do
+ let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:comment) { activities.second }
+
+ subject { described_class.new(comment) }
+
+ describe '#id' do
+ it { expect(subject.id).to eq(7) }
+ end
+
+ describe '#from_sha' do
+ it { expect(subject.from_sha).to eq('c5f4288162e2e6218180779c7f6ac1735bb56eab') }
+ end
+
+ describe '#to_sha' do
+ it { expect(subject.to_sha).to eq('a4c2164330f2549f67c13f36a93884cf66e976be') }
+ end
+
+ describe '#to?' do
+ it { expect(subject.to?).to be_falsey }
+ end
+
+ describe '#from?' do
+ it { expect(subject.from?).to be_truthy }
+ end
+
+ describe '#added?' do
+ it { expect(subject.added?).to be_falsey }
+ end
+
+ describe '#removed?' do
+ it { expect(subject.removed?).to be_falsey }
+ end
+
+ describe '#new_pos' do
+ it { expect(subject.new_pos).to eq(11) }
+ end
+
+ describe '#old_pos' do
+ it { expect(subject.old_pos).to eq(9) }
+ end
+
+ describe '#file_path' do
+ it { expect(subject.file_path).to eq('CHANGELOG.md') }
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/pull_request_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
new file mode 100644
index 00000000000..4b8afdb006b
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
@@ -0,0 +1,79 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::PullRequest do
+ let(:sample_data) { JSON.parse(fixture_file('importers/bitbucket_server/pull_request.json')) }
+
+ subject { described_class.new(sample_data) }
+
+ describe '#author' do
+ it { expect(subject.author).to eq('root') }
+ end
+
+ describe '#author_email' do
+ it { expect(subject.author_email).to eq('joe.montana@49ers.com') }
+ end
+
+ describe '#description' do
+ it { expect(subject.description).to eq('Test') }
+ end
+
+ describe '#iid' do
+ it { expect(subject.iid).to eq(7) }
+ end
+
+ describe '#state' do
+ it { expect(subject.state).to eq('merged') }
+
+ context 'declined pull requests' do
+ before do
+ sample_data['state'] = 'DECLINED'
+ end
+
+ it 'returns closed' do
+ expect(subject.state).to eq('closed')
+ end
+ end
+
+ context 'open pull requests' do
+ before do
+ sample_data['state'] = 'OPEN'
+ end
+
+ it 'returns open' do
+ expect(subject.state).to eq('opened')
+ end
+ end
+ end
+
+ describe '#merged?' do
+ it { expect(subject.merged?).to be_truthy }
+ end
+
+ describe '#created_at' do
+ it { expect(subject.created_at.to_i).to eq(sample_data['createdDate'] / 1000) }
+ end
+
+ describe '#updated_at' do
+ it { expect(subject.updated_at.to_i).to eq(sample_data['updatedDate'] / 1000) }
+ end
+
+ describe '#title' do
+ it { expect(subject.title).to eq('Added a new line') }
+ end
+
+ describe '#source_branch_name' do
+ it { expect(subject.source_branch_name).to eq('refs/heads/root/CODE_OF_CONDUCTmd-1530600625006') }
+ end
+
+ describe '#source_branch_sha' do
+ it { expect(subject.source_branch_sha).to eq('074e2b4dddc5b99df1bf9d4a3f66cfc15481fdc8') }
+ end
+
+ describe '#target_branch_name' do
+ it { expect(subject.target_branch_name).to eq('refs/heads/master') }
+ end
+
+ describe '#target_branch_sha' do
+ it { expect(subject.target_branch_sha).to eq('839fa9a2d434eb697815b8fcafaecc51accfdbbc') }
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/repo_spec.rb b/spec/lib/bitbucket_server/representation/repo_spec.rb
new file mode 100644
index 00000000000..3ac1030fbb0
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/repo_spec.rb
@@ -0,0 +1,80 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::Repo do
+ let(:sample_data) do
+ <<~DATA
+ {
+ "slug": "rouge",
+ "id": 1,
+ "name": "rouge",
+ "scmId": "git",
+ "state": "AVAILABLE",
+ "statusMessage": "Available",
+ "forkable": true,
+ "project": {
+ "key": "TEST",
+ "id": 1,
+ "name": "test",
+ "description": "Test",
+ "public": false,
+ "type": "NORMAL",
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/projects/TEST"
+ }
+ ]
+ }
+ },
+ "public": false,
+ "links": {
+ "clone": [
+ {
+ "href": "http://root@localhost:7990/scm/test/rouge.git",
+ "name": "http"
+ },
+ {
+ "href": "ssh://git@localhost:7999/test/rouge.git",
+ "name": "ssh"
+ }
+ ],
+ "self": [
+ {
+ "href": "http://localhost:7990/projects/TEST/repos/rouge/browse"
+ }
+ ]
+ }
+ }
+ DATA
+ end
+
+ subject { described_class.new(JSON.parse(sample_data)) }
+
+ describe '#project_key' do
+ it { expect(subject.project_key).to eq('TEST') }
+ end
+
+ describe '#project_name' do
+ it { expect(subject.project_name).to eq('test') }
+ end
+
+ describe '#slug' do
+ it { expect(subject.slug).to eq('rouge') }
+ end
+
+ describe '#browse_url' do
+ it { expect(subject.browse_url).to eq('http://localhost:7990/projects/TEST/repos/rouge/browse') }
+ end
+
+ describe '#clone_url' do
+ it { expect(subject.clone_url).to eq('http://root@localhost:7990/scm/test/rouge.git') }
+ end
+
+ describe '#description' do
+ it { expect(subject.description).to eq('Test') }
+ end
+
+ describe '#full_name' do
+ it { expect(subject.full_name).to eq('test/rouge') }
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/remove_restricted_todos_spec.rb b/spec/lib/gitlab/background_migration/remove_restricted_todos_spec.rb
new file mode 100644
index 00000000000..dae754112dc
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/remove_restricted_todos_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::RemoveRestrictedTodos, :migration, schema: 20180704204006 do
+ let(:projects) { table(:projects) }
+ let(:users) { table(:users) }
+ let(:todos) { table(:todos) }
+ let(:issues) { table(:issues) }
+ let(:assignees) { table(:issue_assignees) }
+ let(:project_authorizations) { table(:project_authorizations) }
+ let(:project_features) { table(:project_features) }
+
+ let(:todo_params) { { author_id: 1, target_type: 'Issue', action: 1, state: :pending } }
+
+ before do
+ users.create(id: 1, email: 'user@example.com', projects_limit: 10)
+ users.create(id: 2, email: 'reporter@example.com', projects_limit: 10)
+ users.create(id: 3, email: 'guest@example.com', projects_limit: 10)
+
+ projects.create!(id: 1, name: 'project-1', path: 'project-1', visibility_level: 0, namespace_id: 1)
+ projects.create!(id: 2, name: 'project-2', path: 'project-2', visibility_level: 0, namespace_id: 1)
+
+ issues.create(id: 1, project_id: 1)
+ issues.create(id: 2, project_id: 2)
+
+ project_authorizations.create(user_id: 2, project_id: 2, access_level: 20) # reporter
+ project_authorizations.create(user_id: 3, project_id: 2, access_level: 10) # guest
+
+ todos.create(todo_params.merge(user_id: 1, project_id: 1, target_id: 1)) # out of project ids range
+ todos.create(todo_params.merge(user_id: 1, project_id: 2, target_id: 2)) # non member
+ todos.create(todo_params.merge(user_id: 2, project_id: 2, target_id: 2)) # reporter
+ todos.create(todo_params.merge(user_id: 3, project_id: 2, target_id: 2)) # guest
+ end
+
+ subject { described_class.new.perform(2, 5) }
+
+ context 'when a project is private' do
+ it 'removes todos of users without project access' do
+ expect { subject }.to change { Todo.count }.from(4).to(3)
+ end
+
+ context 'with a confidential issue' do
+ it 'removes todos of users without project access and guests for confidential issues' do
+ issues.create(id: 3, project_id: 2, confidential: true)
+ issues.create(id: 4, project_id: 1, confidential: true) # not in the batch
+ todos.create(todo_params.merge(user_id: 3, project_id: 2, target_id: 3))
+ todos.create(todo_params.merge(user_id: 2, project_id: 2, target_id: 3))
+ todos.create(todo_params.merge(user_id: 1, project_id: 1, target_id: 4))
+
+ expect { subject }.to change { Todo.count }.from(7).to(5)
+ end
+ end
+ end
+
+ context 'when a project is public' do
+ before do
+ projects.find(2).update_attribute(:visibility_level, 20)
+ end
+
+ context 'when all features have the same visibility as the project, no confidential issues' do
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'with confidential issues' do
+ before do
+ users.create(id: 4, email: 'author@example.com', projects_limit: 10)
+ users.create(id: 5, email: 'assignee@example.com', projects_limit: 10)
+ issues.create(id: 3, project_id: 2, confidential: true, author_id: 4)
+ assignees.create(user_id: 5, issue_id: 3)
+
+ todos.create(todo_params.merge(user_id: 1, project_id: 2, target_id: 3)) # to be deleted
+ todos.create(todo_params.merge(user_id: 2, project_id: 2, target_id: 3)) # authorized user
+ todos.create(todo_params.merge(user_id: 3, project_id: 2, target_id: 3)) # to be deleted guest
+ todos.create(todo_params.merge(user_id: 4, project_id: 2, target_id: 3)) # conf issue author
+ todos.create(todo_params.merge(user_id: 5, project_id: 2, target_id: 3)) # conf issue assignee
+ end
+
+ it 'removes confidential issue todos for non authorized users' do
+ expect { subject }.to change { Todo.count }.from(9).to(7)
+ end
+ end
+
+ context 'features visibility restrictions' do
+ before do
+ todo_params.merge!(project_id: 2, user_id: 1, target_id: 3)
+ todos.create(todo_params.merge(user_id: 1, target_id: 3, target_type: 'MergeRequest'))
+ todos.create(todo_params.merge(user_id: 1, target_id: 3, target_type: 'Commit'))
+ end
+
+ context 'when issues are restricted to project members' do
+ before do
+ project_features.create(issues_access_level: 10, project_id: 2)
+ end
+
+ it 'removes non members issue todos' do
+ expect { subject }.to change { Todo.count }.from(6).to(5)
+ end
+ end
+
+ context 'when merge requests are restricted to project members' do
+ before do
+ project_features.create(merge_requests_access_level: 10, project_id: 2)
+ end
+
+ it 'removes non members issue todos' do
+ expect { subject }.to change { Todo.count }.from(6).to(5)
+ end
+ end
+
+ context 'when repository and merge requests are restricted to project members' do
+ before do
+ project_features.create(repository_access_level: 10, merge_requests_access_level: 10, project_id: 2)
+ end
+
+ it 'removes non members commit and merge requests todos' do
+ expect { subject }.to change { Todo.count }.from(6).to(4)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
new file mode 100644
index 00000000000..70423823b89
--- /dev/null
+++ b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
@@ -0,0 +1,291 @@
+require 'spec_helper'
+
+describe Gitlab::BitbucketServerImport::Importer do
+ include ImportSpecHelper
+
+ let(:project) { create(:project, :repository, import_url: 'http://my-bitbucket') }
+ let(:now) { Time.now.utc.change(usec: 0) }
+ let(:project_key) { 'TEST' }
+ let(:repo_slug) { 'rouge' }
+ let(:sample) { RepoHelpers.sample_compare }
+
+ subject { described_class.new(project, recover_missing_commits: true) }
+
+ before do
+ data = project.create_or_update_import_data(
+ data: { project_key: project_key, repo_slug: repo_slug },
+ credentials: { base_uri: 'http://my-bitbucket', user: 'bitbucket', password: 'test' }
+ )
+ data.save
+ project.save
+ end
+
+ describe '#import_repository' do
+ before do
+ expect(subject).to receive(:import_pull_requests)
+ expect(subject).to receive(:delete_temp_branches)
+ end
+
+ it 'adds a remote' do
+ expect(project.repository).to receive(:fetch_as_mirror)
+ .with('http://bitbucket:test@my-bitbucket',
+ refmap: [:heads, :tags, '+refs/pull-requests/*/to:refs/merge-requests/*/head'],
+ remote_name: 'bitbucket_server')
+
+ subject.execute
+ end
+ end
+
+ describe '#import_pull_requests' do
+ before do
+ allow(subject).to receive(:import_repository)
+ allow(subject).to receive(:delete_temp_branches)
+ allow(subject).to receive(:restore_branches)
+
+ pull_request = instance_double(
+ BitbucketServer::Representation::PullRequest,
+ iid: 10,
+ source_branch_sha: sample.commits.last,
+ source_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.source_branch,
+ target_branch_sha: sample.commits.first,
+ target_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.target_branch,
+ title: 'This is a title',
+ description: 'This is a test pull request',
+ state: 'merged',
+ author: 'Test Author',
+ author_email: project.owner.email,
+ created_at: Time.now,
+ updated_at: Time.now,
+ merged?: true)
+
+ allow(subject.client).to receive(:pull_requests).and_return([pull_request])
+
+ @merge_event = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: false,
+ merge_event?: true,
+ committer_email: project.owner.email,
+ merge_timestamp: now,
+ merge_commit: '12345678'
+ )
+
+ @pr_note = instance_double(
+ BitbucketServer::Representation::Comment,
+ note: 'Hello world',
+ author_email: 'unknown@gmail.com',
+ author_username: 'The Flash',
+ comments: [],
+ created_at: now,
+ updated_at: now,
+ parent_comment: nil)
+
+ @pr_comment = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: true,
+ inline_comment?: false,
+ merge_event?: false,
+ comment: @pr_note)
+ end
+
+ it 'imports merge event' do
+ expect(subject.client).to receive(:activities).and_return([@merge_event])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.metrics.merged_by).to eq(project.owner)
+ expect(merge_request.metrics.merged_at).to eq(@merge_event.merge_timestamp)
+ expect(merge_request.merge_commit_sha).to eq('12345678')
+ end
+
+ it 'imports comments' do
+ expect(subject.client).to receive(:activities).and_return([@pr_comment])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(1)
+ note = merge_request.notes.first
+ expect(note.note).to end_with(@pr_note.note)
+ expect(note.author).to eq(project.owner)
+ expect(note.created_at).to eq(@pr_note.created_at)
+ expect(note.updated_at).to eq(@pr_note.created_at)
+ end
+
+ it 'imports threaded discussions' do
+ reply = instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ author_email: 'someuser@gitlab.com',
+ author_username: 'Batman',
+ note: 'I agree',
+ created_at: now,
+ updated_at: now)
+
+ # https://gitlab.com/gitlab-org/gitlab-test/compare/c1acaa58bbcbc3eafe538cb8274ba387047b69f8...5937ac0a7beb003549fc5fd26fc247ad
+ inline_note = instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ file_type: 'ADDED',
+ from_sha: sample.commits.first,
+ to_sha: sample.commits.last,
+ file_path: '.gitmodules',
+ old_pos: nil,
+ new_pos: 4,
+ note: 'Hello world',
+ author_email: 'unknown@gmail.com',
+ author_username: 'Superman',
+ comments: [reply],
+ created_at: now,
+ updated_at: now,
+ parent_comment: nil)
+
+ allow(reply).to receive(:parent_comment).and_return(inline_note)
+
+ inline_comment = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: true,
+ inline_comment?: true,
+ merge_event?: false,
+ comment: inline_note)
+
+ expect(subject.client).to receive(:activities).and_return([inline_comment])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(2)
+ expect(merge_request.notes.map(&:discussion_id).uniq.count).to eq(1)
+
+ notes = merge_request.notes.order(:id).to_a
+ start_note = notes.first
+ expect(start_note.type).to eq('DiffNote')
+ expect(start_note.note).to end_with(inline_note.note)
+ expect(start_note.created_at).to eq(inline_note.created_at)
+ expect(start_note.updated_at).to eq(inline_note.updated_at)
+ expect(start_note.position.base_sha).to eq(inline_note.from_sha)
+ expect(start_note.position.start_sha).to eq(inline_note.from_sha)
+ expect(start_note.position.head_sha).to eq(inline_note.to_sha)
+ expect(start_note.position.old_line).to be_nil
+ expect(start_note.position.new_line).to eq(inline_note.new_pos)
+
+ reply_note = notes.last
+ # Make sure author and reply context is included
+ expect(reply_note.note).to start_with("*By #{reply.author_username} (#{reply.author_email})*\n\n")
+ expect(reply_note.note).to end_with("> #{inline_note.note}\n\n#{reply.note}")
+ expect(reply_note.author).to eq(project.owner)
+ expect(reply_note.created_at).to eq(reply.created_at)
+ expect(reply_note.updated_at).to eq(reply.created_at)
+ expect(reply_note.position.base_sha).to eq(inline_note.from_sha)
+ expect(reply_note.position.start_sha).to eq(inline_note.from_sha)
+ expect(reply_note.position.head_sha).to eq(inline_note.to_sha)
+ expect(reply_note.position.old_line).to be_nil
+ expect(reply_note.position.new_line).to eq(inline_note.new_pos)
+ end
+
+ it 'falls back to comments if diff comments fail to validate' do
+ reply = instance_double(
+ BitbucketServer::Representation::Comment,
+ author_email: 'someuser@gitlab.com',
+ author_username: 'Aquaman',
+ note: 'I agree',
+ created_at: now,
+ updated_at: now)
+
+ # https://gitlab.com/gitlab-org/gitlab-test/compare/c1acaa58bbcbc3eafe538cb8274ba387047b69f8...5937ac0a7beb003549fc5fd26fc247ad
+ inline_note = instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ file_type: 'REMOVED',
+ from_sha: sample.commits.first,
+ to_sha: sample.commits.last,
+ file_path: '.gitmodules',
+ old_pos: 8,
+ new_pos: 9,
+ note: 'This is a note with an invalid line position.',
+ author_email: project.owner.email,
+ author_username: 'Owner',
+ comments: [reply],
+ created_at: now,
+ updated_at: now,
+ parent_comment: nil)
+
+ inline_comment = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: true,
+ inline_comment?: true,
+ merge_event?: false,
+ comment: inline_note)
+
+ allow(reply).to receive(:parent_comment).and_return(inline_note)
+
+ expect(subject.client).to receive(:activities).and_return([inline_comment])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(2)
+ notes = merge_request.notes
+
+ expect(notes.first.note).to start_with('*Comment on .gitmodules')
+ expect(notes.second.note).to start_with('*Comment on .gitmodules')
+ end
+ end
+
+ describe 'inaccessible branches' do
+ let(:id) { 10 }
+ let(:temp_branch_from) { "gitlab/import/pull-request/#{id}/from" }
+ let(:temp_branch_to) { "gitlab/import/pull-request/#{id}/to" }
+
+ before do
+ pull_request = instance_double(
+ BitbucketServer::Representation::PullRequest,
+ iid: id,
+ source_branch_sha: '12345678',
+ source_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.source_branch,
+ target_branch_sha: '98765432',
+ target_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.target_branch,
+ title: 'This is a title',
+ description: 'This is a test pull request',
+ state: 'merged',
+ author: 'Test Author',
+ author_email: project.owner.email,
+ created_at: Time.now,
+ updated_at: Time.now,
+ merged?: true)
+
+ expect(subject.client).to receive(:pull_requests).and_return([pull_request])
+ expect(subject.client).to receive(:activities).and_return([])
+ expect(subject).to receive(:import_repository).twice
+ end
+
+ it '#restore_branches' do
+ expect(subject).to receive(:restore_branches).and_call_original
+ expect(subject).to receive(:delete_temp_branches)
+ expect(subject.client).to receive(:create_branch)
+ .with(project_key, repo_slug,
+ temp_branch_from,
+ '12345678')
+ expect(subject.client).to receive(:create_branch)
+ .with(project_key, repo_slug,
+ temp_branch_to,
+ '98765432')
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+ end
+
+ it '#delete_temp_branches' do
+ expect(subject.client).to receive(:create_branch).twice
+ expect(subject).to receive(:delete_temp_branches).and_call_original
+ expect(subject.client).to receive(:delete_branch)
+ .with(project_key, repo_slug,
+ temp_branch_from,
+ '12345678')
+ expect(subject.client).to receive(:delete_branch)
+ .with(project_key, repo_slug,
+ temp_branch_to,
+ '98765432')
+ expect(project.repository).to receive(:delete_branch).with(temp_branch_from)
+ expect(project.repository).to receive(:delete_branch).with(temp_branch_to)
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cleanup/remote_uploads_spec.rb b/spec/lib/gitlab/cleanup/remote_uploads_spec.rb
new file mode 100644
index 00000000000..8d03baeb07b
--- /dev/null
+++ b/spec/lib/gitlab/cleanup/remote_uploads_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Gitlab::Cleanup::RemoteUploads do
+ context 'when object_storage is enabled' do
+ let(:connection) { double }
+ let(:directory) { double }
+ let!(:uploads) do
+ [
+ create(:upload, path: 'dir/file1', store: ObjectStorage::Store::REMOTE),
+ create(:upload, path: 'dir/file2', store: ObjectStorage::Store::LOCAL)
+ ]
+ end
+ let(:remote_files) do
+ [
+ double(key: 'dir/file1'),
+ double(key: 'dir/file2'),
+ double(key: 'dir/file3'),
+ double(key: 'lost_and_found/dir/file3')
+ ]
+ end
+
+ before do
+ stub_uploads_object_storage(FileUploader)
+
+ expect(::Fog::Storage).to receive(:new).and_return(connection)
+
+ expect(connection).to receive(:directories).and_return(double(get: directory))
+ expect(directory).to receive(:files).and_return(remote_files)
+ end
+
+ context 'when dry_run is set to false' do
+ subject { described_class.new.run!(dry_run: false) }
+
+ it 'moves files that are not in uploads table' do
+ expect(remote_files[0]).not_to receive(:copy)
+ expect(remote_files[0]).not_to receive(:destroy)
+ expect(remote_files[1]).to receive(:copy)
+ expect(remote_files[1]).to receive(:destroy)
+ expect(remote_files[2]).to receive(:copy)
+ expect(remote_files[2]).to receive(:destroy)
+ expect(remote_files[3]).not_to receive(:copy)
+ expect(remote_files[3]).not_to receive(:destroy)
+
+ subject
+ end
+ end
+
+ context 'when dry_run is set to true' do
+ subject { described_class.new.run!(dry_run: true) }
+
+ it 'does not move filese' do
+ expect(remote_files[0]).not_to receive(:copy)
+ expect(remote_files[0]).not_to receive(:destroy)
+ expect(remote_files[1]).not_to receive(:copy)
+ expect(remote_files[1]).not_to receive(:destroy)
+ expect(remote_files[2]).not_to receive(:copy)
+ expect(remote_files[2]).not_to receive(:destroy)
+ expect(remote_files[3]).not_to receive(:copy)
+ expect(remote_files[3]).not_to receive(:destroy)
+
+ subject
+ end
+ end
+ end
+
+ context 'when object_storage is not enabled' do
+ it 'does not connect to any storage' do
+ expect(::Fog::Storage).not_to receive(:new)
+
+ subject
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_sources_spec.rb b/spec/lib/gitlab/import_sources_spec.rb
index 25827423914..94abf9679c4 100644
--- a/spec/lib/gitlab/import_sources_spec.rb
+++ b/spec/lib/gitlab/import_sources_spec.rb
@@ -5,15 +5,16 @@ describe Gitlab::ImportSources do
it 'returns a hash' do
expected =
{
- 'GitHub' => 'github',
- 'Bitbucket' => 'bitbucket',
- 'GitLab.com' => 'gitlab',
- 'Google Code' => 'google_code',
- 'FogBugz' => 'fogbugz',
- 'Repo by URL' => 'git',
- 'GitLab export' => 'gitlab_project',
- 'Gitea' => 'gitea',
- 'Manifest file' => 'manifest'
+ 'GitHub' => 'github',
+ 'Bitbucket Cloud' => 'bitbucket',
+ 'Bitbucket Server' => 'bitbucket_server',
+ 'GitLab.com' => 'gitlab',
+ 'Google Code' => 'google_code',
+ 'FogBugz' => 'fogbugz',
+ 'Repo by URL' => 'git',
+ 'GitLab export' => 'gitlab_project',
+ 'Gitea' => 'gitea',
+ 'Manifest file' => 'manifest'
}
expect(described_class.options).to eq(expected)
@@ -26,6 +27,7 @@ describe Gitlab::ImportSources do
%w(
github
bitbucket
+ bitbucket_server
gitlab
google_code
fogbugz
@@ -45,6 +47,7 @@ describe Gitlab::ImportSources do
%w(
github
bitbucket
+ bitbucket_server
gitlab
google_code
fogbugz
@@ -60,6 +63,7 @@ describe Gitlab::ImportSources do
import_sources = {
'github' => Gitlab::GithubImport::ParallelImporter,
'bitbucket' => Gitlab::BitbucketImport::Importer,
+ 'bitbucket_server' => Gitlab::BitbucketServerImport::Importer,
'gitlab' => Gitlab::GitlabImport::Importer,
'google_code' => Gitlab::GoogleCodeImport::Importer,
'fogbugz' => Gitlab::FogbugzImport::Importer,
@@ -79,7 +83,8 @@ describe Gitlab::ImportSources do
describe '.title' do
import_sources = {
'github' => 'GitHub',
- 'bitbucket' => 'Bitbucket',
+ 'bitbucket' => 'Bitbucket Cloud',
+ 'bitbucket_server' => 'Bitbucket Server',
'gitlab' => 'GitLab.com',
'google_code' => 'Google Code',
'fogbugz' => 'FogBugz',
@@ -97,7 +102,7 @@ describe Gitlab::ImportSources do
end
describe 'imports_repository? checker' do
- let(:allowed_importers) { %w[github gitlab_project] }
+ let(:allowed_importers) { %w[github gitlab_project bitbucket_server] }
it 'fails if any importer other than the allowed ones implements this method' do
current_importers = described_class.values.select { |kind| described_class.importer(kind).try(:imports_repository?) }
diff --git a/spec/lib/gitlab/kubernetes/config_map_spec.rb b/spec/lib/gitlab/kubernetes/config_map_spec.rb
index e253b291277..fe65d03875f 100644
--- a/spec/lib/gitlab/kubernetes/config_map_spec.rb
+++ b/spec/lib/gitlab/kubernetes/config_map_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Gitlab::Kubernetes::ConfigMap do
let(:kubeclient) { double('kubernetes client') }
let(:application) { create(:clusters_applications_prometheus) }
- let(:config_map) { described_class.new(application.name, application.values) }
+ let(:config_map) { described_class.new(application.name, application.files) }
let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
let(:metadata) do
@@ -15,7 +15,7 @@ describe Gitlab::Kubernetes::ConfigMap do
end
describe '#generate' do
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) }
+ let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: application.files) }
subject { config_map.generate }
it 'should build a Kubeclient Resource' do
diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
index 6e9b4ca0869..341f71a3e49 100644
--- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
@@ -39,7 +39,7 @@ describe Gitlab::Kubernetes::Helm::Api do
end
context 'with a ConfigMap' do
- let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.values).generate }
+ let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.files).generate }
it 'creates a ConfigMap on kubeclient' do
expect(client).to receive(:create_config_map).with(resource).once
diff --git a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
index 7be8be54d5e..d50616e95e8 100644
--- a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
@@ -2,7 +2,25 @@ require 'spec_helper'
describe Gitlab::Kubernetes::Helm::BaseCommand do
let(:application) { create(:clusters_applications_helm) }
- let(:base_command) { described_class.new(application.name) }
+ let(:test_class) do
+ Class.new do
+ include Gitlab::Kubernetes::Helm::BaseCommand
+
+ def name
+ "test-class-name"
+ end
+
+ def files
+ {
+ some: 'value'
+ }
+ end
+ end
+ end
+
+ let(:base_command) do
+ test_class.new
+ end
subject { base_command }
@@ -18,15 +36,9 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do
end
end
- describe '#config_map?' do
- subject { base_command.config_map? }
-
- it { is_expected.to be_falsy }
- end
-
describe '#pod_name' do
subject { base_command.pod_name }
- it { is_expected.to eq('install-helm') }
+ it { is_expected.to eq('install-test-class-name') }
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb b/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb
new file mode 100644
index 00000000000..167bee22fc3
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::Helm::Certificate do
+ describe '.generate_root' do
+ subject { described_class.generate_root }
+
+ it 'should generate a root CA that expires a long way in the future' do
+ expect(subject.cert.not_after).to be > 999.years.from_now
+ end
+ end
+
+ describe '#issue' do
+ subject { described_class.generate_root.issue }
+
+ it 'should generate a cert that expires soon' do
+ expect(subject.cert.not_after).to be < 60.minutes.from_now
+ end
+
+ context 'passing in INFINITE_EXPIRY' do
+ subject { described_class.generate_root.issue(expires_in: described_class::INFINITE_EXPIRY) }
+
+ it 'should generate a cert that expires a long way in the future' do
+ expect(subject.cert.not_after).to be > 999.years.from_now
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
index 89e36a298f8..dcbc046cf00 100644
--- a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
@@ -2,9 +2,9 @@ require 'spec_helper'
describe Gitlab::Kubernetes::Helm::InitCommand do
let(:application) { create(:clusters_applications_helm) }
- let(:commands) { 'helm init >/dev/null' }
+ let(:commands) { 'helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem >/dev/null' }
- subject { described_class.new(application.name) }
+ subject { described_class.new(name: application.name, files: {}) }
it_behaves_like 'helm commands'
end
diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
index cd456a45287..982e2f41043 100644
--- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
@@ -1,83 +1,82 @@
require 'rails_helper'
describe Gitlab::Kubernetes::Helm::InstallCommand do
- let(:application) { create(:clusters_applications_prometheus) }
- let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
- let(:install_command) { application.install_command }
+ let(:files) { { 'ca.pem': 'some file content' } }
+ let(:repository) { 'https://repository.example.com' }
+ let(:version) { '1.2.3' }
+
+ let(:install_command) do
+ described_class.new(
+ name: 'app-name',
+ chart: 'chart-name',
+ files: files,
+ version: version, repository: repository
+ )
+ end
subject { install_command }
- context 'for ingress' do
- let(:application) { create(:clusters_applications_ingress) }
-
- it_behaves_like 'helm commands' do
- let(:commands) do
- <<~EOS
- helm init --client-only >/dev/null
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
- EOS
- end
+ it_behaves_like 'helm commands' do
+ let(:commands) do
+ <<~EOS
+ helm init --client-only >/dev/null
+ helm repo add app-name https://repository.example.com
+ 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 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
+ EOS
end
end
- context 'for prometheus' do
- let(:application) { create(:clusters_applications_prometheus) }
+ context 'when there is no repository' do
+ let(:repository) { nil }
it_behaves_like 'helm commands' do
let(:commands) do
<<~EOS
helm init --client-only >/dev/null
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ 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 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
EOS
end
end
end
- context 'for runner' do
- let(:ci_runner) { create(:ci_runner) }
- let(:application) { create(:clusters_applications_runner, runner: ci_runner) }
+ 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 --client-only >/dev/null
- helm repo add #{application.name} #{application.repository}
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ helm repo add app-name https://repository.example.com
+ helm install chart-name --name app-name --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
EOS
end
end
end
- context 'for jupyter' do
- let(:application) { create(:clusters_applications_jupyter) }
+ context 'when there is no version' do
+ let(:version) { nil }
it_behaves_like 'helm commands' do
let(:commands) do
<<~EOS
helm init --client-only >/dev/null
- helm repo add #{application.name} #{application.repository}
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ helm repo add app-name https://repository.example.com
+ 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 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
EOS
end
end
end
- describe '#config_map?' do
- subject { install_command.config_map? }
-
- it { is_expected.to be_truthy }
- end
-
describe '#config_map_resource' do
let(:metadata) do
{
- name: "values-content-configuration-#{application.name}",
- namespace: namespace,
- labels: { name: "values-content-configuration-#{application.name}" }
+ name: "values-content-configuration-app-name",
+ namespace: 'gitlab-managed-apps',
+ labels: { name: "values-content-configuration-app-name" }
}
end
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) }
+ let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) }
subject { install_command.config_map_resource }
diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
index 43adc80d576..ec64193c0b2 100644
--- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
@@ -2,14 +2,13 @@ require 'rails_helper'
describe Gitlab::Kubernetes::Helm::Pod do
describe '#generate' do
- let(:cluster) { create(:cluster) }
- let(:app) { create(:clusters_applications_prometheus, cluster: cluster) }
+ let(:app) { create(:clusters_applications_prometheus) }
let(:command) { app.install_command }
let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
subject { described_class.new(command, namespace) }
- shared_examples 'helm pod' do
+ context 'with a command' do
it 'should generate a Kubeclient::Resource' do
expect(subject.generate).to be_a_kind_of(Kubeclient::Resource)
end
@@ -41,10 +40,6 @@ describe Gitlab::Kubernetes::Helm::Pod do
spec = subject.generate.spec
expect(spec.restartPolicy).to eq('Never')
end
- end
-
- context 'with a install command' do
- it_behaves_like 'helm pod'
it 'should include volumes for the container' do
container = subject.generate.spec.containers.first
@@ -60,24 +55,8 @@ describe Gitlab::Kubernetes::Helm::Pod do
it 'should mount configMap specification in the volume' do
volume = subject.generate.spec.volumes.first
expect(volume.configMap['name']).to eq("values-content-configuration-#{app.name}")
- expect(volume.configMap['items'].first['key']).to eq('values')
- expect(volume.configMap['items'].first['path']).to eq('values.yaml')
- end
- end
-
- context 'with a init command' do
- let(:app) { create(:clusters_applications_helm, cluster: cluster) }
-
- it_behaves_like 'helm pod'
-
- it 'should not include volumeMounts inside the container' do
- container = subject.generate.spec.containers.first
- expect(container.volumeMounts).to be_nil
- end
-
- it 'should not a volume inside the specification' do
- spec = subject.generate.spec
- expect(spec.volumes).to be_nil
+ expect(volume.configMap['items'].first['key']).to eq(:'values.yaml')
+ expect(volume.configMap['items'].first['path']).to eq(:'values.yaml')
end
end
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 3512ba6aee5..77b7332a761 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1856,9 +1856,7 @@ describe Ci::Pipeline, :mailer do
context 'when pipeline has builds with test reports' do
before do
- create(:ci_build, pipeline: pipeline, project: project).tap do |build|
- create(:ci_job_artifact, :junit, job: build, project: build.project)
- end
+ create(:ci_build, :test_reports, pipeline: pipeline, project: project)
end
context 'when pipeline status is running' do
@@ -1875,6 +1873,22 @@ describe Ci::Pipeline, :mailer do
end
context 'when pipeline does not have builds with test reports' do
+ before do
+ create(:ci_build, :artifacts, pipeline: pipeline, project: project)
+ end
+
+ let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when retried build has test reports' do
+ before do
+ create(:ci_build, :retried, :test_reports, pipeline: pipeline, project: project)
+ end
+
+ let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+
it { is_expected.to be_falsey }
end
end
@@ -1883,14 +1897,12 @@ describe Ci::Pipeline, :mailer do
subject { pipeline.test_reports }
context 'when pipeline has multiple builds with test reports' do
- before do
- create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project).tap do |build|
- create(:ci_job_artifact, :junit, job: build, project: build.project)
- end
+ let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) }
+ let!(:build_java) { create(:ci_build, :success, name: 'java', pipeline: pipeline, project: project) }
- create(:ci_build, :success, name: 'java', pipeline: pipeline, project: project).tap do |build|
- create(:ci_job_artifact, :junit_with_ant, job: build, project: build.project)
- end
+ before do
+ create(:ci_job_artifact, :junit, job: build_rspec, project: project)
+ create(:ci_job_artifact, :junit_with_ant, job: build_java, project: project)
end
it 'returns test reports with collected data' do
@@ -1898,6 +1910,17 @@ describe Ci::Pipeline, :mailer do
expect(subject.success_count).to be(5)
expect(subject.failed_count).to be(2)
end
+
+ context 'when builds are retried' do
+ let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
+ let!(:build_java) { create(:ci_build, :retried, :success, name: 'java', pipeline: pipeline, project: project) }
+
+ it 'does not take retried builds into account' do
+ expect(subject.total_count).to be(0)
+ expect(subject.success_count).to be(0)
+ expect(subject.failed_count).to be(0)
+ end
+ end
end
context 'when pipeline does not have any builds with test reports' do
diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb
index 0eb1e3876e2..e5b2bdc8a4e 100644
--- a/spec/models/clusters/applications/helm_spec.rb
+++ b/spec/models/clusters/applications/helm_spec.rb
@@ -6,13 +6,24 @@ describe Clusters::Applications::Helm do
describe '.installed' do
subject { described_class.installed }
- let!(:cluster) { create(:clusters_applications_helm, :installed) }
+ let!(:installed_cluster) { create(:clusters_applications_helm, :installed) }
before do
create(:clusters_applications_helm, :errored)
end
- it { is_expected.to contain_exactly(cluster) }
+ it { is_expected.to contain_exactly(installed_cluster) }
+ end
+
+ describe '#issue_client_cert' do
+ let(:application) { create(:clusters_applications_helm) }
+ subject { application.issue_client_cert }
+
+ it 'returns a new cert' do
+ is_expected.to be_kind_of(Gitlab::Kubernetes::Helm::Certificate)
+ expect(subject.cert_string).not_to eq(application.ca_cert)
+ expect(subject.key_string).not_to eq(application.ca_key)
+ end
end
describe '#install_command' do
@@ -25,5 +36,16 @@ describe Clusters::Applications::Helm do
it 'should be initialized with 1 arguments' do
expect(subject.name).to eq('helm')
end
+
+ it 'should have cert files' do
+ expect(subject.files[:'ca.pem']).to be_present
+ expect(subject.files[:'ca.pem']).to eq(helm.ca_cert)
+
+ expect(subject.files[:'cert.pem']).to be_present
+ expect(subject.files[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem'])
+ expect(cert.not_after).to be > 999.years.from_now
+ end
end
end
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
index d378248d5d6..21f75ced8c3 100644
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -88,7 +88,7 @@ describe Clusters::Applications::Ingress do
expect(subject.name).to eq('ingress')
expect(subject.chart).to eq('stable/nginx-ingress')
expect(subject.version).to eq('0.23.0')
- expect(subject.values).to eq(ingress.values)
+ expect(subject.files).to eq(ingress.files)
end
context 'application failed to install previously' do
@@ -100,14 +100,40 @@ describe Clusters::Applications::Ingress do
end
end
- describe '#values' do
- subject { ingress.values }
+ describe '#files' do
+ let(:application) { ingress }
+ let(:values) { subject[:'values.yaml'] }
- it 'should include ingress valid keys' do
- is_expected.to include('image')
- is_expected.to include('repository')
- is_expected.to include('stats')
- is_expected.to include('podAnnotations')
+ subject { application.files }
+
+ it 'should include ingress valid keys in values' do
+ expect(values).to include('image')
+ expect(values).to include('repository')
+ expect(values).to include('stats')
+ expect(values).to include('podAnnotations')
+ end
+
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
end
end
end
diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb
index e0d57ac65f7..027b732681b 100644
--- a/spec/models/clusters/applications/jupyter_spec.rb
+++ b/spec/models/clusters/applications/jupyter_spec.rb
@@ -52,7 +52,7 @@ describe Clusters::Applications::Jupyter do
expect(subject.chart).to eq('jupyter/jupyterhub')
expect(subject.version).to eq('v0.6')
expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/')
- expect(subject.values).to eq(jupyter.values)
+ expect(subject.files).to eq(jupyter.files)
end
context 'application failed to install previously' do
@@ -64,19 +64,43 @@ describe Clusters::Applications::Jupyter do
end
end
- describe '#values' do
- let(:jupyter) { create(:clusters_applications_jupyter) }
+ describe '#files' do
+ let(:application) { create(:clusters_applications_jupyter) }
+ let(:values) { subject[:'values.yaml'] }
- subject { jupyter.values }
+ subject { application.files }
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
+ end
+
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
it 'should include valid values' do
- is_expected.to include('ingress')
- is_expected.to include('hub')
- is_expected.to include('rbac')
- is_expected.to include('proxy')
- is_expected.to include('auth')
- is_expected.to include("clientId: #{jupyter.oauth_application.uid}")
- is_expected.to include("callbackUrl: #{jupyter.callback_url}")
+ expect(values).to include('ingress')
+ expect(values).to include('hub')
+ expect(values).to include('rbac')
+ expect(values).to include('proxy')
+ expect(values).to include('auth')
+ expect(values).to match(/clientId: '?#{application.oauth_application.uid}/)
+ expect(values).to match(/callbackUrl: '?#{application.callback_url}/)
end
end
end
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
index 3812c65b3b6..7454be3ab2f 100644
--- a/spec/models/clusters/applications/prometheus_spec.rb
+++ b/spec/models/clusters/applications/prometheus_spec.rb
@@ -167,7 +167,7 @@ describe Clusters::Applications::Prometheus do
expect(command.name).to eq('prometheus')
expect(command.chart).to eq('stable/prometheus')
expect(command.version).to eq('6.7.3')
- expect(command.values).to eq(prometheus.values)
+ expect(command.files).to eq(prometheus.files)
end
context 'application failed to install previously' do
@@ -179,17 +179,41 @@ describe Clusters::Applications::Prometheus do
end
end
- describe '#values' do
- let(:prometheus) { create(:clusters_applications_prometheus) }
+ describe '#files' do
+ let(:application) { create(:clusters_applications_prometheus) }
+ let(:values) { subject[:'values.yaml'] }
+
+ subject { application.files }
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
+ end
- subject { prometheus.values }
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
it 'should include prometheus valid values' do
- is_expected.to include('alertmanager')
- is_expected.to include('kubeStateMetrics')
- is_expected.to include('nodeExporter')
- is_expected.to include('pushgateway')
- is_expected.to include('serverFiles')
+ expect(values).to include('alertmanager')
+ expect(values).to include('kubeStateMetrics')
+ expect(values).to include('nodeExporter')
+ expect(values).to include('pushgateway')
+ expect(values).to include('serverFiles')
end
end
end
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
index 526300755b5..d84f125e246 100644
--- a/spec/models/clusters/applications/runner_spec.rb
+++ b/spec/models/clusters/applications/runner_spec.rb
@@ -47,7 +47,7 @@ describe Clusters::Applications::Runner do
expect(subject.chart).to eq('runner/gitlab-runner')
expect(subject.version).to eq('0.1.31')
expect(subject.repository).to eq('https://charts.gitlab.io')
- expect(subject.values).to eq(gitlab_runner.values)
+ expect(subject.files).to eq(gitlab_runner.files)
end
context 'application failed to install previously' do
@@ -59,27 +59,51 @@ describe Clusters::Applications::Runner do
end
end
- describe '#values' do
- let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) }
+ describe '#files' do
+ let(:application) { create(:clusters_applications_runner, runner: ci_runner) }
+ let(:values) { subject[:'values.yaml'] }
+
+ subject { application.files }
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
+ end
- subject { gitlab_runner.values }
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
it 'should include runner valid values' do
- is_expected.to include('concurrent')
- is_expected.to include('checkInterval')
- is_expected.to include('rbac')
- is_expected.to include('runners')
- is_expected.to include('privileged: true')
- is_expected.to include('image: ubuntu:16.04')
- is_expected.to include('resources')
- is_expected.to include("runnerToken: #{ci_runner.token}")
- is_expected.to include("gitlabUrl: #{Gitlab::Routing.url_helpers.root_url}")
+ expect(values).to include('concurrent')
+ expect(values).to include('checkInterval')
+ expect(values).to include('rbac')
+ expect(values).to include('runners')
+ expect(values).to include('privileged: true')
+ expect(values).to include('image: ubuntu:16.04')
+ expect(values).to include('resources')
+ expect(values).to match(/runnerToken: '?#{ci_runner.token}/)
+ expect(values).to match(/gitlabUrl: '?#{Gitlab::Routing.url_helpers.root_url}/)
end
context 'without a runner' do
let(:project) { create(:project) }
- let(:cluster) { create(:cluster, projects: [project]) }
- let(:gitlab_runner) { create(:clusters_applications_runner, cluster: cluster) }
+ let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) }
+ let(:application) { create(:clusters_applications_runner, cluster: cluster) }
it 'creates a runner' do
expect do
@@ -88,18 +112,18 @@ describe Clusters::Applications::Runner do
end
it 'uses the new runner token' do
- expect(subject).to include("runnerToken: #{gitlab_runner.reload.runner.token}")
+ expect(values).to match(/runnerToken: '?#{application.reload.runner.token}/)
end
it 'assigns the new runner to runner' do
subject
- expect(gitlab_runner.reload.runner).to be_project_type
+ expect(application.reload.runner).to be_project_type
end
end
context 'with duplicated values on vendor/runner/values.yaml' do
- let(:values) do
+ let(:stub_values) do
{
"concurrent" => 4,
"checkInterval" => 3,
@@ -118,11 +142,11 @@ describe Clusters::Applications::Runner do
end
before do
- allow(gitlab_runner).to receive(:chart_values).and_return(values)
+ allow(application).to receive(:chart_values).and_return(stub_values)
end
it 'should overwrite values.yaml' do
- is_expected.to include("privileged: #{gitlab_runner.privileged}")
+ expect(values).to match(/privileged: '?#{application.privileged}/)
end
end
end
diff --git a/spec/models/concerns/avatarable_spec.rb b/spec/models/concerns/avatarable_spec.rb
index 9faf21bfbbd..76f734079b7 100644
--- a/spec/models/concerns/avatarable_spec.rb
+++ b/spec/models/concerns/avatarable_spec.rb
@@ -43,6 +43,10 @@ describe Avatarable do
expect(project.avatar_path(only_path: only_path)).to eq(avatar_path)
end
+ it 'returns the expected avatar path with width parameter' do
+ expect(project.avatar_path(only_path: only_path, size: 128)).to eq(avatar_path + "?width=128")
+ end
+
context "when avatar is stored remotely" do
before do
stub_uploads_object_storage(AvatarUploader)
diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb
index 79f75c0ffa0..97a4c212f1c 100644
--- a/spec/models/concerns/reactive_caching_spec.rb
+++ b/spec/models/concerns/reactive_caching_spec.rb
@@ -85,6 +85,14 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
it { is_expected.to be_nil }
end
+
+ context 'when cache was invalidated' do
+ it 'refreshes cache' do
+ expect(ReactiveCachingWorker).to receive(:perform_async).with(CacheTest, 666)
+
+ instance.with_reactive_cache { raise described_class::InvalidateReactiveCache }
+ end
+ end
end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 52c52517cca..6258bfa232f 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1204,10 +1204,21 @@ describe MergeRequest do
it 'returns status and data' do
expect_any_instance_of(Ci::CompareTestReportsService)
- .to receive(:execute).with(base_pipeline.iid, head_pipeline.iid)
+ .to receive(:execute).with(base_pipeline, head_pipeline).and_call_original
subject
end
+
+ context 'when cached results is not latest' do
+ before do
+ allow_any_instance_of(Ci::CompareTestReportsService)
+ .to receive(:latest?).and_return(false)
+ end
+
+ it 'raises and InvalidateReactiveCache error' do
+ expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache)
+ end
+ end
end
end
@@ -1354,6 +1365,16 @@ describe MergeRequest do
project.default_branch == branch)
end
+ context 'but merged at timestamp cannot be found' do
+ before do
+ allow(subject).to receive(:merged_at) { nil }
+ end
+
+ it 'returns false' do
+ expect(subject.can_be_reverted?(current_user)).to be_falsey
+ end
+ end
+
context 'when the revert commit is mentioned in a note after the MR was merged' do
it 'returns false' do
expect(subject.can_be_reverted?(current_user)).to be_falsey
@@ -1393,6 +1414,63 @@ describe MergeRequest do
end
end
+ describe '#merged_at' do
+ context 'when MR is not merged' do
+ let(:merge_request) { create(:merge_request, :closed) }
+
+ it 'returns nil' do
+ expect(merge_request.merged_at).to be_nil
+ end
+ end
+
+ context 'when metrics has merged_at data' do
+ let(:merge_request) { create(:merge_request, :merged) }
+
+ before do
+ merge_request.metrics.update!(merged_at: 1.day.ago)
+ end
+
+ it 'returns metrics merged_at' do
+ expect(merge_request.merged_at).to eq(merge_request.metrics.merged_at)
+ end
+ end
+
+ context 'when merged event is persisted, but no metrics merged_at is persisted' do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request, :merged) }
+
+ before do
+ EventCreateService.new.merge_mr(merge_request, user)
+ end
+
+ it 'returns merged event creation date' do
+ expect(merge_request.merge_event).to be_persisted
+ expect(merge_request.merged_at).to eq(merge_request.merge_event.created_at)
+ end
+ end
+
+ context 'when merging note is persisted, but no metrics or merge event exists' do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request, :merged) }
+
+ before do
+ merge_request.metrics.destroy!
+
+ SystemNoteService.change_status(merge_request,
+ merge_request.target_project,
+ user,
+ merge_request.state, nil)
+ end
+
+ it 'returns merging note creation date' do
+ expect(merge_request.reload.metrics).to be_nil
+ expect(merge_request.merge_event).to be_nil
+ expect(merge_request.notes.count).to eq(1)
+ expect(merge_request.merged_at).to eq(merge_request.notes.first.created_at)
+ end
+ end
+ end
+
describe '#participants' do
let(:project) { create(:project, :public) }
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 4313d52d60a..03beb9187ed 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -3307,6 +3307,50 @@ describe Project do
end
end
+ describe '#has_auto_devops_implicitly_enabled?' do
+ set(:project) { create(:project) }
+
+ context 'when disabled in settings' do
+ before do
+ stub_application_setting(auto_devops_enabled: false)
+ end
+
+ it 'does not have auto devops implicitly disabled' do
+ expect(project).not_to have_auto_devops_implicitly_enabled
+ end
+ end
+
+ context 'when enabled in settings' do
+ before do
+ stub_application_setting(auto_devops_enabled: true)
+ end
+
+ it 'auto devops is implicitly disabled' do
+ expect(project).to have_auto_devops_implicitly_enabled
+ end
+
+ context 'when explicitly disabled' do
+ before do
+ create(:project_auto_devops, project: project, enabled: false)
+ end
+
+ it 'does not have auto devops implicitly disabled' do
+ expect(project).not_to have_auto_devops_implicitly_enabled
+ end
+ end
+
+ context 'when explicitly enabled' do
+ before do
+ create(:project_auto_devops, project: project, enabled: true)
+ end
+
+ it 'does not have auto devops implicitly disabled' do
+ expect(project).not_to have_auto_devops_implicitly_enabled
+ end
+ end
+ end
+ end
+
describe '#has_auto_devops_implicitly_disabled?' do
set(:project) { create(:project) }
@@ -3341,7 +3385,7 @@ describe Project do
context 'when explicitly enabled' do
before do
- create(:project_auto_devops, project: project)
+ create(:project_auto_devops, project: project, enabled: true)
end
it 'does not have auto devops implicitly disabled' do
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index bd498269798..f29abcf536e 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -7,6 +7,7 @@ describe Todo do
it { is_expected.to belong_to(:author).class_name("User") }
it { is_expected.to belong_to(:note) }
it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:target).touch(true) }
it { is_expected.to belong_to(:user) }
end
diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb
index 2ee8d150dc8..b5cf04e7f22 100644
--- a/spec/requests/api/todos_spec.rb
+++ b/spec/requests/api/todos_spec.rb
@@ -1,7 +1,8 @@
require 'spec_helper'
describe API::Todos do
- let(:project_1) { create(:project, :repository) }
+ let(:group) { create(:group) }
+ let(:project_1) { create(:project, :repository, group: group) }
let(:project_2) { create(:project) }
let(:author_1) { create(:user) }
let(:author_2) { create(:user) }
@@ -92,6 +93,17 @@ describe API::Todos do
end
end
+ context 'and using the group filter' do
+ it 'filters based on project_id param' do
+ get api('/todos', john_doe), { group_id: group.id, sort: :target_id }
+
+ expect(response.status).to eq(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ end
+ end
+
context 'and using the action filter' do
it 'filters based on action param' do
get api('/todos', john_doe), { action: 'mentioned' }
diff --git a/spec/services/ci/compare_test_reports_service_spec.rb b/spec/services/ci/compare_test_reports_service_spec.rb
index d3bbf17cc5c..a26c970a8f0 100644
--- a/spec/services/ci/compare_test_reports_service_spec.rb
+++ b/spec/services/ci/compare_test_reports_service_spec.rb
@@ -5,7 +5,7 @@ describe Ci::CompareTestReportsService do
let(:project) { create(:project, :repository) }
describe '#execute' do
- subject { service.execute(base_pipeline&.iid, head_pipeline.iid) }
+ subject { service.execute(base_pipeline, head_pipeline) }
context 'when head pipeline has test reports' do
let!(:base_pipeline) { nil }
@@ -42,4 +42,34 @@ describe Ci::CompareTestReportsService do
end
end
end
+
+ describe '#latest?' do
+ subject { service.latest?(base_pipeline, head_pipeline, data) }
+
+ let!(:base_pipeline) { nil }
+ let!(:head_pipeline) { create(:ci_pipeline, :with_test_reports, project: project) }
+ let!(:key) { service.send(:key, base_pipeline, head_pipeline) }
+
+ context 'when cache key is latest' do
+ let(:data) { { key: key } }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when cache key is outdated' do
+ before do
+ head_pipeline.update_column(:updated_at, 10.minutes.ago)
+ end
+
+ let(:data) { { key: key } }
+
+ it { is_expected.to be_falsy }
+ end
+
+ context 'when cache key is empty' do
+ let(:data) { { key: nil } }
+
+ it { is_expected.to be_falsy }
+ end
+ end
end
diff --git a/spec/services/clusters/applications/install_service_spec.rb b/spec/services/clusters/applications/install_service_spec.rb
index 93199964a0e..a744ec30b65 100644
--- a/spec/services/clusters/applications/install_service_spec.rb
+++ b/spec/services/clusters/applications/install_service_spec.rb
@@ -47,7 +47,7 @@ describe Clusters::Applications::InstallService do
end
context 'when application cannot be persisted' do
- let(:application) { build(:clusters_applications_helm, :scheduled) }
+ let(:application) { create(:clusters_applications_helm, :scheduled) }
it 'make the application errored' do
expect(application).to receive(:make_installing!).once.and_raise(ActiveRecord::RecordInvalid)
diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb
index 48d689e11d4..7c5c7409cc1 100644
--- a/spec/services/groups/update_service_spec.rb
+++ b/spec/services/groups/update_service_spec.rb
@@ -12,13 +12,17 @@ describe Groups::UpdateService do
let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL) }
before do
- public_group.add_user(user, Gitlab::Access::MAINTAINER)
+ public_group.add_user(user, Gitlab::Access::OWNER)
create(:project, :public, group: public_group)
+
+ expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in)
end
it "does not change permission level" do
service.execute
expect(public_group.errors.count).to eq(1)
+
+ expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in)
end
end
@@ -26,8 +30,10 @@ describe Groups::UpdateService do
let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
before do
- internal_group.add_user(user, Gitlab::Access::MAINTAINER)
+ internal_group.add_user(user, Gitlab::Access::OWNER)
create(:project, :internal, group: internal_group)
+
+ expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in)
end
it "does not change permission level" do
@@ -35,6 +41,24 @@ describe Groups::UpdateService do
expect(internal_group.errors.count).to eq(1)
end
end
+
+ context "internal group with private project" do
+ let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+
+ before do
+ internal_group.add_user(user, Gitlab::Access::OWNER)
+ create(:project, :private, group: internal_group)
+
+ expect(TodosDestroyer::GroupPrivateWorker).to receive(:perform_in)
+ .with(1.hour, internal_group.id)
+ end
+
+ it "changes permission level to private" do
+ service.execute
+ expect(internal_group.visibility_level)
+ .to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
+ end
end
context "with parent_id user doesn't have permissions for" do
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index fd69fe04053..bb3f1501f0e 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -114,6 +114,17 @@ describe Projects::CreateService, '#execute' do
end
end
+ context 'import data' do
+ it 'stores import data and URL' do
+ import_data = { data: { 'test' => 'some data' } }
+ project = create_project(user, { name: 'test', import_url: 'http://import-url', import_data: import_data })
+
+ expect(project.import_data).to be_persisted
+ expect(project.import_data.data).to eq(import_data[:data])
+ expect(project.import_url).to eq('http://import-url')
+ end
+ end
+
context 'builds_enabled global setting' do
let(:project) { create_project(user, opts) }
diff --git a/spec/services/todos/destroy/confidential_issue_service_spec.rb b/spec/services/todos/destroy/confidential_issue_service_spec.rb
index 54d1d7e83f1..3294f7509aa 100644
--- a/spec/services/todos/destroy/confidential_issue_service_spec.rb
+++ b/spec/services/todos/destroy/confidential_issue_service_spec.rb
@@ -29,12 +29,8 @@ describe Todos::Destroy::ConfidentialIssueService do
issue.update!(confidential: true)
end
- it 'removes issue todos for a user who is not a project member' do
+ it 'removes issue todos for users who can not access the confidential issue' do
expect { subject }.to change { Todo.count }.from(6).to(4)
-
- expect(user.todos).to match_array([todo_another_non_member])
- expect(author.todos).to match_array([todo_issue_author])
- expect(project_member.todos).to match_array([todo_issue_member])
end
end
diff --git a/spec/services/todos/destroy/entity_leave_service_spec.rb b/spec/services/todos/destroy/entity_leave_service_spec.rb
index bad408a314e..8cb91e7c1b9 100644
--- a/spec/services/todos/destroy/entity_leave_service_spec.rb
+++ b/spec/services/todos/destroy/entity_leave_service_spec.rb
@@ -5,60 +5,120 @@ describe Todos::Destroy::EntityLeaveService do
let(:project) { create(:project, group: group) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
- let(:issue) { create(:issue, project: project) }
+ let(:issue) { create(:issue, project: project, confidential: true) }
let(:mr) { create(:merge_request, source_project: project) }
let!(:todo_mr_user) { create(:todo, user: user, target: mr, project: project) }
let!(:todo_issue_user) { create(:todo, user: user, target: issue, project: project) }
+ let!(:todo_group_user) { create(:todo, user: user, group: group) }
let!(:todo_issue_user2) { create(:todo, user: user2, target: issue, project: project) }
+ let!(:todo_group_user2) { create(:todo, user: user2, group: group) }
describe '#execute' do
context 'when a user leaves a project' do
subject { described_class.new(user.id, project.id, 'Project').execute }
context 'when project is private' do
- it 'removes todos for the provided user' do
- expect { subject }.to change { Todo.count }.from(3).to(1)
+ it 'removes project todos for the provided user' do
+ expect { subject }.to change { Todo.count }.from(5).to(3)
- expect(user.todos).to be_empty
- expect(user2.todos).to match_array([todo_issue_user2])
+ expect(user.todos).to match_array([todo_group_user])
+ expect(user2.todos).to match_array([todo_issue_user2, todo_group_user2])
end
- end
- context 'when project is not private' do
- before do
- group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
- project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ context 'when the user is member of the project' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
- context 'when a user is not an author of confidential issue' do
+ context 'when the user is a project guest' do
before do
- issue.update!(confidential: true)
+ project.add_guest(user)
end
it 'removes only confidential issues todos' do
- expect { subject }.to change { Todo.count }.from(3).to(2)
+ expect { subject }.to change { Todo.count }.from(5).to(4)
end
end
- context 'when a user is an author of confidential issue' do
+ context 'when the user is member of a parent group' do
before do
- issue.update!(author: user, confidential: true)
+ group.add_developer(user)
end
- it 'removes only confidential issues todos' do
+ it 'does not remove any todos' do
expect { subject }.not_to change { Todo.count }
end
end
- context 'when a user is an assignee of confidential issue' do
+ context 'when the user is guest of a parent group' do
before do
- issue.update!(confidential: true)
- issue.assignees << user
+ project.add_guest(user)
end
it 'removes only confidential issues todos' do
- expect { subject }.not_to change { Todo.count }
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+ end
+
+ context 'when project is not private' do
+ before do
+ group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ end
+
+ context 'confidential issues' do
+ context 'when a user is not an author of confidential issue' do
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when a user is an author of confidential issue' do
+ before do
+ issue.update!(author: user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when a user is an assignee of confidential issue' do
+ before do
+ issue.assignees << user
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when a user is a project guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when a user is a project guest but group developer' do
+ before do
+ project.add_guest(user)
+ group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
end
@@ -69,7 +129,7 @@ describe Todos::Destroy::EntityLeaveService do
end
it 'removes only users issue todos' do
- expect { subject }.to change { Todo.count }.from(3).to(2)
+ expect { subject }.to change { Todo.count }.from(5).to(4)
end
end
end
@@ -80,40 +140,135 @@ describe Todos::Destroy::EntityLeaveService do
subject { described_class.new(user.id, group.id, 'Group').execute }
context 'when group is private' do
- it 'removes todos for the user' do
- expect { subject }.to change { Todo.count }.from(3).to(1)
+ it 'removes group and subproject todos for the user' do
+ expect { subject }.to change { Todo.count }.from(5).to(2)
expect(user.todos).to be_empty
- expect(user2.todos).to match_array([todo_issue_user2])
+ expect(user2.todos).to match_array([todo_issue_user2, todo_group_user2])
+ end
+
+ context 'when the user is member of the group' do
+ before do
+ group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when the user is member of the group project but not the group' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
context 'with nested groups', :nested_groups do
let(:subgroup) { create(:group, :private, parent: group) }
+ let(:subgroup2) { create(:group, :private, parent: group) }
let(:subproject) { create(:project, group: subgroup) }
+ let(:subproject2) { create(:project, group: subgroup2) }
- let!(:todo_subproject_user) { create(:todo, user: user, project: subproject) }
+ let!(:todo_subproject_user) { create(:todo, user: user, project: subproject) }
+ let!(:todo_subproject2_user) { create(:todo, user: user, project: subproject2) }
+ let!(:todo_subgroup_user) { create(:todo, user: user, group: subgroup) }
+ let!(:todo_subgroup2_user) { create(:todo, user: user, group: subgroup2) }
let!(:todo_subproject_user2) { create(:todo, user: user2, project: subproject) }
+ let!(:todo_subpgroup_user2) { create(:todo, user: user2, group: subgroup) }
+
+ context 'when the user is not a member of any groups/projects' do
+ it 'removes todos for the user including subprojects todos' do
+ expect { subject }.to change { Todo.count }.from(11).to(4)
+
+ expect(user.todos).to be_empty
+ expect(user2.todos)
+ .to match_array(
+ [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2]
+ )
+ end
+ end
+
+ context 'when the user is member of a parent group' do
+ before do
+ parent_group = create(:group)
+ group.update!(parent: parent_group)
+ parent_group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when the user is member of a subgroup' do
+ before do
+ subgroup.add_developer(user)
+ end
- it 'removes todos for the user including subprojects todos' do
- expect { subject }.to change { Todo.count }.from(5).to(2)
+ it 'does not remove group and subproject todos' do
+ expect { subject }.to change { Todo.count }.from(11).to(7)
- expect(user.todos).to be_empty
- expect(user2.todos)
- .to match_array([todo_issue_user2, todo_subproject_user2])
+ expect(user.todos).to match_array([todo_group_user, todo_subgroup_user, todo_subproject_user])
+ expect(user2.todos)
+ .to match_array(
+ [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2]
+ )
+ end
+ end
+
+ context 'when the user is member of a child project' do
+ before do
+ subproject.add_developer(user)
+ end
+
+ it 'does not remove subproject and group todos' do
+ expect { subject }.to change { Todo.count }.from(11).to(7)
+
+ expect(user.todos).to match_array([todo_subgroup_user, todo_group_user, todo_subproject_user])
+ expect(user2.todos)
+ .to match_array(
+ [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2]
+ )
+ end
end
end
end
context 'when group is not private' do
before do
- issue.update!(confidential: true)
-
group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
end
- it 'removes only confidential issues todos' do
- expect { subject }.to change { Todo.count }.from(3).to(2)
+ context 'when user is not member' do
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when user is a project guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when user is a project guest & group developer' do
+ before do
+ project.add_guest(user)
+ group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
end
end
diff --git a/spec/services/todos/destroy/group_private_service_spec.rb b/spec/services/todos/destroy/group_private_service_spec.rb
new file mode 100644
index 00000000000..2f49b68f544
--- /dev/null
+++ b/spec/services/todos/destroy/group_private_service_spec.rb
@@ -0,0 +1,69 @@
+require 'spec_helper'
+
+describe Todos::Destroy::GroupPrivateService do
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, group: group) }
+ let(:user) { create(:user) }
+ let(:group_member) { create(:user) }
+ let(:project_member) { create(:user) }
+
+ let!(:todo_non_member) { create(:todo, user: user, group: group) }
+ let!(:todo_another_non_member) { create(:todo, user: user, group: group) }
+ let!(:todo_group_member) { create(:todo, user: group_member, group: group) }
+ let!(:todo_project_member) { create(:todo, user: project_member, group: group) }
+
+ describe '#execute' do
+ before do
+ group.add_developer(group_member)
+ project.add_developer(project_member)
+ end
+
+ subject { described_class.new(group.id).execute }
+
+ context 'when a group set to private' do
+ before do
+ group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it 'removes todos only for users who are not group users' do
+ expect { subject }.to change { Todo.count }.from(4).to(2)
+
+ expect(user.todos).to be_empty
+ expect(group_member.todos).to match_array([todo_group_member])
+ expect(project_member.todos).to match_array([todo_project_member])
+ end
+
+ context 'with nested groups', :nested_groups do
+ let(:parent_group) { create(:group) }
+ let(:subgroup) { create(:group, :private, parent: group) }
+ let(:subproject) { create(:project, group: subgroup) }
+
+ let(:parent_member) { create(:user) }
+ let(:subgroup_member) { create(:user) }
+ let(:subgproject_member) { create(:user) }
+
+ let!(:todo_parent_member) { create(:todo, user: parent_member, group: group) }
+ let!(:todo_subgroup_member) { create(:todo, user: subgroup_member, group: group) }
+ let!(:todo_subproject_member) { create(:todo, user: subgproject_member, group: group) }
+
+ before do
+ group.update!(parent: parent_group)
+
+ parent_group.add_developer(parent_member)
+ subgroup.add_developer(subgroup_member)
+ subproject.add_developer(subgproject_member)
+ end
+
+ it 'removes todos only for users who are not group users' do
+ expect { subject }.to change { Todo.count }.from(7).to(5)
+ end
+ end
+ end
+
+ context 'when group is not private' do
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/todos/destroy/project_private_service_spec.rb b/spec/services/todos/destroy/project_private_service_spec.rb
index badf3f913a5..128d3487514 100644
--- a/spec/services/todos/destroy/project_private_service_spec.rb
+++ b/spec/services/todos/destroy/project_private_service_spec.rb
@@ -1,17 +1,21 @@
require 'spec_helper'
describe Todos::Destroy::ProjectPrivateService do
- let(:project) { create(:project, :public) }
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, :public, group: group) }
let(:user) { create(:user) }
let(:project_member) { create(:user) }
+ let(:group_member) { create(:user) }
- let!(:todo_issue_non_member) { create(:todo, user: user, project: project) }
- let!(:todo_issue_member) { create(:todo, user: project_member, project: project) }
- let!(:todo_another_non_member) { create(:todo, user: user, project: project) }
+ let!(:todo_non_member) { create(:todo, user: user, project: project) }
+ let!(:todo2_non_member) { create(:todo, user: user, project: project) }
+ let!(:todo_member) { create(:todo, user: project_member, project: project) }
+ let!(:todo_group_member) { create(:todo, user: group_member, project: project) }
describe '#execute' do
before do
project.add_developer(project_member)
+ group.add_developer(group_member)
end
subject { described_class.new(project.id).execute }
@@ -22,10 +26,11 @@ describe Todos::Destroy::ProjectPrivateService do
end
it 'removes issue todos for a user who is not a member' do
- expect { subject }.to change { Todo.count }.from(3).to(1)
+ expect { subject }.to change { Todo.count }.from(4).to(2)
expect(user.todos).to be_empty
- expect(project_member.todos).to match_array([todo_issue_member])
+ expect(project_member.todos).to match_array([todo_member])
+ expect(group_member.todos).to match_array([todo_group_member])
end
end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 8e1d4cfe269..f392660d2c7 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -8,7 +8,7 @@ module TestEnv
# When developing the seed repository, comment out the branch you will modify.
BRANCH_SHA = {
- 'signed-commits' => '2d1096e',
+ 'signed-commits' => '6101e87',
'not-merged-branch' => 'b83d6e3',
'branch-merged' => '498214d',
'empty-branch' => '7efb185',
@@ -51,7 +51,8 @@ module TestEnv
'add-pdf-text-binary' => '79faa7b',
'add_images_and_changes' => '010d106',
'update-gitlab-shell-v-6-0-1' => '2f61d70',
- 'update-gitlab-shell-v-6-0-3' => 'de78448'
+ 'update-gitlab-shell-v-6-0-3' => 'de78448',
+ '2-mb-file' => 'bf12d25'
}.freeze
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
diff --git a/spec/support/shared_examples/controllers/todos_shared_examples.rb b/spec/support/shared_examples/controllers/todos_shared_examples.rb
new file mode 100644
index 00000000000..bafd9bac8d0
--- /dev/null
+++ b/spec/support/shared_examples/controllers/todos_shared_examples.rb
@@ -0,0 +1,43 @@
+shared_examples 'todos actions' do
+ context 'when authorized' do
+ before do
+ sign_in(user)
+ parent.add_developer(user)
+ end
+
+ it 'creates todo' do
+ expect do
+ post_create
+ end.to change { user.todos.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns todo path and pending count' do
+ post_create
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['count']).to eq 1
+ expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
+ end
+ end
+
+ context 'when not authorized for project/group' do
+ it 'does not create todo for resource that user has no access to' do
+ sign_in(user)
+ expect do
+ post_create
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'does not create todo when user is not logged in' do
+ expect do
+ post_create
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(parent.is_a?(Group) ? 401 : 302)
+ end
+ end
+end
diff --git a/spec/workers/todos_destroyer/group_private_worker_spec.rb b/spec/workers/todos_destroyer/group_private_worker_spec.rb
new file mode 100644
index 00000000000..fcc38989ced
--- /dev/null
+++ b/spec/workers/todos_destroyer/group_private_worker_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+
+describe TodosDestroyer::GroupPrivateWorker do
+ it "calls the Todos::Destroy::GroupPrivateService with the params it was given" do
+ service = double
+
+ expect(::Todos::Destroy::GroupPrivateService).to receive(:new).with(100).and_return(service)
+ expect(service).to receive(:execute)
+
+ described_class.new.perform(100)
+ end
+end
diff --git a/vendor/Dockerfile/Node-alpine.Dockerfile b/vendor/Dockerfile/Node-alpine.Dockerfile
index 9776b1336b5..5b9b495644a 100644
--- a/vendor/Dockerfile/Node-alpine.Dockerfile
+++ b/vendor/Dockerfile/Node-alpine.Dockerfile
@@ -1,14 +1,15 @@
-FROM node:7.9-alpine
+FROM node:8.11-alpine
WORKDIR /usr/src/app
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
+
COPY package.json /usr/src/app/
-RUN npm install && npm cache clean
-COPY . /usr/src/app
+RUN npm install
-CMD [ "npm", "start" ]
+COPY . /usr/src/app
# replace this with your application's default port
EXPOSE 8888
+CMD [ "npm", "start" ]
diff --git a/vendor/Dockerfile/Node.Dockerfile b/vendor/Dockerfile/Node.Dockerfile
index 7e936d5e887..e8b64b3a6e4 100644
--- a/vendor/Dockerfile/Node.Dockerfile
+++ b/vendor/Dockerfile/Node.Dockerfile
@@ -1,14 +1,15 @@
-FROM node:7.9
+FROM node:8.11
WORKDIR /usr/src/app
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
+
COPY package.json /usr/src/app/
-RUN npm install && npm cache clean
-COPY . /usr/src/app
+RUN npm install
-CMD [ "npm", "start" ]
+COPY . /usr/src/app
# replace this with your application's default port
EXPOSE 8888
+CMD [ "npm", "start" ] \ No newline at end of file
diff --git a/vendor/Dockerfile/Ruby-alpine.Dockerfile b/vendor/Dockerfile/Ruby-alpine.Dockerfile
index 9db4e2130f2..dffe9a65116 100644
--- a/vendor/Dockerfile/Ruby-alpine.Dockerfile
+++ b/vendor/Dockerfile/Ruby-alpine.Dockerfile
@@ -1,8 +1,8 @@
-FROM ruby:2.4-alpine
+FROM ruby:2.5-alpine
# Edit with nodejs, mysql-client, postgresql-client, sqlite3, etc. for your needs.
# Or delete entirely if not needed.
-RUN apk --no-cache add nodejs postgresql-client
+RUN apk --no-cache add nodejs postgresql-client tzdata
# throw errors if Gemfile has been modified since Gemfile.lock
RUN bundle config --global frozen 1
@@ -11,7 +11,10 @@ RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY Gemfile Gemfile.lock /usr/src/app/
-RUN bundle install
+# Install build dependencies - required for gems with native dependencies
+RUN apk add --no-cache --virtual build-deps build-base postgresql-dev && \
+ bundle install && \
+ apk del build-deps
COPY . /usr/src/app
@@ -21,4 +24,4 @@ COPY . /usr/src/app
# For Rails
EXPOSE 3000
-CMD ["rails", "server"]
+CMD ["bundle", "exec", "rails", "server"]
diff --git a/vendor/Dockerfile/Ruby.Dockerfile b/vendor/Dockerfile/Ruby.Dockerfile
index feb880ee4b2..289ed57bfa2 100644
--- a/vendor/Dockerfile/Ruby.Dockerfile
+++ b/vendor/Dockerfile/Ruby.Dockerfile
@@ -1,4 +1,4 @@
-FROM ruby:2.4
+FROM ruby:2.5
# Edit with nodejs, mysql-client, postgresql-client, sqlite3, etc. for your needs.
# Or delete entirely if not needed.
@@ -24,4 +24,4 @@ COPY . /usr/src/app
# For Rails
EXPOSE 3000
-CMD ["rails", "server", "-b", "0.0.0.0"]
+CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
diff --git a/vendor/gitignore/Autotools.gitignore b/vendor/gitignore/Autotools.gitignore
index 96d6ed2cfea..f4f545c9ca4 100644
--- a/vendor/gitignore/Autotools.gitignore
+++ b/vendor/gitignore/Autotools.gitignore
@@ -16,6 +16,8 @@ autom4te.cache
/compile
/config.guess
/config.h.in
+/config.log
+/config.status
/config.sub
/configure
/configure.scan
diff --git a/vendor/gitignore/Laravel.gitignore b/vendor/gitignore/Laravel.gitignore
index a4854bef534..67e2146f2bc 100644
--- a/vendor/gitignore/Laravel.gitignore
+++ b/vendor/gitignore/Laravel.gitignore
@@ -1,6 +1,7 @@
vendor/
node_modules/
npm-debug.log
+yarn-error.log
# Laravel 4 specific
bootstrap/compiled.php
@@ -10,11 +11,7 @@ app/storage/
public/storage
public/hot
storage/*.key
-.env.*.php
-.env.php
.env
Homestead.yaml
Homestead.json
-
-# Rocketeer PHP task runner and deployment package. https://github.com/rocketeers/rocketeer
-.rocketeer/
+/.vagrant
diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore
index f431ddc7cf5..94b41b913fb 100644
--- a/vendor/gitignore/VisualStudio.gitignore
+++ b/vendor/gitignore/VisualStudio.gitignore
@@ -59,7 +59,7 @@ StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
-*_i.h
+*_h.h
*.ilk
*.meta
*.obj
@@ -327,3 +327,6 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
diff --git a/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml b/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml
index d5ee7ed2c13..5f9c9b2c965 100644
--- a/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml
@@ -17,7 +17,7 @@
variables:
# This will supress any download for dependencies and plugins or upload messages which would clutter the console log.
# `showDateTime` will show the passed time in milliseconds. You need to specify `--batch-mode` to make this work.
- MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=WARN -Dorg.slf4j.simpleLogger.showDateTime=true -Djava.awt.headless=true"
+ MAVEN_OPTS: "-Dhttps.protocols=TLSv1.2 -Dmaven.repo.local=.m2/repository -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=WARN -Dorg.slf4j.simpleLogger.showDateTime=true -Djava.awt.headless=true"
# As of Maven 3.3.0 instead of this you may define these options in `.mvn/maven.config` so the same config is used
# when running from the command line.
# `installAtEnd` and `deployAtEnd` are only effective with recent version of the corresponding plugins.
diff --git a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml
index ff7bdd32239..93cb31f48c0 100644
--- a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml
@@ -1,6 +1,6 @@
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/ruby/tags/
-image: "ruby:2.4"
+image: "ruby:2.5"
# Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
diff --git a/vendor/licenses.csv b/vendor/licenses.csv
index 7503160baa0..a462daf3067 100644
--- a/vendor/licenses.csv
+++ b/vendor/licenses.csv
@@ -7,28 +7,29 @@
@babel/template,7.0.0-beta.44,MIT
@babel/traverse,7.0.0-beta.44,MIT
@babel/types,7.0.0-beta.44,MIT
-@gitlab-org/gitlab-svgs,1.25.0,SEE LICENSE IN LICENSE
+@gitlab-org/gitlab-svgs,1.27.0,SEE LICENSE IN LICENSE
+@gitlab-org/gitlab-ui,1.0.5,UNKNOWN
@sindresorhus/is,0.7.0,MIT
@types/jquery,2.0.48,MIT
@vue/component-compiler-utils,1.2.1,MIT
-@webassemblyjs/ast,1.5.10,MIT
-@webassemblyjs/floating-point-hex-parser,1.5.10,MIT
-@webassemblyjs/helper-api-error,1.5.10,MIT
-@webassemblyjs/helper-buffer,1.5.10,MIT
-@webassemblyjs/helper-code-frame,1.5.10,MIT
-@webassemblyjs/helper-fsm,1.5.10,ISC
-@webassemblyjs/helper-module-context,1.5.10,MIT
-@webassemblyjs/helper-wasm-bytecode,1.5.10,MIT
-@webassemblyjs/helper-wasm-section,1.5.10,MIT
-@webassemblyjs/ieee754,1.5.10,Unknown
-@webassemblyjs/leb128,1.5.10,Apache 2.0
-@webassemblyjs/utf8,1.5.10,MIT
-@webassemblyjs/wasm-edit,1.5.10,MIT
-@webassemblyjs/wasm-gen,1.5.10,MIT
-@webassemblyjs/wasm-opt,1.5.10,MIT
-@webassemblyjs/wasm-parser,1.5.10,MIT
-@webassemblyjs/wast-parser,1.5.10,MIT
-@webassemblyjs/wast-printer,1.5.10,MIT
+@webassemblyjs/ast,1.5.13,MIT
+@webassemblyjs/floating-point-hex-parser,1.5.13,MIT
+@webassemblyjs/helper-api-error,1.5.13,MIT
+@webassemblyjs/helper-buffer,1.5.13,MIT
+@webassemblyjs/helper-code-frame,1.5.13,MIT
+@webassemblyjs/helper-fsm,1.5.13,ISC
+@webassemblyjs/helper-module-context,1.5.13,MIT
+@webassemblyjs/helper-wasm-bytecode,1.5.13,MIT
+@webassemblyjs/helper-wasm-section,1.5.13,MIT
+@webassemblyjs/ieee754,1.5.13,MIT
+@webassemblyjs/leb128,1.5.13,MIT
+@webassemblyjs/utf8,1.5.13,MIT
+@webassemblyjs/wasm-edit,1.5.13,MIT
+@webassemblyjs/wasm-gen,1.5.13,MIT
+@webassemblyjs/wasm-opt,1.5.13,MIT
+@webassemblyjs/wasm-parser,1.5.13,MIT
+@webassemblyjs/wast-parser,1.5.13,MIT
+@webassemblyjs/wast-printer,1.5.13,MIT
RedCloth,4.3.2,MIT
abbrev,1.0.9,ISC
abbrev,1.1.1,ISC
@@ -36,6 +37,7 @@ accepts,1.3.4,MIT
ace-rails-ap,4.1.2,MIT
acorn,3.3.0,MIT
acorn,5.6.2,MIT
+acorn,5.7.1,MIT
acorn-dynamic-import,3.0.0,MIT
acorn-jsx,3.0.1,MIT
actionmailer,4.2.10,MIT
@@ -50,31 +52,29 @@ addressable,2.5.2,Apache 2.0
addressparser,1.0.1,MIT
aes_key_wrap,1.0.1,MIT
after,0.8.2,MIT
-agent-base,2.1.1,MIT
+agent-base,4.2.1,MIT
ajv,5.5.2,MIT
ajv,6.1.1,MIT
ajv-keywords,2.1.1,MIT
ajv-keywords,3.1.0,MIT
akismet,2.0.0,MIT
align-text,0.1.4,MIT
-alphanum-sort,1.0.2,MIT
amdefine,1.0.1,BSD-3-Clause OR MIT
amqplib,0.5.2,MIT
ansi-align,2.0.0,ISC
+ansi-escapes,1.4.0,MIT
ansi-escapes,3.0.0,MIT
ansi-html,0.0.7,Apache 2.0
ansi-regex,2.1.1,MIT
ansi-regex,3.0.0,MIT
ansi-styles,2.2.1,MIT
ansi-styles,3.2.1,MIT
-anymatch,1.3.2,ISC
anymatch,2.0.0,ISC
append-transform,0.4.0,MIT
aproba,1.2.0,ISC
are-we-there-yet,1.1.4,ISC
arel,6.0.4,MIT
argparse,1.0.9,MIT
-arr-diff,2.0.0,MIT
arr-diff,4.0.0,MIT
arr-flatten,1.1.0,MIT
arr-union,3.1.0,MIT
@@ -102,8 +102,8 @@ asset_sync,2.4.0,MIT
assign-symbols,1.0.0,MIT
ast-types,0.11.3,MIT
async,1.5.2,MIT
-async,2.1.5,MIT
async,2.6.0,MIT
+async,2.6.1,MIT
async-each,1.0.1,MIT
async-limiter,1.0.0,MIT
asynckit,0.4.0,MIT
@@ -111,7 +111,6 @@ atob,2.0.3,(MIT OR Apache-2.0)
atomic,1.1.99,Apache 2.0
attr_encrypted,3.1.0,MIT
attr_required,1.0.0,MIT
-autoprefixer,6.7.7,MIT
autosize,4.0.0,MIT
aws-sign2,0.6.0,Apache 2.0
aws-sign2,0.7.0,Apache 2.0
@@ -138,7 +137,7 @@ babel-helper-regex,6.26.0,MIT
babel-helper-remap-async-to-generator,6.24.1,MIT
babel-helper-replace-supers,6.24.1,MIT
babel-helpers,6.24.1,MIT
-babel-loader,7.1.4,MIT
+babel-loader,7.1.5,MIT
babel-messages,6.23.0,MIT
babel-plugin-check-es2015-constants,6.22.0,MIT
babel-plugin-istanbul,4.1.6,New BSD
@@ -182,6 +181,7 @@ babel-plugin-transform-exponentiation-operator,6.24.1,MIT
babel-plugin-transform-object-rest-spread,6.23.0,MIT
babel-plugin-transform-regenerator,6.26.0,MIT
babel-plugin-transform-strict-mode,6.24.1,MIT
+babel-polyfill,6.23.0,MIT
babel-preset-es2015,6.24.1,MIT
babel-preset-es2016,6.24.1,MIT
babel-preset-es2017,6.24.1,MIT
@@ -197,7 +197,6 @@ babosa,1.0.2,MIT
babylon,6.18.0,MIT
babylon,7.0.0-beta.44,MIT
backo2,1.0.2,MIT
-balanced-match,0.4.2,MIT
balanced-match,1.0.0,MIT
base,0.11.2,MIT
base32,0.3.2,MIT
@@ -213,8 +212,9 @@ better-assert,1.0.2,MIT
bfj-node4,5.2.1,MIT
big.js,3.1.3,MIT
binary-extensions,1.11.0,MIT
+binaryextensions,2.1.1,MIT
bindata,2.4.3,ruby
-bitsyntax,0.0.4,Unknown
+bitsyntax,0.0.4,UNKNOWN
bl,1.1.2,MIT
blackst0ne-mermaid,7.1.0-fixed,MIT
blob,0.0.4,MIT*
@@ -226,11 +226,12 @@ boom,2.10.1,New BSD
boom,4.3.1,New BSD
boom,5.2.0,New BSD
bootstrap,4.1.1,MIT
+bootstrap,4.1.2,MIT
+bootstrap-vue,2.0.0-rc.11,MIT
bootstrap_form,2.7.0,MIT
boxen,1.3.0,MIT
brace-expansion,1.1.11,MIT
braces,0.1.5,MIT
-braces,1.8.5,MIT
braces,2.3.1,MIT
brorand,1.1.0,MIT
browser,2.2.0,MIT
@@ -240,7 +241,6 @@ browserify-des,1.0.0,MIT
browserify-rsa,4.0.1,MIT
browserify-sign,4.0.4,ISC
browserify-zlib,0.2.0,MIT
-browserslist,1.7.7,MIT
buffer,4.9.1,MIT
buffer-from,1.0.0,MIT
buffer-indexof,1.1.0,MIT
@@ -263,8 +263,6 @@ camelcase,1.2.1,MIT
camelcase,2.1.1,MIT
camelcase,4.1.0,MIT
camelcase-keys,2.1.0,MIT
-caniuse-api,1.6.1,MIT
-caniuse-db,1.0.30000649,CC-BY-4.0
capture-stack-trace,1.0.0,MIT
carrierwave,1.2.3,MIT
caseless,0.11.0,Apache 2.0
@@ -274,22 +272,22 @@ center-align,0.1.3,MIT
chalk,1.1.3,MIT
chalk,2.4.1,MIT
chardet,0.4.2,MIT
+chardet,0.5.0,MIT
charenc,0.0.2,New BSD
charlock_holmes,0.7.6,MIT
chart.js,1.0.2,MIT
check-types,7.3.0,MIT
-chokidar,1.7.0,MIT
chokidar,2.0.2,MIT
+chokidar,2.0.4,MIT
chownr,1.0.1,ISC
-chrome-trace-event,0.1.2,MIT
+chrome-trace-event,1.0.0,MIT
chronic,0.10.2,MIT
chronic_duration,0.10.6,MIT
chunky_png,1.3.5,MIT
cipher-base,1.0.4,MIT
circular-json,0.3.3,MIT
-circular-json,0.5.1,MIT
+circular-json,0.5.5,MIT
citrus,3.0.2,MIT
-clap,1.1.3,MIT
class-utils,0.3.6,MIT
classlist-polyfill,1.2.0,Unlicense
cli-boxes,1.0.0,MIT
@@ -298,19 +296,16 @@ cli-width,2.1.0,ISC
clipboard,1.7.1,MIT
cliui,2.1.0,ISC
cliui,4.0.0,ISC
-clone,1.0.3,MIT
clone-response,1.0.2,MIT
-co,3.0.6,MIT
co,4.6.0,MIT
-coa,1.0.1,MIT
code-point-at,1.1.0,MIT
+codesandbox-api,0.0.18,MIT
+codesandbox-import-util-types,1.2.11,UNKNOWN
+codesandbox-import-utils,1.2.11,UNKNOWN
coercible,1.0.0,MIT
collection-visit,1.0.0,MIT
-color,0.11.4,MIT
color-convert,1.9.1,MIT
color-name,1.1.2,MIT
-color-string,0.3.0,MIT
-colormin,1.1.2,MIT
colors,1.1.2,MIT
combine-lists,1.0.1,MIT
combined-stream,1.0.6,MIT
@@ -361,28 +356,36 @@ cryptiles,2.0.5,New BSD
cryptiles,3.1.2,New BSD
crypto-browserify,3.12.0,MIT
crypto-random-string,1.0.0,MIT
-css-color-names,0.0.4,MIT
-css-loader,0.28.11,MIT
+css-loader,1.0.0,MIT
css-selector-tokenizer,0.7.0,MIT
css_parser,1.5.0,MIT
cssesc,0.1.0,MIT
-cssnano,3.10.0,MIT
-csso,2.3.2,MIT
currently-unhandled,0.4.1,MIT
custom-event,1.0.1,MIT
cyclist,0.2.2,MIT*
d3,3.5.17,New BSD
+d3,4.12.2,New BSD
d3-array,1.2.1,New BSD
d3-axis,1.0.8,New BSD
d3-brush,1.0.4,New BSD
+d3-chord,1.0.4,New BSD
d3-collection,1.0.4,New BSD
d3-color,1.0.3,New BSD
d3-dispatch,1.0.3,New BSD
d3-drag,1.2.1,New BSD
+d3-dsv,1.0.8,New BSD
d3-ease,1.0.3,New BSD
+d3-force,1.1.0,New BSD
d3-format,1.2.1,New BSD
+d3-geo,1.9.1,New BSD
+d3-hierarchy,1.1.5,New BSD
d3-interpolate,1.1.6,New BSD
d3-path,1.0.5,New BSD
+d3-polygon,1.0.3,New BSD
+d3-quadtree,1.0.3,New BSD
+d3-queue,3.0.7,New BSD
+d3-random,1.1.0,New BSD
+d3-request,1.0.6,New BSD
d3-scale,1.0.7,New BSD
d3-selection,1.2.0,New BSD
d3-shape,1.2.0,New BSD
@@ -390,6 +393,8 @@ d3-time,1.0.8,New BSD
d3-time-format,2.1.1,New BSD
d3-timer,1.0.7,New BSD
d3-transition,1.1.1,New BSD
+d3-voronoi,1.1.2,New BSD
+d3-zoom,1.7.1,New BSD
dagre-d3-renderer,0.4.24,MIT
dagre-layout,0.8.0,MIT
dashdash,1.14.1,MIT
@@ -398,7 +403,6 @@ date-format,1.2.0,MIT
date-now,0.1.4,MIT
dateformat,3.0.3,MIT
de-indent,1.0.2,MIT
-debug,2.2.0,MIT
debug,2.6.8,MIT
debug,2.6.9,MIT
debug,3.1.0,MIT
@@ -418,7 +422,6 @@ define-properties,1.1.2,MIT
define-property,0.2.5,MIT
define-property,1.0.0,MIT
define-property,2.0.2,MIT
-defined,1.0.0,MIT
degenerator,1.0.4,MIT
del,2.2.2,MIT
del,3.0.0,MIT
@@ -426,6 +429,7 @@ delayed-stream,1.0.0,MIT
delegate,3.1.2,MIT
delegates,1.0.0,MIT
depd,1.1.1,MIT
+depd,1.1.2,MIT
des.js,1.0.0,MIT
descendants_tracker,0.0.4,MIT
destroy,1.0.4,MIT
@@ -465,14 +469,15 @@ duplexer3,0.1.4,New BSD
duplexify,3.5.3,MIT
ecc-jsbn,0.1.1,MIT
ed25519,1.2.4,MIT
+editions,1.3.4,MIT
ee-first,1.1.1,MIT
ejs,2.5.9,Apache 2.0
-electron-to-chromium,1.3.3,ISC
elliptic,6.4.0,MIT
email_reply_trimmer,0.1.6,MIT
emoji-unicode-version,0.2.1,MIT
emojis-list,2.1.0,MIT
encodeurl,1.0.2,MIT
+encoding,0.1.12,MIT
encryptor,3.0.0,MIT
end-of-stream,1.4.1,MIT
engine.io,3.1.5,MIT
@@ -480,6 +485,7 @@ engine.io-client,3.1.5,MIT
engine.io-parser,2.1.2,MIT
enhanced-resolve,0.9.1,MIT
enhanced-resolve,4.0.0,MIT
+enhanced-resolve,4.1.0,MIT
ent,2.2.0,MIT
entities,1.1.1,Simplified BSD
equalizer,0.0.11,MIT
@@ -490,6 +496,8 @@ erubis,2.7.0,MIT
es-abstract,1.10.0,MIT
es-to-primitive,1.1.1,MIT
es6-promise,3.0.2,MIT
+es6-promise,4.2.4,MIT
+es6-promisify,5.0.0,MIT
escape-html,1.0.3,MIT
escape-string-regexp,1.0.5,MIT
escape_utils,1.1.1,MIT
@@ -530,10 +538,8 @@ excon,0.62.0,MIT
execa,0.7.0,MIT
execjs,2.6.0,MIT
expand-braces,0.1.2,MIT
-expand-brackets,0.1.5,MIT
expand-brackets,2.1.4,MIT
expand-range,0.1.1,MIT
-expand-range,1.8.2,MIT
exports-loader,0.7.0,MIT
express,4.16.2,MIT
expression_parser,0.9.0,MIT
@@ -541,7 +547,7 @@ extend,3.0.1,MIT
extend-shallow,2.0.1,MIT
extend-shallow,3.0.2,MIT
external-editor,2.2.0,MIT
-extglob,0.3.2,MIT
+external-editor,3.0.0,MIT
extglob,2.0.4,MIT
extsprintf,1.3.0,MIT
extsprintf,1.4.0,MIT
@@ -561,10 +567,8 @@ figures,2.0.0,MIT
file-entry-cache,2.0.0,MIT
file-loader,1.1.11,MIT
file-uri-to-path,1.0.0,MIT
-filename-regex,2.0.1,MIT
fileset,2.0.3,MIT
filesize,3.6.0,New BSD
-fill-range,2.2.3,MIT
fill-range,4.0.0,MIT
finalhandler,1.1.0,MIT
find-cache-dir,1.0.0,MIT
@@ -572,7 +576,6 @@ find-root,1.1.0,MIT
find-up,1.1.2,MIT
find-up,2.1.0,MIT
flat-cache,1.2.2,MIT
-flatten,1.0.2,MIT
flipper,0.13.0,MIT
flipper-active_record,0.13.0,MIT
flipper-active_support_cache_store,0.13.0,MIT
@@ -591,13 +594,12 @@ follow-redirects,1.0.0,MIT
follow-redirects,1.2.6,MIT
font-awesome-rails,4.7.0.1,"MIT,SIL Open Font License"
for-in,1.0.2,MIT
-for-own,0.1.5,MIT
foreach,2.0.5,MIT
forever-agent,0.6.1,Apache 2.0
form-data,2.0.0,MIT
-form-data,2.1.4,MIT
form-data,2.3.2,MIT
formatador,0.2.5,MIT
+formdata-polyfill,3.0.11,MIT
forwarded,0.1.2,MIT
fragment-cache,0.2.1,MIT
fresh,0.5.2,MIT
@@ -620,13 +622,13 @@ generate-object-property,1.2.0,MIT
get-caller-file,1.0.2,ISC
get-stdin,4.0.1,MIT
get-stream,3.0.0,MIT
-get-uri,2.0.1,MIT
+get-uri,2.0.2,MIT
get-value,2.0.6,MIT
get_process_mem,0.2.0,MIT
getpass,0.1.7,MIT
gettext_i18n_rails,1.8.0,MIT
gettext_i18n_rails_js,1.3.0,MIT
-gitaly-proto,0.105.0,MIT
+gitaly-proto,0.112.0,MIT
github-linguist,5.3.3,MIT
github-markup,1.7.0,MIT
gitlab-flowdock-git-hook,1.0.1,MIT
@@ -637,8 +639,6 @@ gitlab-markup,1.6.4,MIT
gitlab_omniauth-ldap,2.0.4,MIT
glob,5.0.15,ISC
glob,7.1.2,ISC
-glob-base,0.3.0,MIT
-glob-parent,2.0.0,ISC
glob-parent,3.1.0,ISC
global-dirs,0.1.1,MIT
global-modules-path,2.1.0,Apache 2.0
@@ -660,16 +660,17 @@ gpgme,2.0.13,LGPL-2.1+
graceful-fs,4.1.11,ISC
grape,1.0.3,MIT
grape-entity,0.7.1,MIT
-grape-path-helpers,1.0.5,MIT
+grape-path-helpers,1.0.6,MIT
grape_logging,1.7.0,MIT
graphiql-rails,1.4.10,MIT
graphlib,2.1.1,MIT
graphql,1.8.1,MIT
grpc,1.11.0,Apache 2.0
gzip-size,4.1.0,MIT
-hamlit,2.6.1,MIT
+hamlit,2.8.8,MIT
handle-thing,1.2.5,MIT
handlebars,4.0.6,MIT
+hangouts-chat,0.0.5,MIT
har-schema,2.0.0,ISC
har-validator,2.0.6,ISC
har-validator,5.0.3,ISC
@@ -704,9 +705,8 @@ hoek,4.2.1,New BSD
home-or-tmp,2.0.0,MIT
hosted-git-info,2.2.0,ISC
hpack.js,2.1.6,MIT
-html-comment-regex,1.1.1,MIT
html-entities,1.2.0,MIT
-html-pipeline,2.8.3,MIT
+html-pipeline,2.8.4,MIT
html2text,0.2.0,MIT
htmlentities,4.3.4,MIT
htmlparser2,3.9.2,MIT
@@ -715,9 +715,10 @@ http-cache-semantics,3.8.1,Simplified BSD
http-cookie,1.0.3,MIT
http-deceiver,1.2.7,MIT
http-errors,1.6.2,MIT
+http-errors,1.6.3,MIT
http-form_data,1.0.3,MIT
http-proxy,1.16.2,MIT
-http-proxy-agent,1.0.0,MIT
+http-proxy-agent,2.1.0,MIT
http-proxy-middleware,0.18.0,MIT
http-signature,1.1.1,MIT
http-signature,1.2.0,MIT
@@ -727,7 +728,7 @@ httpclient,2.8.3,ruby
httpntlm,1.6.1,MIT
httpreq,0.4.24,MIT
https-browserify,1.0.0,MIT
-https-proxy-agent,1.0.0,MIT
+https-proxy-agent,2.2.1,MIT
i18n,0.9.5,MIT
icalendar,2.4.1,ruby
ice_nine,0.11.2,MIT
@@ -749,25 +750,24 @@ imurmurhash,0.1.4,MIT
indent-string,2.1.0,MIT
indexes-of,1.0.1,MIT
indexof,0.0.1,MIT*
-inflection,1.10.0,MIT
+inflection,1.12.0,MIT
inflection,1.3.8,MIT
inflight,1.0.6,ISC
influxdb,0.2.3,MIT
inherits,2.0.1,ISC
inherits,2.0.3,ISC
ini,1.3.5,ISC
+inquirer,3.0.6,MIT
inquirer,3.3.0,MIT
-inquirer,5.2.0,MIT
+inquirer,6.0.0,MIT
internal-ip,1.2.0,MIT
interpret,1.1.0,MIT
into-stream,3.1.0,MIT
invariant,2.2.2,New BSD
invert-kv,1.0.0,MIT
-ip,1.0.1,MIT
ip,1.1.5,MIT
ipaddr.js,1.6.0,MIT
ipaddress,0.8.3,MIT
-is-absolute-url,2.1.0,MIT
is-accessor-descriptor,0.1.6,MIT
is-accessor-descriptor,1.0.0,MIT
is-arrayish,0.2.1,MIT
@@ -780,16 +780,12 @@ is-data-descriptor,1.0.0,MIT
is-date-object,1.0.1,MIT
is-descriptor,0.1.6,MIT
is-descriptor,1.0.2,MIT
-is-dotfile,1.0.3,MIT
-is-equal-shallow,0.1.3,MIT
is-extendable,0.1.1,MIT
is-extendable,1.0.1,MIT
-is-extglob,1.0.0,MIT
is-extglob,2.1.1,MIT
is-finite,1.0.2,MIT
is-fullwidth-code-point,1.0.0,MIT
is-fullwidth-code-point,2.0.0,MIT
-is-glob,2.0.1,MIT
is-glob,3.1.0,MIT
is-glob,4.0.0,MIT
is-installed-globally,0.1.0,MIT
@@ -797,7 +793,6 @@ is-my-ip-valid,1.0.0,MIT
is-my-json-valid,2.17.2,MIT
is-npm,1.0.0,MIT
is-number,0.1.1,MIT
-is-number,2.1.0,MIT
is-number,3.0.0,MIT
is-number,4.0.0,MIT
is-obj,1.0.1,MIT
@@ -808,8 +803,6 @@ is-path-in-cwd,1.0.0,MIT
is-path-inside,1.0.0,MIT
is-plain-obj,1.1.0,MIT
is-plain-object,2.0.4,MIT
-is-posix-bracket,0.1.1,MIT
-is-primitive,2.0.0,MIT
is-promise,2.1.0,MIT
is-property,1.0.2,MIT
is-redirect,1.0.0,MIT
@@ -817,7 +810,6 @@ is-regex,1.0.4,MIT
is-resolvable,1.0.0,MIT
is-retry-allowed,1.1.0,MIT
is-stream,1.1.0,MIT
-is-svg,2.1.0,MIT
is-symbol,1.0.1,MIT
is-typedarray,1.0.0,MIT
is-utf8,0.2.1,MIT
@@ -840,8 +832,10 @@ istanbul-lib-instrument,1.10.1,New BSD
istanbul-lib-report,1.1.2,New BSD
istanbul-lib-source-maps,1.2.2,New BSD
istanbul-reports,1.1.3,New BSD
+istextorbinary,2.2.1,MIT
isurl,1.0.0,MIT
jasmine-core,2.9.0,MIT
+jasmine-diff,0.1.3,MIT
jasmine-jquery,2.1.1,MIT
jed,1.1.1,MIT
jira-ruby,1.4.1,MIT
@@ -849,11 +843,9 @@ jquery,3.3.1,MIT
jquery-atwho-rails,1.3.2,MIT
jquery-ujs,1.2.2,MIT
jquery.waitforimages,2.2.0,MIT
-js-base64,2.1.9,New BSD
js-cookie,2.1.3,MIT
js-tokens,3.0.2,MIT
js-yaml,3.11.0,MIT
-js-yaml,3.7.0,MIT
jsbn,0.1.1,MIT
jsesc,0.5.0,MIT
jsesc,1.3.0,MIT
@@ -877,13 +869,13 @@ kaminari,1.0.1,MIT
kaminari-actionview,1.0.1,MIT
kaminari-activerecord,1.0.1,MIT
kaminari-core,1.0.1,MIT
-karma,2.0.2,MIT
+karma,2.0.4,MIT
karma-chrome-launcher,2.2.0,MIT
karma-coverage-istanbul-reporter,1.4.2,MIT
-karma-jasmine,1.1.1,MIT
+karma-jasmine,1.1.2,MIT
karma-mocha-reporter,2.2.5,MIT
karma-sourcemap-loader,0.3.7,MIT
-karma-webpack,3.0.0,MIT
+karma-webpack,4.0.0-beta.0,MIT
katex,0.8.3,MIT
keyv,3.0.0,MIT
kgio,2.10.0,LGPL-2.1+
@@ -897,7 +889,6 @@ latest-version,3.1.0,MIT
lazy-cache,1.0.4,MIT
lazy-cache,2.0.2,MIT
lcid,1.0.0,MIT
-leb,0.3.0,Apache 2.0
levn,0.3.0,MIT
libbase64,0.1.0,MIT
libmime,3.0.0,MIT
@@ -915,43 +906,44 @@ lodash,4.17.10,MIT
lodash,4.17.4,MIT
lodash.camelcase,4.3.0,MIT
lodash.clonedeep,4.5.0,MIT
+lodash.debounce,4.0.8,MIT
lodash.escaperegexp,4.1.2,MIT
+lodash.get,4.4.2,MIT
+lodash.isequal,4.5.0,MIT
lodash.kebabcase,4.1.1,MIT
-lodash.memoize,4.1.2,MIT
lodash.mergewith,4.6.0,MIT
lodash.snakecase,4.1.1,MIT
-lodash.uniq,4.5.0,MIT
+lodash.startcase,4.4.0,MIT
lodash.upperfirst,4.3.1,MIT
log-symbols,2.2.0,MIT
-log4js,2.5.3,Apache 2.0
+log4js,2.11.0,Apache 2.0
logging,2.2.2,MIT
loggly,1.1.1,MIT
loglevel,1.4.1,MIT
loglevelnext,1.0.3,MIT
lograge,0.10.0,MIT
long,3.2.0,Apache 2.0
+long,4.0.0,Apache 2.0
longest,1.0.1,MIT
loofah,2.2.2,MIT
loose-envify,1.3.1,MIT
loud-rejection,1.6.0,MIT
lowercase-keys,1.0.0,MIT
lru-cache,2.2.4,MIT
-lru-cache,2.6.5,ISC
lru-cache,4.1.3,ISC
-macaddress,0.2.8,MIT
+lz-string,1.4.4,WTFPL
mail,2.7.0,MIT
mail_room,0.9.1,MIT
mailcomposer,4.0.1,MIT
-mailgun-js,0.7.15,MIT
+mailgun-js,0.18.1,MIT
make-dir,1.2.0,MIT
mamacro,0.0.3,MIT
map-cache,0.2.2,MIT
map-obj,1.0.1,MIT
-map-stream,0.1.0,Unknown
+map-stream,0.1.0,UNKNOWN
map-visit,1.0.0,MIT
marked,0.3.12,MIT
match-at,0.1.1,MIT
-math-expression-evaluator,1.2.16,MIT
md5.js,1.3.4,MIT
media-typer,0.3.0,MIT
mem,1.1.0,MIT
@@ -963,7 +955,6 @@ merge-descriptors,1.0.1,MIT
merge-source-map,1.1.0,MIT
method_source,0.8.2,MIT
methods,1.1.2,MIT
-micromatch,2.3.11,MIT
micromatch,3.1.10,MIT
miller-rabin,4.0.1,MIT
mime,1.4.1,MIT
@@ -996,8 +987,8 @@ monaco-editor-webpack-plugin,1.4.0,MIT
mousetrap,1.4.6,Apache 2.0
mousetrap-rails,1.4.6,"MIT,Apache"
move-concurrently,1.0.1,ISC
-ms,0.7.1,MIT
ms,2.0.0,MIT
+msgpack,1.2.4,Apache 2.0
multi_json,1.13.1,MIT
multi_xml,0.6.0,MIT
multicast-dns,6.1.1,MIT
@@ -1018,6 +1009,7 @@ net-ssh,5.0.1,MIT
netmask,1.0.6,MIT
netrc,0.11.0,MIT
nice-try,1.0.4,MIT
+node-fetch,1.6.3,MIT
node-forge,0.6.33,New BSD
node-libs-browser,2.1.0,MIT
node-pre-gyp,0.10.0,New BSD
@@ -1029,23 +1021,20 @@ nodemailer-shared,1.1.0,MIT
nodemailer-smtp-pool,2.8.2,MIT
nodemailer-smtp-transport,2.7.2,MIT
nodemailer-wellknown,0.1.10,MIT
-nodemon,1.17.3,MIT
-nokogiri,1.8.3,MIT
+nodemon,1.18.2,MIT
+nokogiri,1.8.4,MIT
nokogumbo,1.5.0,Apache 2.0
nopt,1.0.10,MIT
nopt,3.0.6,ISC
nopt,4.0.1,ISC
normalize-package-data,2.4.0,Simplified BSD
normalize-path,2.1.1,MIT
-normalize-range,0.1.2,MIT
-normalize-url,1.9.1,MIT
normalize-url,2.0.1,MIT
npm-bundled,1.0.3,ISC
npm-packlist,1.1.10,ISC
npm-run-path,2.0.2,MIT
npmlog,4.1.2,ISC
null-check,1.0.0,MIT
-num2fraction,1.2.2,MIT
number-is-nan,1.0.1,MIT
numerizer,0.1.1,MIT
oauth,0.5.4,MIT
@@ -1056,7 +1045,6 @@ object-component,0.0.3,MIT*
object-copy,0.1.0,MIT
object-keys,1.0.11,MIT
object-visit,1.0.1,MIT
-object.omit,2.0.1,MIT
object.pick,1.3.0,MIT
obuf,1.1.1,MIT
octokit,4.9.0,MIT
@@ -1082,7 +1070,9 @@ on-finished,2.3.0,MIT
on-headers,1.0.1,MIT
once,1.4.0,ISC
onetime,2.0.1,MIT
+opencollective,1.0.3,MIT
opener,1.4.3,(WTFPL OR MIT)
+opn,4.0.2,MIT
opn,5.2.0,MIT
optimist,0.6.1,MIT
optionator,0.8.2,MIT
@@ -1103,13 +1093,12 @@ p-locate,2.0.0,MIT
p-map,1.1.1,MIT
p-timeout,2.0.1,MIT
p-try,1.0.0,MIT
-pac-proxy-agent,1.1.0,MIT
-pac-resolver,2.0.0,MIT
+pac-proxy-agent,2.0.2,MIT
+pac-resolver,3.0.0,MIT
package-json,4.0.1,MIT
pako,1.0.6,(MIT AND Zlib)
parallel-transform,1.1.0,MIT
parse-asn1,5.1.0,ISC
-parse-glob,3.0.4,MIT
parse-json,2.2.0,MIT
parseqs,0.0.5,MIT
parseuri,0.0.5,MIT
@@ -1151,47 +1140,19 @@ popper.js,1.14.3,MIT
portfinder,1.0.13,MIT
posix-character-classes,0.1.1,MIT
posix-spawn,0.3.13,MIT
-postcss,5.2.16,MIT
postcss,6.0.22,MIT
-postcss-calc,5.3.1,MIT
-postcss-colormin,2.2.2,MIT
-postcss-convert-values,2.6.1,MIT
-postcss-discard-comments,2.0.4,MIT
-postcss-discard-duplicates,2.1.0,MIT
-postcss-discard-empty,2.1.0,MIT
-postcss-discard-overridden,0.1.1,MIT
-postcss-discard-unused,2.2.3,MIT
-postcss-filter-plugins,2.0.2,MIT
-postcss-merge-idents,2.1.7,MIT
-postcss-merge-longhand,2.0.2,MIT
-postcss-merge-rules,2.1.2,MIT
-postcss-message-helpers,2.0.0,MIT
-postcss-minify-font-values,1.0.5,MIT
-postcss-minify-gradients,1.0.5,MIT
-postcss-minify-params,1.2.2,MIT
-postcss-minify-selectors,2.1.1,MIT
+postcss,6.0.23,MIT
postcss-modules-extract-imports,1.2.0,ISC
postcss-modules-local-by-default,1.2.0,MIT
postcss-modules-scope,1.1.0,ISC
postcss-modules-values,1.3.0,ISC
-postcss-normalize-charset,1.1.1,MIT
-postcss-normalize-url,3.0.8,MIT
-postcss-ordered-values,2.2.3,MIT
-postcss-reduce-idents,2.4.0,MIT
-postcss-reduce-initial,1.0.1,MIT
-postcss-reduce-transforms,1.0.4,MIT
-postcss-selector-parser,2.2.3,MIT
postcss-selector-parser,3.1.1,MIT
-postcss-svgo,2.1.6,MIT
-postcss-unique-selectors,2.0.2,MIT
postcss-value-parser,3.3.0,MIT
-postcss-zindex,2.2.0,MIT
prelude-ls,1.1.2,MIT
premailer,1.10.4,New BSD
premailer-rails,1.9.7,MIT
prepend-http,1.0.4,MIT
prepend-http,2.0.0,MIT
-preserve,0.2.0,MIT
prettier,1.12.1,MIT
prismjs,1.6.0,MIT
private,0.1.8,MIT
@@ -1199,10 +1160,12 @@ process,0.11.10,MIT
process-nextick-args,1.0.7,MIT
process-nextick-args,2.0.0,MIT
progress,2.0.0,MIT
-prometheus-client-mmap,0.9.3,Apache 2.0
+prometheus-client-mmap,0.9.4,Apache 2.0
promise-inflight,1.0.1,ISC
+promisify-call,2.0.4,MIT
proxy-addr,2.0.3,MIT
-proxy-agent,2.0.0,MIT
+proxy-agent,3.0.1,MIT
+proxy-from-env,1.0.0,MIT
prr,0.0.0,MIT
prr,1.0.1,MIT
ps-tree,1.1.0,MIT
@@ -1215,12 +1178,9 @@ pumpify,1.4.0,MIT
punycode,1.3.2,MIT
punycode,1.4.1,MIT
pyu-ruby-sasl,0.0.3.3,MIT
-q,1.4.1,MIT
-q,1.5.0,MIT
qjobs,1.2.0,MIT
qs,6.2.3,New BSD
qs,6.5.1,New BSD
-query-string,4.3.2,MIT
query-string,5.1.1,MIT
querystring,0.2.0,MIT
querystring-es3,0.2.1,MIT
@@ -1243,16 +1203,17 @@ railties,4.2.10,MIT
rainbow,2.2.2,MIT
raindrops,0.18.0,LGPL-2.1+
rake,12.3.1,MIT
-randomatic,1.1.7,MIT
randombytes,2.0.6,MIT
randomfill,1.0.4,MIT
range-parser,1.2.0,MIT
raphael,2.2.7,MIT
raven-js,3.22.1,Simplified BSD
raw-body,2.3.2,MIT
+raw-body,2.3.3,MIT
raw-loader,0.5.1,MIT
rb-fsevent,0.10.2,MIT
rb-inotify,0.9.10,MIT
+rbtrace,0.4.10,MIT
rc,1.2.5,(BSD-2-Clause OR MIT OR Apache-2.0)
rdoc,6.0.4,ruby
re2,1.1.1,New BSD
@@ -1279,12 +1240,10 @@ redis-parser,2.6.0,MIT
redis-rack,2.0.4,MIT
redis-rails,5.0.2,MIT
redis-store,1.4.1,MIT
-reduce-css-calc,1.3.0,MIT
-reduce-function-call,1.0.2,MIT
regenerate,1.3.2,MIT
+regenerator-runtime,0.10.5,MIT
regenerator-runtime,0.11.0,MIT
regenerator-transform,0.10.1,BSD
-regex-cache,0.4.4,MIT
regex-not,1.0.2,MIT
regexpu-core,1.0.0,MIT
regexpu-core,2.0.0,MIT
@@ -1323,7 +1282,7 @@ rimraf,2.6.2,ISC
rinku,2.0.0,ISC
ripemd160,2.0.1,MIT
rotp,2.1.2,MIT
-rouge,3.1.1,MIT
+rouge,3.2.0,MIT
rqrcode,0.7.0,MIT
rqrcode-rails3,0.1.7,MIT
ruby-enum,0.7.2,MIT
@@ -1338,21 +1297,22 @@ rufus-scheduler,3.4.0,MIT
rugged,0.27.2,MIT
run-async,2.3.0,MIT
run-queue,1.0.3,ISC
+rw,1.3.3,New BSD
+rx,4.1.0,Apache 2.0
rx-lite,4.0.8,Apache 2.0
rx-lite-aggregates,4.0.8,Apache 2.0
-rxjs,5.5.10,Apache 2.0
+rxjs,6.2.1,Apache 2.0
safe-buffer,5.1.1,MIT
safe-buffer,5.1.2,MIT
safe-regex,1.1.0,MIT
safe_yaml,1.0.4,MIT
safer-buffer,2.1.2,MIT
-sanitize,4.6.5,MIT
+sanitize,4.6.6,MIT
sanitize-html,1.16.3,MIT
sass,3.5.5,MIT
sass-listen,4.0.0,MIT
sass-rails,5.0.6,MIT
sawyer,0.8.1,MIT
-sax,1.2.2,ISC
sax,1.2.4,ISC
schema-utils,0.4.5,MIT
seed-fu,2.3.7,MIT
@@ -1361,7 +1321,6 @@ select-hose,2.0.0,MIT
select2,3.5.2-browserify,Apache*
select2-rails,3.5.9.3,MIT
selfsigned,1.10.1,MIT
-semver,5.0.3,ISC
semver,5.5.0,ISC
semver-diff,2.1.0,MIT
send,0.16.1,MIT
@@ -1393,6 +1352,8 @@ slack-notifier,1.5.1,MIT
slash,1.0.0,MIT
slice-ansi,1.0.0,MIT
smart-buffer,1.1.15,MIT
+smart-buffer,4.0.1,MIT
+smooshpack,0.0.48,SEE LICENSE.MD IN ROOT
smtp-connection,2.12.0,MIT
snapdragon,0.8.1,MIT
snapdragon-node,2.1.1,MIT
@@ -1407,8 +1368,9 @@ sockjs,0.3.19,MIT
sockjs-client,1.1.4,MIT
socks,1.1.10,MIT
socks,1.1.9,MIT
-socks-proxy-agent,2.1.1,MIT
-sort-keys,1.1.2,MIT
+socks,2.2.1,MIT
+socks-proxy-agent,3.0.1,MIT
+socks-proxy-agent,4.0.1,MIT
sort-keys,2.0.0,MIT
sortablejs,1.7.0,MIT
source-list-map,2.0.0,MIT
@@ -1442,6 +1404,7 @@ state_machines-activerecord,0.5.1,MIT
static-extend,0.1.2,MIT
statuses,1.3.1,MIT
statuses,1.4.0,MIT
+statuses,1.5.0,MIT
stickyfilljs,2.0.5,MIT
stream-browserify,2.0.1,MIT
stream-combiner,0.0.4,MIT
@@ -1469,18 +1432,17 @@ supports-color,2.0.0,MIT
supports-color,3.2.3,MIT
supports-color,5.4.0,MIT
svg4everybody,2.1.9,CC0-1.0
-svgo,0.7.2,MIT
-symbol-observable,1.0.1,MIT
sys-filesystem,1.1.6,Artistic 2.0
table,4.0.2,New BSD
tapable,0.1.10,MIT
tapable,1.0.0,MIT
tar,4.4.4,ISC
-temple,0.7.7,MIT
+temple,0.8.0,MIT
term-size,1.2.0,MIT
test-exclude,4.2.1,ISC
text,1.3.1,MIT
text-table,0.2.0,MIT
+textextensions,2.2.0,MIT
thor,0.19.4,MIT
thread_safe,0.3.6,Apache 2.0
three,0.84.0,MIT
@@ -1490,7 +1452,7 @@ through,2.3.8,MIT
through2,2.0.3,MIT
thunkify,2.1.2,MIT
thunky,0.1.0,MIT*
-tilt,2.0.6,MIT
+tilt,2.0.8,MIT
timeago.js,3.0.2,MIT
timed-out,4.0.1,MIT
timers-browserify,2.0.10,MIT
@@ -1511,9 +1473,11 @@ tough-cookie,2.3.3,New BSD
traverse,0.6.6,MIT
trim-newlines,1.0.0,MIT
trim-right,1.0.1,MIT
+trollop,2.1.3,MIT
truncato,0.7.10,MIT
tryer,1.0.0,MIT
tryit,1.0.3,MIT
+tslib,1.9.3,Apache 2.0
tsscmp,1.0.5,MIT
tty-browserify,0.0.0,MIT
tunnel-agent,0.4.3,Apache 2.0
@@ -1540,8 +1504,6 @@ unicorn,5.1.0,ruby
unicorn-worker-killer,0.4.4,ruby
union-value,1.0.0,MIT
uniq,1.0.1,MIT
-uniqid,4.1.1,MIT
-uniqs,2.0.0,MIT
unique-filename,1.1.0,ISC
unique-slug,2.0.0,ISC
unique-string,1.0.0,MIT
@@ -1549,10 +1511,10 @@ unpipe,1.0.0,MIT
unset-value,1.0.0,MIT
unzip-response,2.0.1,MIT
upath,1.0.5,MIT
+upath,1.1.0,MIT
update-notifier,2.3.0,Simplified BSD
urix,0.1.0,MIT
url,0.11.0,MIT
-url-join,2.0.5,MIT
url-join,4.0.0,MIT
url-loader,1.0.1,MIT
url-parse,1.0.5,MIT
@@ -1572,7 +1534,6 @@ validate-npm-package-license,3.0.1,Apache 2.0
validates_hostname,1.0.6,MIT
vary,1.1.1,MIT
vary,1.1.2,MIT
-vendors,1.0.1,MIT
verror,1.10.0,MIT
version_sorter,2.1.0,MIT
virtus,1.0.5,MIT
@@ -1582,8 +1543,9 @@ vmstat,2.3.0,MIT
void-elements,2.0.1,MIT
vue,2.5.16,MIT
vue-eslint-parser,2.0.3,MIT
+vue-functional-data-merge,2.0.6,MIT
vue-hot-reload-api,2.3.0,MIT
-vue-loader,15.2.0,MIT
+vue-loader,15.2.4,MIT
vue-resource,1.5.0,MIT
vue-router,3.0.1,MIT
vue-style-loader,4.1.0,MIT
@@ -1594,10 +1556,9 @@ vuex,3.0.1,MIT
warden,1.2.7,MIT
watchpack,1.5.0,MIT
wbuf,1.7.2,MIT
-webpack,4.11.1,MIT
-webpack-bundle-analyzer,2.11.1,MIT
-webpack-cli,3.0.2,MIT
-webpack-dev-middleware,2.0.6,MIT
+webpack,4.16.0,MIT
+webpack-bundle-analyzer,2.13.1,MIT
+webpack-cli,3.0.8,MIT
webpack-dev-middleware,3.1.3,MIT
webpack-dev-server,3.1.4,MIT
webpack-log,1.2.0,MIT
@@ -1607,13 +1568,13 @@ webpack-stats-plugin,0.2.1,MIT
websocket-driver,0.6.5,MIT
websocket-extensions,0.1.1,MIT
when,3.7.8,MIT
-whet.extend,0.9.9,MIT
which,1.3.0,ISC
which-module,2.0.0,ISC
wide-align,1.1.2,ISC
widest-line,2.0.0,MIT
wikicloth,0.8.1,MIT
window-size,0.1.0,MIT
+with-callback,1.0.2,MIT
wordwrap,0.0.2,MIT
wordwrap,0.0.3,MIT
wordwrap,1.0.0,MIT
@@ -1627,9 +1588,11 @@ ws,3.3.3,MIT
ws,4.0.0,MIT
xdg-basedir,3.0.0,MIT
xml-simple,1.1.5,ruby
+xmlhttprequest,1.8.0,MIT
xmlhttprequest-ssl,1.5.5,MIT
xregexp,2.0.0,MIT
xtend,4.0.1,MIT
+xterm,3.5.0,MIT
y18n,3.2.1,ISC
y18n,4.0.0,ISC
yallist,2.1.2,ISC
diff --git a/yarn.lock b/yarn.lock
index f6e3b84c84b..c1e9d0ab73e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
+binaryextensions@2:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935"
+
bitsyntax@~0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82"
@@ -1776,6 +1780,22 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+codesandbox-api@^0.0.18:
+ version "0.0.18"
+ resolved "https://registry.yarnpkg.com/codesandbox-api/-/codesandbox-api-0.0.18.tgz#56b96b37533f80d20c21861e5e477d3557e613ca"
+
+codesandbox-import-util-types@^1.2.11:
+ version "1.2.11"
+ resolved "https://registry.yarnpkg.com/codesandbox-import-util-types/-/codesandbox-import-util-types-1.2.11.tgz#68e812f21d6b309e9a52eec5cf027c3e63b4c703"
+
+codesandbox-import-utils@^1.2.3:
+ version "1.2.11"
+ resolved "https://registry.yarnpkg.com/codesandbox-import-utils/-/codesandbox-import-utils-1.2.11.tgz#b88423a4a7c785175c784c84e87f5950820280e1"
+ dependencies:
+ codesandbox-import-util-types "^1.2.11"
+ istextorbinary "^2.2.1"
+ lz-string "^1.4.4"
+
collection-visit@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
@@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1:
dependencies:
jsbn "~0.1.0"
+editions@^1.3.3:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b"
+
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1:
agent-base "^4.1.0"
debug "^3.1.0"
-iconv-lite@0.4:
+iconv-lite@0.4, iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
version "0.4.23"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
dependencies:
@@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17:
version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
-iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
- version "0.4.23"
- resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
- dependencies:
- safer-buffer ">= 2.1.2 < 3"
-
icss-replace-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
@@ -4490,6 +4508,14 @@ istanbul@^0.4.5:
which "^1.1.1"
wordwrap "^1.0.0"
+istextorbinary@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.2.1.tgz#a5231a08ef6dd22b268d0895084cf8d58b5bec53"
+ dependencies:
+ binaryextensions "2"
+ editions "^1.3.3"
+ textextensions "2"
+
isurl@^1.0.0-alpha5:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
@@ -4839,6 +4865,10 @@ lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+lodash.isequal@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+
lodash.kebabcase@4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
@@ -4948,6 +4978,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2:
pseudomap "^1.0.2"
yallist "^2.1.2"
+lz-string@^1.4.4:
+ version "1.4.4"
+ resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
+
mailcomposer@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4"
@@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.1.tgz#07ea1ca8d4db24eb4cac86537d7d18995221ace3"
+smooshpack@^0.0.48:
+ version "0.0.48"
+ resolved "https://registry.yarnpkg.com/smooshpack/-/smooshpack-0.0.48.tgz#6fbeaaf59226a1fe500f56aa17185eed377d2823"
+ dependencies:
+ codesandbox-api "^0.0.18"
+ codesandbox-import-utils "^1.2.3"
+ lodash.isequal "^4.5.0"
+
smtp-connection@2.12.0:
version "2.12.0"
resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1"
@@ -7240,6 +7282,10 @@ text-table@~0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+textextensions@2:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.2.0.tgz#38ac676151285b658654581987a0ce1a4490d286"
+
three-orbit-controls@^82.1.0:
version "82.1.0"
resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4"