summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml139
-rw-r--r--CHANGELOG.md23
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--GITLAB_SHELL_VERSION3
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock11
-rw-r--r--app/assets/images/auth_buttons/signin_with_google.pngbin3983 -> 8001 bytes
-rw-r--r--app/assets/javascripts/api.js14
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js3
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js3
-rw-r--r--app/assets/javascripts/boards/services/board_service.js4
-rw-r--r--app/assets/javascripts/broadcast_message.js45
-rw-r--r--app/assets/javascripts/clusters.js5
-rw-r--r--app/assets/javascripts/dispatcher.js74
-rw-r--r--app/assets/javascripts/dropzone_input.js562
-rw-r--r--app/assets/javascripts/due_date_select.js52
-rw-r--r--app/assets/javascripts/filterable_list.js7
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js4
-rw-r--r--app/assets/javascripts/flash.js7
-rw-r--r--app/assets/javascripts/gl_field_error.js5
-rw-r--r--app/assets/javascripts/gl_field_errors.js36
-rw-r--r--app/assets/javascripts/gl_form.js169
-rw-r--r--app/assets/javascripts/groups/components/app.vue194
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue38
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue228
-rw-r--r--app/assets/javascripts/groups/components/groups.vue26
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue93
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue25
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue98
-rw-r--r--app/assets/javascripts/groups/components/item_type_icon.vue34
-rw-r--r--app/assets/javascripts/groups/constants.js35
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js64
-rw-r--r--app/assets/javascripts/groups/index.js195
-rw-r--r--app/assets/javascripts/groups/new_group_child.js62
-rw-r--r--app/assets/javascripts/groups/service/groups_service.js (renamed from app/assets/javascripts/groups/services/groups_service.js)8
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js105
-rw-r--r--app/assets/javascripts/groups/stores/groups_store.js167
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js4
-rw-r--r--app/assets/javascripts/issuable_form.js8
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue16
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue56
-rw-r--r--app/assets/javascripts/jobs/components/header.vue10
-rw-r--r--app/assets/javascripts/labels_select.js4
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js8
-rw-r--r--app/assets/javascripts/lib/utils/datefix.js33
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js4
-rw-r--r--app/assets/javascripts/main.js22
-rw-r--r--app/assets/javascripts/member_expiration_date.js94
-rw-r--r--app/assets/javascripts/members.js129
-rw-r--r--app/assets/javascripts/milestone_select.js4
-rw-r--r--app/assets/javascripts/network/network_bundle.js2
-rw-r--r--app/assets/javascripts/notes.js10
-rw-r--r--app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js3
-rw-r--r--app/assets/javascripts/prometheus_metrics/prometheus_metrics.js6
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue2
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue2
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js2
-rw-r--r--app/assets/javascripts/repo/components/repo.vue4
-rw-r--r--app/assets/javascripts/repo/components/repo_commit_section.vue95
-rw-r--r--app/assets/javascripts/repo/components/repo_edit_button.vue4
-rw-r--r--app/assets/javascripts/repo/components/repo_editor.vue9
-rw-r--r--app/assets/javascripts/repo/components/repo_file.vue161
-rw-r--r--app/assets/javascripts/repo/components/repo_file_buttons.vue4
-rw-r--r--app/assets/javascripts/repo/components/repo_file_options.vue25
-rw-r--r--app/assets/javascripts/repo/components/repo_loading_file.vue87
-rw-r--r--app/assets/javascripts/repo/components/repo_prev_directory.vue58
-rw-r--r--app/assets/javascripts/repo/components/repo_preview.vue4
-rw-r--r--app/assets/javascripts/repo/components/repo_sidebar.vue80
-rw-r--r--app/assets/javascripts/repo/components/repo_tab.vue51
-rw-r--r--app/assets/javascripts/repo/components/repo_tabs.vue51
-rw-r--r--app/assets/javascripts/repo/event_hub.js3
-rw-r--r--app/assets/javascripts/repo/helpers/repo_helper.js182
-rw-r--r--app/assets/javascripts/repo/index.js6
-rw-r--r--app/assets/javascripts/repo/services/repo_service.js4
-rw-r--r--app/assets/javascripts/repo/stores/repo_store.js60
-rw-r--r--app/assets/javascripts/shortcuts.js224
-rw-r--r--app/assets/javascripts/shortcuts_blob.js3
-rw-r--r--app/assets/javascripts/shortcuts_find_file.js56
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js156
-rw-r--r--app/assets/javascripts/shortcuts_navigation.js51
-rw-r--r--app/assets/javascripts/shortcuts_network.js37
-rw-r--r--app/assets/javascripts/shortcuts_wiki.js2
-rw-r--r--app/assets/javascripts/users/index.js8
-rw-r--r--app/assets/javascripts/users_select.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/popup_dialog.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue31
-rw-r--r--app/assets/javascripts/zen_mode.js2
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/animations.scss7
-rw-r--r--app/assets/stylesheets/framework/banner.scss25
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss1
-rw-r--r--app/assets/stylesheets/framework/files.scss4
-rw-r--r--app/assets/stylesheets/framework/lists.scss89
-rw-r--r--app/assets/stylesheets/framework/new-sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/secondary-navigation-elements.scss2
-rw-r--r--app/assets/stylesheets/framework/selects.scss4
-rw-r--r--app/assets/stylesheets/framework/tooltips.scss2
-rw-r--r--app/assets/stylesheets/framework/variables.scss1
-rw-r--r--app/assets/stylesheets/pages/clusters.scss2
-rw-r--r--app/assets/stylesheets/pages/diff.scss4
-rw-r--r--app/assets/stylesheets/pages/editor.scss2
-rw-r--r--app/assets/stylesheets/pages/groups.scss115
-rw-r--r--app/assets/stylesheets/pages/issuable.scss14
-rw-r--r--app/assets/stylesheets/pages/notes.scss4
-rw-r--r--app/assets/stylesheets/pages/projects.scss6
-rw-r--r--app/assets/stylesheets/pages/repo.scss82
-rw-r--r--app/controllers/concerns/group_tree.rb24
-rw-r--r--app/controllers/dashboard/groups_controller.rb33
-rw-r--r--app/controllers/explore/groups_controller.rb16
-rw-r--r--app/controllers/groups/children_controller.rb39
-rw-r--r--app/controllers/groups_controller.rb33
-rw-r--r--app/controllers/projects/application_controller.rb10
-rw-r--r--app/controllers/projects/commits_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb2
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb12
-rw-r--r--app/finders/group_descendants_finder.rb153
-rw-r--r--app/finders/group_projects_finder.rb1
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/application_settings_helper.rb32
-rw-r--r--app/helpers/issuables_helper.rb12
-rw-r--r--app/helpers/preferences_helper.rb3
-rw-r--r--app/helpers/sorting_helper.rb11
-rw-r--r--app/models/application_setting.rb9
-rw-r--r--app/models/blob.rb4
-rw-r--r--app/models/concerns/group_descendant.rb56
-rw-r--r--app/models/concerns/loaded_in_group_list.rb72
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb2
-rw-r--r--app/models/concerns/time_trackable.rb9
-rw-r--r--app/models/gcp/cluster.rb3
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/merge_request.rb2
-rw-r--r--app/models/namespace.rb7
-rw-r--r--app/models/note.rb2
-rw-r--r--app/models/oauth_access_token.rb2
-rw-r--r--app/models/project.rb16
-rw-r--r--app/models/repository.rb31
-rw-r--r--app/models/user.rb9
-rw-r--r--app/serializers/base_serializer.rb7
-rw-r--r--app/serializers/concerns/with_pagination.rb22
-rw-r--r--app/serializers/container_tag_entity.rb4
-rw-r--r--app/serializers/environment_serializer.rb12
-rw-r--r--app/serializers/group_child_entity.rb77
-rw-r--r--app/serializers/group_child_serializer.rb51
-rw-r--r--app/serializers/group_serializer.rb18
-rw-r--r--app/serializers/pipeline_serializer.rb10
-rw-r--r--app/services/auth/container_registry_authentication_service.rb17
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/merge_requests/add_todo_when_build_fails_service.rb2
-rw-r--r--app/services/merge_requests/conflicts/list_service.rb4
-rw-r--r--app/services/merge_requests/conflicts/resolve_service.rb48
-rw-r--r--app/services/merge_requests/merge_service.rb18
-rw-r--r--app/services/notification_service.rb2
-rw-r--r--app/services/projects/unlink_fork_service.rb2
-rw-r--r--app/services/quick_actions/interpret_service.rb16
-rw-r--r--app/services/system_note_service.rb9
-rw-r--r--app/views/admin/application_settings/_form.html.haml26
-rw-r--r--app/views/dashboard/_groups_head.html.haml6
-rw-r--r--app/views/dashboard/groups/_empty_state.html.haml7
-rw-r--r--app/views/dashboard/groups/_groups.html.haml9
-rw-r--r--app/views/dashboard/groups/index.html.haml4
-rw-r--r--app/views/discussions/_parallel_diff_discussion.html.haml4
-rw-r--r--app/views/explore/groups/_groups.html.haml6
-rw-r--r--app/views/explore/groups/index.html.haml9
-rw-r--r--app/views/groups/_children.html.haml5
-rw-r--r--app/views/groups/_show_nav.html.haml8
-rw-r--r--app/views/groups/show.html.haml42
-rw-r--r--app/views/groups/subgroups.html.haml21
-rw-r--r--app/views/help/_shortcuts.html.haml4
-rw-r--r--app/views/layouts/_head.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml5
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml10
-rw-r--r--app/views/notify/pipeline_success_email.html.haml10
-rw-r--r--app/views/projects/_readme.html.haml23
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/clusters/_advanced_settings.html.haml14
-rw-r--r--app/views/projects/clusters/login.html.haml2
-rw-r--r--app/views/projects/clusters/show.html.haml90
-rw-r--r--app/views/projects/empty.html.haml9
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml4
-rw-r--r--app/views/projects/merge_requests/index.html.haml2
-rw-r--r--app/views/projects/new.html.haml94
-rw-r--r--app/views/projects/pipelines/index.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml29
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml2
-rw-r--r--app/views/shared/groups/_dropdown.html.haml44
-rw-r--r--app/views/shared/groups/_empty_state.html.haml7
-rw-r--r--app/views/shared/groups/_group.html.haml2
-rw-r--r--app/views/shared/groups/_list.html.haml2
-rw-r--r--app/views/shared/groups/_search_form.html.haml4
-rw-r--r--app/views/shared/icons/_icon_autodevops.svg2
-rw-r--r--app/views/shared/projects/_dropdown.html.haml2
-rw-r--r--app/views/shared/repo/_repo.html.haml5
-rw-r--r--app/views/users/show.html.haml3
-rwxr-xr-xbin/changelog1
-rw-r--r--changelogs/unreleased/1312-time-spent-at.yml5
-rw-r--r--changelogs/unreleased/26763-grant-registry-auth-scope-to-admins.yml5
-rw-r--r--changelogs/unreleased/27654-retry-button.yml5
-rw-r--r--changelogs/unreleased/30140-restore-readme-only-preference.yml5
-rw-r--r--changelogs/unreleased/34897-delete-branch-after-merge.yml5
-rw-r--r--changelogs/unreleased/35652-prometheus-service-page-shows-error.yml5
-rw-r--r--changelogs/unreleased/36160-zindex.yml (renamed from changelogs/unreleased/36160-select2-dropdown.yml)0
-rw-r--r--changelogs/unreleased/37032-get-project-branch-invalid-name-message.yml5
-rw-r--r--changelogs/unreleased/37571-replace-wikipage-createservice-with-factory.yml5
-rw-r--r--changelogs/unreleased/37660-match-sidebar-colors.yml5
-rw-r--r--changelogs/unreleased/37691-subscription-fires-multiple-notifications.yml5
-rw-r--r--changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml6
-rw-r--r--changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml5
-rw-r--r--changelogs/unreleased/38871-cleanup-data-page-attribute-after-karma-test.yml5
-rw-r--r--changelogs/unreleased/38986-due-date.yml5
-rw-r--r--changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml5
-rw-r--r--changelogs/unreleased/39032-improve-merge-ongoing-check-consistency.yml5
-rw-r--r--changelogs/unreleased/39033-d3-js-is-being-included-in-the-user_profile-and-graphs_show-bundles.yml6
-rw-r--r--changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml5
-rw-r--r--changelogs/unreleased/add-lazy-option-to-user-avatar-image-component.yml5
-rw-r--r--changelogs/unreleased/an-popen-deadline.yml5
-rw-r--r--changelogs/unreleased/bvl-circuitbreaker-improvements.yml5
-rw-r--r--changelogs/unreleased/bvl-do-not-use-redis-keys.yml5
-rw-r--r--changelogs/unreleased/bvl-fix-deleting-forked-projects.yml5
-rw-r--r--changelogs/unreleased/bvl-fix-locale-path.yml5
-rw-r--r--changelogs/unreleased/bvl-group-trees.yml5
-rw-r--r--changelogs/unreleased/cache-issuable-template-names.yml5
-rw-r--r--changelogs/unreleased/es-module-broadcast_message.yml5
-rw-r--r--changelogs/unreleased/fix-resolved-side-by-side.yml5
-rw-r--r--changelogs/unreleased/fix_diff_parsing.yml5
-rw-r--r--changelogs/unreleased/fix_global_board_routes_39073.yml5
-rw-r--r--changelogs/unreleased/fl-autodevops-fix.yml5
-rw-r--r--changelogs/unreleased/issue-36484.yml5
-rw-r--r--changelogs/unreleased/kt-bug-fix-revision-and-size-for-container-registry.yml5
-rw-r--r--changelogs/unreleased/new-mr-repo-editor.yml5
-rw-r--r--changelogs/unreleased/not-found-in-commits.yml5
-rw-r--r--changelogs/unreleased/prevent-creating-multiple-application-settings.yml5
-rw-r--r--changelogs/unreleased/replace_explore_projects-feature.yml5
-rw-r--r--changelogs/unreleased/sha-handling.yml5
-rw-r--r--changelogs/unreleased/tc-saml-fix-false-empty.yml5
-rw-r--r--changelogs/unreleased/winh-indeterminate-dropdown.yml5
-rw-r--r--changelogs/unreleased/zj-add-performance-changelog-cat.yml5
-rw-r--r--config/application.rb1
-rw-r--r--config/database.yml.mysql21
-rw-r--r--config/database.yml.postgresql21
-rw-r--r--config/dependency_decisions.yml7
-rw-r--r--config/gitlab.yml.example8
-rw-r--r--config/initializers/1_settings.rb23
-rw-r--r--config/routes.rb26
-rw-r--r--config/routes/group.rb3
-rw-r--r--config/routes/project.rb9
-rw-r--r--config/webpack.config.js5
-rw-r--r--db/migrate/20170909150936_add_spent_at_to_timelogs.rb11
-rw-r--r--db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb27
-rw-r--r--db/schema.rb7
-rw-r--r--doc/administration/img/circuitbreaker_config.pngbin0 -> 213210 bytes
-rw-r--r--doc/administration/raketasks/github_import.md1
-rw-r--r--doc/administration/repository_storage_paths.md55
-rw-r--r--doc/api/README.md16
-rw-r--r--doc/api/repositories.md2
-rw-r--r--doc/api/settings.md119
-rw-r--r--doc/ci/README.md2
-rw-r--r--doc/development/README.md6
-rw-r--r--doc/development/gitaly.md24
-rw-r--r--doc/development/i18n/translation.md12
-rw-r--r--doc/development/licensing.md2
-rw-r--r--doc/development/profiling.md10
-rw-r--r--doc/development/testing_guide/index.md1
-rw-r--r--doc/gitlab-basics/create-project.md2
-rw-r--r--doc/gitlab-basics/img/create_new_project_info.pngbin82725 -> 75470 bytes
-rw-r--r--doc/install/installation.md8
-rw-r--r--doc/install/requirements.md4
-rw-r--r--doc/integration/google.md138
-rw-r--r--doc/update/10.0-to-10.1.md6
-rw-r--r--doc/user/discussions/index.md3
-rw-r--r--doc/user/permissions.md1
-rw-r--r--doc/user/project/clusters/index.md90
-rw-r--r--doc/user/project/index.md2
-rw-r--r--doc/user/project/issues/automatic_issue_closing.md11
-rw-r--r--doc/user/project/quick_actions.md2
-rw-r--r--features/explore/groups.feature12
-rw-r--r--features/explore/projects.feature144
-rw-r--r--features/steps/explore/projects.rb145
-rw-r--r--features/steps/shared/paths.rb15
-rw-r--r--features/steps/shared/project.rb18
-rw-r--r--lib/api/api.rb7
-rw-r--r--lib/api/api_guard.rb133
-rw-r--r--lib/api/branches.rb22
-rw-r--r--lib/api/commits.rb14
-rw-r--r--lib/api/helpers.rb52
-rw-r--r--lib/api/repositories.rb4
-rw-r--r--lib/api/v3/builds.rb2
-rw-r--r--lib/api/v3/commits.rb14
-rw-r--r--lib/api/v3/repositories.rb6
-rw-r--r--lib/backup/manager.rb78
-rw-r--r--lib/banzai/filter/sanitization_filter.rb14
-rw-r--r--lib/github/representation/comment.rb2
-rw-r--r--lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb1
-rw-r--r--lib/gitlab/bitbucket_import/importer.rb2
-rw-r--r--lib/gitlab/conflict/file.rb88
-rw-r--r--lib/gitlab/conflict/file_collection.rb68
-rw-r--r--lib/gitlab/conflict/parser.rb74
-rw-r--r--lib/gitlab/conflict/resolution_error.rb5
-rw-r--r--lib/gitlab/diff/file.rb2
-rw-r--r--lib/gitlab/diff/line_code.rb9
-rw-r--r--lib/gitlab/diff/parser.rb4
-rw-r--r--lib/gitlab/encoding_helper.rb7
-rw-r--r--lib/gitlab/file_detector.rb28
-rw-r--r--lib/gitlab/git.rb4
-rw-r--r--lib/gitlab/git/conflict/file.rb86
-rw-r--r--lib/gitlab/git/conflict/parser.rb91
-rw-r--r--lib/gitlab/git/conflict/resolver.rb91
-rw-r--r--lib/gitlab/git/env.rb17
-rw-r--r--lib/gitlab/git/operation_service.rb12
-rw-r--r--lib/gitlab/git/popen.rb63
-rw-r--r--lib/gitlab/git/repository.rb58
-rw-r--r--lib/gitlab/git/rev_list.rb2
-rw-r--r--lib/gitlab/git/storage/circuit_breaker.rb14
-rw-r--r--lib/gitlab/git/storage/circuit_breaker_settings.rb29
-rw-r--r--lib/gitlab/git/storage/health.rb20
-rw-r--r--lib/gitlab/git/storage/null_circuit_breaker.rb13
-rw-r--r--lib/gitlab/git/wiki.rb35
-rw-r--r--lib/gitlab/gitaly_client.rb20
-rw-r--r--lib/gitlab/gitaly_client/operation_service.rb31
-rw-r--r--lib/gitlab/gitaly_client/queue_enumerator.rb28
-rw-r--r--lib/gitlab/gitaly_client/util.rb10
-rw-r--r--lib/gitlab/gitaly_client/wiki_service.rb45
-rw-r--r--lib/gitlab/github_import/comment_formatter.rb2
-rw-r--r--lib/gitlab/group_hierarchy.rb30
-rw-r--r--lib/gitlab/multi_collection_paginator.rb61
-rw-r--r--lib/gitlab/path_regex.rb2
-rw-r--r--lib/gitlab/project_template.rb6
-rw-r--r--lib/gitlab/quick_actions/spend_time_and_date_separator.rb54
-rw-r--r--lib/gitlab/saml/auth_hash.rb2
-rw-r--r--lib/gitlab/sidekiq_status.rb7
-rw-r--r--lib/gitlab/sql/union.rb6
-rw-r--r--lib/gitlab/usage_data.rb2
-rw-r--r--lib/gitlab/utils/merge_hash.rb117
-rw-r--r--lib/tasks/gitlab/dev.rake4
-rw-r--r--locale/gitlab.pot217
-rw-r--r--package.json6
-rw-r--r--qa/qa.rb5
-rw-r--r--qa/qa/scenario/entrypoint.rb36
-rw-r--r--qa/qa/scenario/test/instance.rb17
-rw-r--r--qa/qa/scenario/test/integration/mattermost.rb15
-rw-r--r--qa/qa/specs/features/login/standard_spec.rb2
-rw-r--r--qa/qa/specs/features/mattermost/group_create_spec.rb16
-rw-r--r--qa/qa/specs/features/project/create_spec.rb2
-rw-r--r--qa/qa/specs/features/repository/clone_spec.rb2
-rw-r--r--qa/qa/specs/features/repository/push_spec.rb4
-rw-r--r--qa/qa/specs/runner.rb9
-rw-r--r--rubocop/cop/rspec/env_assignment.rb58
-rw-r--r--rubocop/rubocop.rb11
-rw-r--r--rubocop/spec_helpers.rb12
-rw-r--r--scripts/prepare_build.sh6
-rw-r--r--spec/bin/changelog_spec.rb2
-rw-r--r--spec/controllers/concerns/group_tree_spec.rb89
-rw-r--r--spec/controllers/dashboard/groups_controller_spec.rb23
-rw-r--r--spec/controllers/explore/groups_controller_spec.rb23
-rw-r--r--spec/controllers/groups/children_controller_spec.rb286
-rw-r--r--spec/controllers/groups_controller_spec.rb108
-rw-r--r--spec/controllers/profiles_controller_spec.rb44
-rw-r--r--spec/controllers/projects/commits_controller_spec.rb31
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb57
-rw-r--r--spec/controllers/projects/merge_requests/conflicts_controller_spec.rb8
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb32
-rw-r--r--spec/controllers/projects_controller_spec.rb8
-rw-r--r--spec/factories/merge_requests.rb6
-rw-r--r--spec/features/admin/admin_health_check_spec.rb4
-rw-r--r--spec/features/boards/sidebar_spec.rb32
-rw-r--r--spec/features/dashboard/groups_list_spec.rb81
-rw-r--r--spec/features/explore/groups_list_spec.rb13
-rw-r--r--spec/features/explore/user_explores_projects_spec.rb72
-rw-r--r--spec/features/groups/show_spec.rb31
-rw-r--r--spec/features/groups_spec.rb23
-rw-r--r--spec/features/merge_requests/diff_notes_resolve_spec.rb29
-rw-r--r--spec/features/projects/issues/list_spec.rb20
-rw-r--r--spec/features/projects/issues/user_views_issues_spec.rb56
-rw-r--r--spec/features/projects/merge_requests/user_views_open_merge_requests_spec.rb143
-rw-r--r--spec/features/projects/user_views_details_spec.rb151
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb1
-rw-r--r--spec/features/projects/wiki/shortcuts_spec.rb4
-rw-r--r--spec/features/projects/wiki/user_git_access_wiki_page_spec.rb9
-rw-r--r--spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb7
-rw-r--r--spec/features/projects_spec.rb2
-rw-r--r--spec/finders/group_descendants_finder_spec.rb166
-rw-r--r--spec/fixtures/api/schemas/registry/tag.json5
-rw-r--r--spec/fixtures/pages.tar.gzbin1795 -> 1884 bytes
-rw-r--r--spec/fixtures/pages.zipbin1851 -> 2338 bytes
-rw-r--r--spec/helpers/application_helper_spec.rb8
-rw-r--r--spec/initializers/settings_spec.rb20
-rw-r--r--spec/javascripts/awards_handler_spec.js5
-rw-r--r--spec/javascripts/behaviors/quick_submit_spec.js5
-rw-r--r--spec/javascripts/blob/blob_file_dropzone_spec.js1
-rw-r--r--spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js23
-rw-r--r--spec/javascripts/flash_spec.js21
-rw-r--r--spec/javascripts/gl_field_errors_spec.js180
-rw-r--r--spec/javascripts/gl_form_spec.js9
-rw-r--r--spec/javascripts/groups/components/app_spec.js443
-rw-r--r--spec/javascripts/groups/components/group_folder_spec.js66
-rw-r--r--spec/javascripts/groups/components/group_item_spec.js177
-rw-r--r--spec/javascripts/groups/components/groups_spec.js70
-rw-r--r--spec/javascripts/groups/components/item_actions_spec.js110
-rw-r--r--spec/javascripts/groups/components/item_caret_spec.js40
-rw-r--r--spec/javascripts/groups/components/item_stats_spec.js159
-rw-r--r--spec/javascripts/groups/components/item_type_icon_spec.js54
-rw-r--r--spec/javascripts/groups/group_item_spec.js102
-rw-r--r--spec/javascripts/groups/groups_spec.js99
-rw-r--r--spec/javascripts/groups/mock_data.js470
-rw-r--r--spec/javascripts/groups/service/groups_service_spec.js42
-rw-r--r--spec/javascripts/groups/store/groups_store_spec.js110
-rw-r--r--spec/javascripts/helpers/set_timeout_promise_helper.js3
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js11
-rw-r--r--spec/javascripts/issue_show/components/title_spec.js39
-rw-r--r--spec/javascripts/jobs/header_spec.js7
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js26
-rw-r--r--spec/javascripts/lib/utils/datefix_spec.js29
-rw-r--r--spec/javascripts/merge_request_notes_spec.js14
-rw-r--r--spec/javascripts/merge_request_tabs_spec.js5
-rw-r--r--spec/javascripts/notes_spec.js27
-rw-r--r--spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js12
-rw-r--r--spec/javascripts/registry/mock_data.js8
-rw-r--r--spec/javascripts/repo/components/repo_commit_section_spec.js172
-rw-r--r--spec/javascripts/repo/components/repo_edit_button_spec.js12
-rw-r--r--spec/javascripts/repo/components/repo_editor_spec.js5
-rw-r--r--spec/javascripts/repo/components/repo_file_buttons_spec.js4
-rw-r--r--spec/javascripts/repo/components/repo_file_options_spec.js33
-rw-r--r--spec/javascripts/repo/components/repo_file_spec.js103
-rw-r--r--spec/javascripts/repo/components/repo_loading_file_spec.js29
-rw-r--r--spec/javascripts/repo/components/repo_prev_directory_spec.js16
-rw-r--r--spec/javascripts/repo/components/repo_sidebar_spec.js117
-rw-r--r--spec/javascripts/repo/components/repo_tab_spec.js40
-rw-r--r--spec/javascripts/repo/components/repo_tabs_spec.js18
-rw-r--r--spec/javascripts/repo/mock_data.js13
-rw-r--r--spec/javascripts/search_autocomplete_spec.js30
-rw-r--r--spec/javascripts/shortcuts_issuable_spec.js4
-rw-r--r--spec/javascripts/shortcuts_spec.js11
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js84
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js (renamed from spec/javascripts/vue_shared/components/user_avatar_link_spec.js)0
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js (renamed from spec/javascripts/vue_shared/components/user_avatar_svg_spec.js)0
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar_image_spec.js54
-rw-r--r--spec/javascripts/zen_mode_spec.js3
-rw-r--r--spec/lib/banzai/filter/sanitization_filter_spec.rb5
-rw-r--r--spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb21
-rw-r--r--spec/lib/gitlab/backup/manager_spec.rb4
-rw-r--r--spec/lib/gitlab/conflict/file_collection_spec.rb2
-rw-r--r--spec/lib/gitlab/conflict/file_spec.rb18
-rw-r--r--spec/lib/gitlab/diff/parser_spec.rb17
-rw-r--r--spec/lib/gitlab/diff/position_spec.rb20
-rw-r--r--spec/lib/gitlab/encoding_helper_spec.rb16
-rw-r--r--spec/lib/gitlab/file_detector_spec.rb12
-rw-r--r--spec/lib/gitlab/git/conflict/parser_spec.rb (renamed from spec/lib/gitlab/conflict/parser_spec.rb)68
-rw-r--r--spec/lib/gitlab/git/env_spec.rb42
-rw-r--r--spec/lib/gitlab/git/popen_spec.rb132
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb111
-rw-r--r--spec/lib/gitlab/git/storage/circuit_breaker_spec.rb58
-rw-r--r--spec/lib/gitlab/git/storage/health_spec.rb30
-rw-r--r--spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb4
-rw-r--r--spec/lib/gitlab/gitaly_client/util_spec.rb8
-rw-r--r--spec/lib/gitlab/group_hierarchy_spec.rb28
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml1
-rw-r--r--spec/lib/gitlab/multi_collection_paginator_spec.rb46
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb14
-rw-r--r--spec/lib/gitlab/quick_actions/spend_time_and_date_separator_spec.rb81
-rw-r--r--spec/lib/gitlab/saml/auth_hash_spec.rb40
-rw-r--r--spec/lib/gitlab/saml/user_spec.rb32
-rw-r--r--spec/lib/gitlab/sidekiq_status_spec.rb12
-rw-r--r--spec/lib/gitlab/sql/union_spec.rb7
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb2
-rw-r--r--spec/lib/gitlab/utils/merge_hash_spec.rb33
-rw-r--r--spec/migrations/migrate_user_project_view_spec.rb7
-rw-r--r--spec/models/application_setting_spec.rb23
-rw-r--r--spec/models/blob_viewer/readme_spec.rb2
-rw-r--r--spec/models/ci/build_spec.rb31
-rw-r--r--spec/models/concerns/group_descendant_spec.rb166
-rw-r--r--spec/models/concerns/loaded_in_group_list_spec.rb49
-rw-r--r--spec/models/diff_note_spec.rb2
-rw-r--r--spec/models/gcp/cluster_spec.rb24
-rw-r--r--spec/models/merge_request_spec.rb22
-rw-r--r--spec/models/namespace_spec.rb28
-rw-r--r--spec/models/project_services/microsoft_teams_service_spec.rb8
-rw-r--r--spec/models/project_spec.rb21
-rw-r--r--spec/models/project_wiki_spec.rb58
-rw-r--r--spec/models/repository_spec.rb34
-rw-r--r--spec/requests/api/branches_spec.rb36
-rw-r--r--spec/requests/api/helpers_spec.rb18
-rw-r--r--spec/requests/api/settings_spec.rb5
-rw-r--r--spec/requests/api/v3/repositories_spec.rb11
-rw-r--r--spec/rubocop/cop/rspec/env_assignment_spec.rb59
-rw-r--r--spec/serializers/container_tag_entity_spec.rb2
-rw-r--r--spec/serializers/group_child_entity_spec.rb101
-rw-r--r--spec/serializers/group_child_serializer_spec.rb110
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb50
-rw-r--r--spec/services/ci/retry_build_service_spec.rb3
-rw-r--r--spec/services/merge_requests/conflicts/list_service_spec.rb2
-rw-r--r--spec/services/merge_requests/conflicts/resolve_service_spec.rb39
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb23
-rw-r--r--spec/services/notification_service_spec.rb12
-rw-r--r--spec/services/projects/destroy_service_spec.rb17
-rw-r--r--spec/services/projects/update_pages_service_spec.rb5
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb56
-rw-r--r--spec/services/system_note_service_spec.rb14
-rw-r--r--spec/support/api/scopes/read_user_shared_examples.rb8
-rw-r--r--spec/support/board_helpers.rb16
-rw-r--r--spec/support/email_helpers.rb14
-rw-r--r--spec/support/ldap_helpers.rb5
-rw-r--r--spec/support/login_helpers.rb12
-rw-r--r--spec/support/project_forks_helper.rb2
-rw-r--r--spec/support/redis_without_keys.rb8
-rw-r--r--spec/support/shared_examples/requests/api/status_shared_examples.rb7
-rw-r--r--spec/support/slack_mattermost_notifications_shared_examples.rb3
-rw-r--r--spec/support/stub_configuration.rb4
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb215
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb9
-rw-r--r--spec/tasks/gitlab/ldap_rake_spec.rb2
-rw-r--r--vendor/gitignore/Android.gitignore3
-rw-r--r--vendor/gitignore/Autotools.gitignore9
-rw-r--r--vendor/gitignore/Elixir.gitignore2
-rw-r--r--vendor/gitignore/ExtJs.gitignore2
-rw-r--r--vendor/gitignore/Global/Matlab.gitignore2
-rw-r--r--vendor/gitignore/Global/Xcode.gitignore18
-rw-r--r--vendor/gitignore/Global/macOS.gitignore2
-rw-r--r--vendor/gitignore/Joomla.gitignore2
-rw-r--r--vendor/gitignore/OCaml.gitignore3
-rw-r--r--vendor/gitignore/Python.gitignore5
-rw-r--r--vendor/gitignore/Qt.gitignore2
-rw-r--r--vendor/gitignore/TeX.gitignore1
-rw-r--r--vendor/gitignore/Terraform.gitignore3
-rw-r--r--vendor/gitignore/Umbraco.gitignore4
-rw-r--r--vendor/gitignore/VisualStudio.gitignore6
-rw-r--r--vendor/gitignore/ZendFramework.gitignore1
-rw-r--r--vendor/gitlab-ci-yml/Go.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Maven.gitlab-ci.yml7
-rw-r--r--vendor/gitlab-ci-yml/Python.gitlab-ci.yml32
-rw-r--r--vendor/licenses.csv154
-rw-r--r--yarn.lock18
533 files changed, 11377 insertions, 5326 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index fc0d2b71174..188e78d167b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -49,11 +49,10 @@ stages:
- gitlab-org
.tests-metadata-state: &tests-metadata-state
- services: []
+ <<: *dedicated-runner
variables:
- SETUP_DB: "false"
- USE_BUNDLE_INSTALL: "false"
TESTS_METADATA_S3_BUCKET: "gitlab-ce-cache"
+ before_script: []
artifacts:
expire_in: 31d
paths:
@@ -80,6 +79,7 @@ stages:
.rspec-metadata: &rspec-metadata
<<: *dedicated-runner
<<: *pull-cache
+ <<: *except-docs
stage: test
script:
- JOB_NAME=( $CI_JOB_NAME )
@@ -109,16 +109,15 @@ stages:
.rspec-metadata-pg: &rspec-metadata-pg
<<: *rspec-metadata
<<: *use-pg
- <<: *except-docs
.rspec-metadata-mysql: &rspec-metadata-mysql
<<: *rspec-metadata
<<: *use-mysql
- <<: *except-docs
.spinach-metadata: &spinach-metadata
<<: *dedicated-runner
<<: *pull-cache
+ <<: *except-docs
stage: test
script:
- JOB_NAME=( $CI_JOB_NAME )
@@ -141,12 +140,10 @@ stages:
.spinach-metadata-pg: &spinach-metadata-pg
<<: *spinach-metadata
<<: *use-pg
- <<: *except-docs
.spinach-metadata-mysql: &spinach-metadata-mysql
<<: *spinach-metadata
<<: *use-mysql
- <<: *except-docs
.only-canonical-masters: &only-canonical-masters
only:
@@ -222,7 +219,6 @@ review-docs-cleanup:
# Retrieve knapsack and rspec_flaky reports
retrieve-tests-metadata:
<<: *tests-metadata-state
- <<: *dedicated-runner
<<: *except-docs
stage: prepare
cache:
@@ -240,7 +236,6 @@ retrieve-tests-metadata:
update-tests-metadata:
<<: *tests-metadata-state
- <<: *dedicated-runner
<<: *only-canonical-masters
stage: post-test
cache:
@@ -305,69 +300,69 @@ setup-test-env:
- public/assets
- tmp/tests
-rspec-pg 0 25: *rspec-metadata-pg
-rspec-pg 1 25: *rspec-metadata-pg
-rspec-pg 2 25: *rspec-metadata-pg
-rspec-pg 3 25: *rspec-metadata-pg
-rspec-pg 4 25: *rspec-metadata-pg
-rspec-pg 5 25: *rspec-metadata-pg
-rspec-pg 6 25: *rspec-metadata-pg
-rspec-pg 7 25: *rspec-metadata-pg
-rspec-pg 8 25: *rspec-metadata-pg
-rspec-pg 9 25: *rspec-metadata-pg
-rspec-pg 10 25: *rspec-metadata-pg
-rspec-pg 11 25: *rspec-metadata-pg
-rspec-pg 12 25: *rspec-metadata-pg
-rspec-pg 13 25: *rspec-metadata-pg
-rspec-pg 14 25: *rspec-metadata-pg
-rspec-pg 15 25: *rspec-metadata-pg
-rspec-pg 16 25: *rspec-metadata-pg
-rspec-pg 17 25: *rspec-metadata-pg
-rspec-pg 18 25: *rspec-metadata-pg
-rspec-pg 19 25: *rspec-metadata-pg
-rspec-pg 20 25: *rspec-metadata-pg
-rspec-pg 21 25: *rspec-metadata-pg
-rspec-pg 22 25: *rspec-metadata-pg
-rspec-pg 23 25: *rspec-metadata-pg
-rspec-pg 24 25: *rspec-metadata-pg
-
-rspec-mysql 0 25: *rspec-metadata-mysql
-rspec-mysql 1 25: *rspec-metadata-mysql
-rspec-mysql 2 25: *rspec-metadata-mysql
-rspec-mysql 3 25: *rspec-metadata-mysql
-rspec-mysql 4 25: *rspec-metadata-mysql
-rspec-mysql 5 25: *rspec-metadata-mysql
-rspec-mysql 6 25: *rspec-metadata-mysql
-rspec-mysql 7 25: *rspec-metadata-mysql
-rspec-mysql 8 25: *rspec-metadata-mysql
-rspec-mysql 9 25: *rspec-metadata-mysql
-rspec-mysql 10 25: *rspec-metadata-mysql
-rspec-mysql 11 25: *rspec-metadata-mysql
-rspec-mysql 12 25: *rspec-metadata-mysql
-rspec-mysql 13 25: *rspec-metadata-mysql
-rspec-mysql 14 25: *rspec-metadata-mysql
-rspec-mysql 15 25: *rspec-metadata-mysql
-rspec-mysql 16 25: *rspec-metadata-mysql
-rspec-mysql 17 25: *rspec-metadata-mysql
-rspec-mysql 18 25: *rspec-metadata-mysql
-rspec-mysql 19 25: *rspec-metadata-mysql
-rspec-mysql 20 25: *rspec-metadata-mysql
-rspec-mysql 21 25: *rspec-metadata-mysql
-rspec-mysql 22 25: *rspec-metadata-mysql
-rspec-mysql 23 25: *rspec-metadata-mysql
-rspec-mysql 24 25: *rspec-metadata-mysql
-
-spinach-pg 0 5: *spinach-metadata-pg
-spinach-pg 1 5: *spinach-metadata-pg
-spinach-pg 2 5: *spinach-metadata-pg
-spinach-pg 3 5: *spinach-metadata-pg
-spinach-pg 4 5: *spinach-metadata-pg
-
-spinach-mysql 0 5: *spinach-metadata-mysql
-spinach-mysql 1 5: *spinach-metadata-mysql
-spinach-mysql 2 5: *spinach-metadata-mysql
-spinach-mysql 3 5: *spinach-metadata-mysql
-spinach-mysql 4 5: *spinach-metadata-mysql
+rspec-pg 0 26: *rspec-metadata-pg
+rspec-pg 1 26: *rspec-metadata-pg
+rspec-pg 2 26: *rspec-metadata-pg
+rspec-pg 3 26: *rspec-metadata-pg
+rspec-pg 4 26: *rspec-metadata-pg
+rspec-pg 5 26: *rspec-metadata-pg
+rspec-pg 6 26: *rspec-metadata-pg
+rspec-pg 7 26: *rspec-metadata-pg
+rspec-pg 8 26: *rspec-metadata-pg
+rspec-pg 9 26: *rspec-metadata-pg
+rspec-pg 10 26: *rspec-metadata-pg
+rspec-pg 11 26: *rspec-metadata-pg
+rspec-pg 12 26: *rspec-metadata-pg
+rspec-pg 13 26: *rspec-metadata-pg
+rspec-pg 14 26: *rspec-metadata-pg
+rspec-pg 15 26: *rspec-metadata-pg
+rspec-pg 16 26: *rspec-metadata-pg
+rspec-pg 17 26: *rspec-metadata-pg
+rspec-pg 18 26: *rspec-metadata-pg
+rspec-pg 19 26: *rspec-metadata-pg
+rspec-pg 20 26: *rspec-metadata-pg
+rspec-pg 21 26: *rspec-metadata-pg
+rspec-pg 22 26: *rspec-metadata-pg
+rspec-pg 23 26: *rspec-metadata-pg
+rspec-pg 24 26: *rspec-metadata-pg
+rspec-pg 25 26: *rspec-metadata-pg
+
+rspec-mysql 0 26: *rspec-metadata-mysql
+rspec-mysql 1 26: *rspec-metadata-mysql
+rspec-mysql 2 26: *rspec-metadata-mysql
+rspec-mysql 3 26: *rspec-metadata-mysql
+rspec-mysql 4 26: *rspec-metadata-mysql
+rspec-mysql 5 26: *rspec-metadata-mysql
+rspec-mysql 6 26: *rspec-metadata-mysql
+rspec-mysql 7 26: *rspec-metadata-mysql
+rspec-mysql 8 26: *rspec-metadata-mysql
+rspec-mysql 9 26: *rspec-metadata-mysql
+rspec-mysql 10 26: *rspec-metadata-mysql
+rspec-mysql 11 26: *rspec-metadata-mysql
+rspec-mysql 12 26: *rspec-metadata-mysql
+rspec-mysql 13 26: *rspec-metadata-mysql
+rspec-mysql 14 26: *rspec-metadata-mysql
+rspec-mysql 15 26: *rspec-metadata-mysql
+rspec-mysql 16 26: *rspec-metadata-mysql
+rspec-mysql 17 26: *rspec-metadata-mysql
+rspec-mysql 18 26: *rspec-metadata-mysql
+rspec-mysql 19 26: *rspec-metadata-mysql
+rspec-mysql 20 26: *rspec-metadata-mysql
+rspec-mysql 21 26: *rspec-metadata-mysql
+rspec-mysql 22 26: *rspec-metadata-mysql
+rspec-mysql 23 26: *rspec-metadata-mysql
+rspec-mysql 24 26: *rspec-metadata-mysql
+rspec-mysql 25 26: *rspec-metadata-mysql
+
+spinach-pg 0 4: *spinach-metadata-pg
+spinach-pg 1 4: *spinach-metadata-pg
+spinach-pg 2 4: *spinach-metadata-pg
+spinach-pg 3 4: *spinach-metadata-pg
+
+spinach-mysql 0 4: *spinach-metadata-mysql
+spinach-mysql 1 4: *spinach-metadata-mysql
+spinach-mysql 2 4: *spinach-metadata-mysql
+spinach-mysql 3 4: *spinach-metadata-mysql
# Static analysis jobs
.ruby-static-analysis: &ruby-static-analysis
diff --git a/CHANGELOG.md b/CHANGELOG.md
index efd32d44890..c857efddb15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 10.0.4 (2017-10-16)
+
+- [SECURITY] Move project repositories between namespaces when renaming users.
+- [SECURITY] Prevent an open redirect on project pages.
+- [SECURITY] Prevent a persistent XSS in user-provided markup.
+
## 10.0.3 (2017-10-05)
- [FIXED] find_user Users helper method no longer overrides find_user API helper method. !14418
@@ -212,6 +218,14 @@ entry.
- Added type to CHANGELOG entries. (Jacopo Beschi @jacopo-beschi)
- [BUGIFX] Improves subgroup creation permissions. !13418
+## 9.5.9 (2017-10-16)
+
+- [SECURITY] Move project repositories between namespaces when renaming users.
+- [SECURITY] Prevent an open redirect on project pages.
+- [SECURITY] Prevent a persistent XSS in user-provided markup.
+- [FIXED] Allow using newlines in pipeline email service recipients. !14250
+- Escape user name in filtered search bar.
+
## 9.5.8 (2017-10-04)
- [FIXED] Fixed fork button being disabled for users who can fork to a group.
@@ -457,6 +471,15 @@ entry.
- Use a specialized class for querying events to improve performance.
- Update build badges to be pipeline badges and display passing instead of success.
+## 9.4.7 (2017-10-16)
+
+- [SECURITY] Upgrade mail and nokogiri gems due to security issues. !13662 (Markus Koller)
+- [SECURITY] Move project repositories between namespaces when renaming users.
+- [SECURITY] Prevent an open redirect on project pages.
+- [SECURITY] Prevent a persistent XSS in user-provided markup.
+- [FIXED] Allow using newlines in pipeline email service recipients. !14250
+- Escape user name in filtered search bar.
+
## 9.4.6 (2017-09-06)
- [SECURITY] Upgrade mail and nokogiri gems due to security issues. !13662 (Markus Koller)
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index ff12df2fbb0..a758a09aae5 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-0.45.1
+0.48.0
diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION
index 5508e17c23b..c5b7013b9c5 100644
--- a/GITLAB_SHELL_VERSION
+++ b/GITLAB_SHELL_VERSION
@@ -1,2 +1 @@
-5.9.3
-
+5.9.4
diff --git a/Gemfile b/Gemfile
index 80cbf87cd09..6859ea73e4f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -398,7 +398,7 @@ group :ed25519 do
end
# Gitaly GRPC client
-gem 'gitaly-proto', '~> 0.41.0', require: 'gitaly'
+gem 'gitaly-proto', '~> 0.45.0', require: 'gitaly'
gem 'toml-rb', '~> 0.3.15', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index ecf09da283a..27a76dc36cb 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -273,7 +273,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
- gitaly-proto (0.41.0)
+ gitaly-proto (0.45.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
@@ -324,7 +324,9 @@ GEM
mime-types (~> 3.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
- google-protobuf (3.4.0.2)
+ google-protobuf (3.4.1.1)
+ googleapis-common-protos-types (1.0.0)
+ google-protobuf (~> 3.0)
googleauth (0.5.3)
faraday (~> 0.12)
jwt (~> 1.4)
@@ -351,8 +353,9 @@ GEM
rake
grape_logging (1.7.0)
grape
- grpc (1.6.0)
+ grpc (1.6.6)
google-protobuf (~> 3.1)
+ googleapis-common-protos-types (~> 1.0.0)
googleauth (~> 0.5.1)
haml (4.0.7)
tilt
@@ -1027,7 +1030,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
- gitaly-proto (~> 0.41.0)
+ gitaly-proto (~> 0.45.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.6.2)
diff --git a/app/assets/images/auth_buttons/signin_with_google.png b/app/assets/images/auth_buttons/signin_with_google.png
index b1327b4f7b4..f27bb243304 100644
--- a/app/assets/images/auth_buttons/signin_with_google.png
+++ b/app/assets/images/auth_buttons/signin_with_google.png
Binary files differ
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 38d1effc77c..242b3e2b990 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -15,6 +15,7 @@ const Api = {
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
usersPath: '/api/:version/users.json',
commitPath: '/api/:version/projects/:id/repository/commits',
+ branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath)
@@ -123,6 +124,19 @@ const Api = {
});
},
+ branchSingle(id, branch) {
+ const url = Api.buildUrl(Api.branchSinglePath)
+ .replace(':id', id)
+ .replace(':branch', branch);
+
+ return this.wrapAjaxCall({
+ url,
+ type: 'GET',
+ contentType: 'application/json; charset=utf-8',
+ dataType: 'json',
+ });
+ },
+
// Return text for a specific license
licenseText(key, data, callback) {
const url = Api.buildUrl(Api.licensePath)
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index ddd1fea3aca..0d590a9dbc4 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -1,6 +1,5 @@
/* eslint-disable func-names, object-shorthand, prefer-arrow-callback */
-/* global Dropzone */
-
+import Dropzone from 'dropzone';
import '../lib/utils/url_utility';
import { HIDDEN_CLASS } from '../lib/utils/constants';
import csrf from '../lib/utils/csrf';
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 7f3afefc9cc..c1f902a785a 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -9,6 +9,7 @@ import Flash from '../../flash';
import eventHub from '../../sidebar/event_hub';
import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
import Assignees from '../../sidebar/components/assignees/assignees';
+import DueDateSelectors from '../../due_date_select';
import './sidebar/remove_issue';
const Store = gl.issueBoards.BoardsStore;
@@ -113,7 +114,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
mounted () {
new IssuableContext(this.currentUser);
new MilestoneSelect();
- new gl.DueDateSelectors();
+ new DueDateSelectors();
new LabelsSelect();
new Sidebar();
gl.Subscription.bindAll('.subscription');
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 38eea38f949..97e80afa3f8 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -7,7 +7,7 @@ class BoardService {
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: {
method: 'GET',
- url: `${gon.relative_url_root}/boards/${boardId}/issues.json`,
+ url: `${gon.relative_url_root}/-/boards/${boardId}/issues.json`,
}
});
this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
@@ -16,7 +16,7 @@ class BoardService {
url: `${listsEndpoint}/generate.json`
}
});
- this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
+ this.issue = Vue.resource(`${gon.relative_url_root}/-/boards/${boardId}/issues{/id}`, {});
this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
bulkUpdate: {
method: 'POST',
diff --git a/app/assets/javascripts/broadcast_message.js b/app/assets/javascripts/broadcast_message.js
index f73e489e7b2..ff88083a4b4 100644
--- a/app/assets/javascripts/broadcast_message.js
+++ b/app/assets/javascripts/broadcast_message.js
@@ -1,33 +1,28 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, no-else-return, object-shorthand, comma-dangle, max-len */
-
-$(function() {
- var previewPath;
- $('input#broadcast_message_color').on('input', function() {
- var previewColor;
- previewColor = $(this).val();
- return $('div.broadcast-message-preview').css('background-color', previewColor);
+export default function initBroadcastMessagesForm() {
+ $('input#broadcast_message_color').on('input', function onMessageColorInput() {
+ const previewColor = $(this).val();
+ $('div.broadcast-message-preview').css('background-color', previewColor);
});
- $('input#broadcast_message_font').on('input', function() {
- var previewColor;
- previewColor = $(this).val();
- return $('div.broadcast-message-preview').css('color', previewColor);
+
+ $('input#broadcast_message_font').on('input', function onMessageFontInput() {
+ const previewColor = $(this).val();
+ $('div.broadcast-message-preview').css('color', previewColor);
});
- previewPath = $('textarea#broadcast_message_message').data('preview-path');
- return $('textarea#broadcast_message_message').on('input', function() {
- var message;
- message = $(this).val();
+
+ const previewPath = $('textarea#broadcast_message_message').data('preview-path');
+
+ $('textarea#broadcast_message_message').on('input', _.debounce(function onMessageInput() {
+ const message = $(this).val();
if (message === '') {
- return $('.js-broadcast-message-preview').text("Your message here");
+ $('.js-broadcast-message-preview').text('Your message here');
} else {
- return $.ajax({
+ $.ajax({
url: previewPath,
- type: "POST",
+ type: 'POST',
data: {
- broadcast_message: {
- message: message
- }
- }
+ broadcast_message: { message },
+ },
});
}
- });
-});
+ }, 250));
+}
diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js
index 50dbeb06362..180aa30e98c 100644
--- a/app/assets/javascripts/clusters.js
+++ b/app/assets/javascripts/clusters.js
@@ -3,7 +3,8 @@ import Visibility from 'visibilityjs';
import axios from 'axios';
import Poll from './lib/utils/poll';
import { s__ } from './locale';
-import './flash';
+import initSettingsPanels from './settings_panels';
+import Flash from './flash';
/**
* Cluster page has 2 separate parts:
@@ -24,6 +25,8 @@ class ClusterService {
export default class Clusters {
constructor() {
+ initSettingsPanels();
+
const dataset = document.querySelector('.js-edit-cluster-form').dataset;
this.state = {
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 5ebc92110dd..2885923aeda 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -1,8 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */
/* global ProjectSelect */
-/* global ShortcutsNavigation */
/* global IssuableIndex */
-/* global ShortcutsIssuable */
/* global Milestone */
/* global IssuableForm */
/* global LabelsSelect */
@@ -31,10 +29,7 @@ import CILintEditor from './ci_lint_editor';
/* global ProjectImport */
import Labels from './labels';
import LabelManager from './label_manager';
-/* global Shortcuts */
-/* global ShortcutsFindFile */
/* global Sidebar */
-/* global ShortcutsWiki */
import CommitsList from './commits';
import Issue from './issue';
@@ -70,6 +65,7 @@ import initSettingsPanels from './settings_panels';
import initExperimentalFlags from './experimental_flags';
import OAuthRememberMe from './oauth_remember_me';
import PerformanceBar from './performance_bar';
+import initBroadcastMessagesForm from './broadcast_message';
import initNotes from './init_notes';
import initLegacyFilters from './init_legacy_filters';
import initIssuableSidebar from './init_issuable_sidebar';
@@ -77,10 +73,20 @@ import initProjectVisibilitySelector from './project_visibility';
import GpgBadges from './gpg_badges';
import UserFeatureHelper from './helpers/user_feature_helper';
import initChangesDropdown from './init_changes_dropdown';
+import NewGroupChild from './groups/new_group_child';
import AbuseReports from './abuse_reports';
import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
import AjaxLoadingSpinner from './ajax_loading_spinner';
+import GlFieldErrors from './gl_field_errors';
+import GLForm from './gl_form';
+import Shortcuts from './shortcuts';
+import ShortcutsNavigation from './shortcuts_navigation';
+import ShortcutsFindFile from './shortcuts_find_file';
+import ShortcutsIssuable from './shortcuts_issuable';
import U2FAuthenticate from './u2f/authenticate';
+import Members from './members';
+import memberExpirationDate from './member_expiration_date';
+import DueDateSelectors from './due_date_select';
(function() {
var Dispatcher;
@@ -164,9 +170,6 @@ import U2FAuthenticate from './u2f/authenticate';
const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
filteredSearchManager.setup();
}
- if (page === 'projects:merge_requests:index') {
- new UserCallout({ setCalloutPerProject: true });
- }
const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
IssuableIndex.init(pagePrefix);
@@ -230,8 +233,8 @@ import U2FAuthenticate from './u2f/authenticate';
case 'groups:milestones:edit':
case 'groups:milestones:update':
new ZenMode();
- new gl.DueDateSelectors();
- new gl.GLForm($('.milestone-form'), true);
+ new DueDateSelectors();
+ new GLForm($('.milestone-form'), true);
break;
case 'projects:compare:show':
new gl.Diff();
@@ -248,7 +251,7 @@ import U2FAuthenticate from './u2f/authenticate';
case 'projects:issues:new':
case 'projects:issues:edit':
shortcut_handler = new ShortcutsNavigation();
- new gl.GLForm($('.issue-form'), true);
+ new GLForm($('.issue-form'), true);
new IssuableForm($('.issue-form'));
new LabelsSelect();
new MilestoneSelect();
@@ -272,7 +275,7 @@ import U2FAuthenticate from './u2f/authenticate';
case 'projects:merge_requests:edit':
new gl.Diff();
shortcut_handler = new ShortcutsNavigation();
- new gl.GLForm($('.merge-request-form'), true);
+ new GLForm($('.merge-request-form'), true);
new IssuableForm($('.merge-request-form'));
new LabelsSelect();
new MilestoneSelect();
@@ -281,7 +284,7 @@ import U2FAuthenticate from './u2f/authenticate';
break;
case 'projects:tags:new':
new ZenMode();
- new gl.GLForm($('.tag-form'), true);
+ new GLForm($('.tag-form'), true);
new RefSelectDropdown($('.js-branch-select'));
break;
case 'projects:snippets:show':
@@ -291,17 +294,17 @@ import U2FAuthenticate from './u2f/authenticate';
case 'projects:snippets:edit':
case 'projects:snippets:create':
case 'projects:snippets:update':
- new gl.GLForm($('.snippet-form'), true);
+ new GLForm($('.snippet-form'), true);
break;
case 'snippets:new':
case 'snippets:edit':
case 'snippets:create':
case 'snippets:update':
- new gl.GLForm($('.snippet-form'), false);
+ new GLForm($('.snippet-form'), false);
break;
case 'projects:releases:edit':
new ZenMode();
- new gl.GLForm($('.release-form'), true);
+ new GLForm($('.release-form'), true);
break;
case 'projects:merge_requests:show':
new gl.Diff();
@@ -348,7 +351,10 @@ import U2FAuthenticate from './u2f/authenticate';
case 'projects:show':
shortcut_handler = new ShortcutsNavigation();
new NotificationsForm();
- new UserCallout({ setCalloutPerProject: true });
+ new UserCallout({
+ setCalloutPerProject: true,
+ className: 'js-autodevops-banner',
+ });
if ($('#tree-slider').length) new TreeView();
if ($('.blob-viewer').length) new BlobViewer();
@@ -368,9 +374,6 @@ import U2FAuthenticate from './u2f/authenticate';
case 'projects:pipelines:new':
new NewBranchForm($('.js-new-pipeline-form'));
break;
- case 'projects:pipelines:index':
- new UserCallout({ setCalloutPerProject: true });
- break;
case 'projects:pipelines:builds':
case 'projects:pipelines:failures':
case 'projects:pipelines:show':
@@ -391,21 +394,26 @@ import U2FAuthenticate from './u2f/authenticate';
new gl.Activities();
break;
case 'groups:show':
+ const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
shortcut_handler = new ShortcutsNavigation();
new NotificationsForm();
new NotificationsDropdown();
new ProjectsList();
+
+ if (newGroupChildWrapper) {
+ new NewGroupChild(newGroupChildWrapper);
+ }
break;
case 'groups:group_members:index':
- new gl.MemberExpirationDate();
- new gl.Members();
+ memberExpirationDate();
+ new Members();
new UsersSelect();
break;
case 'projects:project_members:index':
- new gl.MemberExpirationDate('.js-access-expiration-date-groups');
+ memberExpirationDate('.js-access-expiration-date-groups');
new GroupsSelect();
- new gl.MemberExpirationDate();
- new gl.Members();
+ memberExpirationDate();
+ new Members();
new UsersSelect();
break;
case 'groups:new':
@@ -428,7 +436,6 @@ import U2FAuthenticate from './u2f/authenticate';
new TreeView();
new BlobViewer();
new NewCommitForm($('.js-create-dir-form'));
- new UserCallout({ setCalloutPerProject: true });
$('#tree-slider').waitForImages(function() {
ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
});
@@ -526,7 +533,7 @@ import U2FAuthenticate from './u2f/authenticate';
break;
case 'profiles:personal_access_tokens:index':
case 'admin:impersonation_tokens:index':
- new gl.DueDateSelectors();
+ new DueDateSelectors();
break;
case 'projects:clusters:show':
import(/* webpackChunkName: "clusters" */ './clusters')
@@ -551,6 +558,9 @@ import U2FAuthenticate from './u2f/authenticate';
case 'admin':
new Admin();
switch (path[1]) {
+ case 'broadcast_messages':
+ initBroadcastMessagesForm();
+ break;
case 'cohorts':
new UsagePing();
break;
@@ -607,7 +617,7 @@ import U2FAuthenticate from './u2f/authenticate';
new Wikis();
shortcut_handler = new ShortcutsWiki();
new ZenMode();
- new gl.GLForm($('.wiki-form'), true);
+ new GLForm($('.wiki-form'), true);
break;
case 'snippets':
shortcut_handler = new ShortcutsNavigation();
@@ -632,12 +642,6 @@ import U2FAuthenticate from './u2f/authenticate';
shortcut_handler = new ShortcutsNavigation();
}
break;
- case 'users':
- const action = path[1];
- import(/* webpackChunkName: 'user_profile' */ './users')
- .then(user => user.default(action))
- .catch(() => {});
- break;
}
// If we haven't installed a custom shortcut handler, install the default one
if (!shortcut_handler) {
@@ -658,7 +662,7 @@ import U2FAuthenticate from './u2f/authenticate';
Dispatcher.prototype.initFieldErrors = function() {
$('.gl-show-field-errors').each((i, form) => {
- new gl.GlFieldErrors(form);
+ new GlFieldErrors(form);
});
};
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index bd45da8c422..7a17adcd44e 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,308 +1,276 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, one-var, no-var, one-var-declaration-per-line, no-unused-vars, camelcase, quotes, no-useless-concat, prefer-template, quote-props, comma-dangle, object-shorthand, consistent-return, prefer-arrow-callback */
-/* global Dropzone */
+import Dropzone from 'dropzone';
import _ from 'underscore';
import './preview_markdown';
import csrf from './lib/utils/csrf';
-window.DropzoneInput = (function() {
- function DropzoneInput(form) {
- const divHover = '<div class="div-dropzone-hover"></div>';
- const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
- const $attachButton = form.find('.button-attach-file');
- const $attachingFileMessage = form.find('.attaching-file-message');
- const $cancelButton = form.find('.button-cancel-uploading-files');
- const $retryLink = form.find('.retry-uploading-link');
- const $uploadProgress = form.find('.uploading-progress');
- const $uploadingErrorContainer = form.find('.uploading-error-container');
- const $uploadingErrorMessage = form.find('.uploading-error-message');
- const $uploadingProgressContainer = form.find('.uploading-progress-container');
- const uploadsPath = window.uploads_path || null;
- const maxFileSize = gon.max_file_size || 10;
- const formTextarea = form.find('.js-gfm-input');
- let handlePaste;
- let pasteText;
- let addFileToForm;
- let updateAttachingMessage;
- let isImage;
- let getFilename;
- let uploadFile;
-
- formTextarea.wrap('<div class="div-dropzone"></div>');
- formTextarea.on('paste', (function(_this) {
- return function(event) {
- return handlePaste(event);
- };
- })(this));
-
- // Add dropzone area to the form.
- const $mdArea = formTextarea.closest('.md-area');
- form.setupMarkdownPreview();
- const $formDropzone = form.find('.div-dropzone');
- $formDropzone.parent().addClass('div-dropzone-wrapper');
- $formDropzone.append(divHover);
- $formDropzone.find('.div-dropzone-hover').append(iconPaperclip);
-
- if (!uploadsPath) return;
-
- const dropzone = $formDropzone.dropzone({
- url: uploadsPath,
- dictDefaultMessage: '',
- clickable: true,
- paramName: 'file',
- maxFilesize: maxFileSize,
- uploadMultiple: false,
- headers: csrf.headers,
- previewContainer: false,
- processing: function() {
- return $('.div-dropzone-alert').alert('close');
- },
- dragover: function() {
- $mdArea.addClass('is-dropzone-hover');
- form.find('.div-dropzone-hover').css('opacity', 0.7);
- },
- dragleave: function() {
- $mdArea.removeClass('is-dropzone-hover');
- form.find('.div-dropzone-hover').css('opacity', 0);
- },
- drop: function() {
- $mdArea.removeClass('is-dropzone-hover');
- form.find('.div-dropzone-hover').css('opacity', 0);
- formTextarea.focus();
- },
- success: function(header, response) {
- const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length;
- const shouldPad = processingFileCount >= 1;
-
- pasteText(response.link.markdown, shouldPad);
- // Show 'Attach a file' link only when all files have been uploaded.
- if (!processingFileCount) $attachButton.removeClass('hide');
- addFileToForm(response.link.url);
- },
- error: function(file, errorMessage = 'Attaching the file failed.', xhr) {
- // If 'error' event is fired by dropzone, the second parameter is error message.
- // If the 'errorMessage' parameter is empty, the default error message is set.
- // If the 'error' event is fired by backend (xhr) error response, the third parameter is
- // xhr object (xhr.responseText is error message).
- // On error we hide the 'Attach' and 'Cancel' buttons
- // and show an error.
-
- // If there's xhr error message, let's show it instead of dropzone's one.
- const message = xhr ? xhr.responseText : errorMessage;
-
- $uploadingErrorContainer.removeClass('hide');
- $uploadingErrorMessage.html(message);
- $attachButton.addClass('hide');
- $cancelButton.addClass('hide');
- },
- totaluploadprogress: function(totalUploadProgress) {
- updateAttachingMessage(this.files, $attachingFileMessage);
- $uploadProgress.text(Math.round(totalUploadProgress) + '%');
- },
- sending: function(file) {
- // DOM elements already exist.
- // Instead of dynamically generating them,
- // we just either hide or show them.
- $attachButton.addClass('hide');
- $uploadingErrorContainer.addClass('hide');
- $uploadingProgressContainer.removeClass('hide');
- $cancelButton.removeClass('hide');
- },
- removedfile: function() {
- $attachButton.removeClass('hide');
- $cancelButton.addClass('hide');
- $uploadingProgressContainer.addClass('hide');
- $uploadingErrorContainer.addClass('hide');
- },
- queuecomplete: function() {
- $('.dz-preview').remove();
- $('.markdown-area').trigger('input');
+export default function dropzoneInput(form) {
+ const divHover = '<div class="div-dropzone-hover"></div>';
+ const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
+ const $attachButton = form.find('.button-attach-file');
+ const $attachingFileMessage = form.find('.attaching-file-message');
+ const $cancelButton = form.find('.button-cancel-uploading-files');
+ const $retryLink = form.find('.retry-uploading-link');
+ const $uploadProgress = form.find('.uploading-progress');
+ const $uploadingErrorContainer = form.find('.uploading-error-container');
+ const $uploadingErrorMessage = form.find('.uploading-error-message');
+ const $uploadingProgressContainer = form.find('.uploading-progress-container');
+ const uploadsPath = window.uploads_path || null;
+ const maxFileSize = gon.max_file_size || 10;
+ const formTextarea = form.find('.js-gfm-input');
+ let handlePaste;
+ let pasteText;
+ let addFileToForm;
+ let updateAttachingMessage;
+ let isImage;
+ let getFilename;
+ let uploadFile;
+
+ formTextarea.wrap('<div class="div-dropzone"></div>');
+ formTextarea.on('paste', event => handlePaste(event));
+
+ // Add dropzone area to the form.
+ const $mdArea = formTextarea.closest('.md-area');
+ form.setupMarkdownPreview();
+ const $formDropzone = form.find('.div-dropzone');
+ $formDropzone.parent().addClass('div-dropzone-wrapper');
+ $formDropzone.append(divHover);
+ $formDropzone.find('.div-dropzone-hover').append(iconPaperclip);
+
+ if (!uploadsPath) return;
+
+ const dropzone = $formDropzone.dropzone({
+ url: uploadsPath,
+ dictDefaultMessage: '',
+ clickable: true,
+ paramName: 'file',
+ maxFilesize: maxFileSize,
+ uploadMultiple: false,
+ headers: csrf.headers,
+ previewContainer: false,
+ processing: () => $('.div-dropzone-alert').alert('close'),
+ dragover: () => {
+ $mdArea.addClass('is-dropzone-hover');
+ form.find('.div-dropzone-hover').css('opacity', 0.7);
+ },
+ dragleave: () => {
+ $mdArea.removeClass('is-dropzone-hover');
+ form.find('.div-dropzone-hover').css('opacity', 0);
+ },
+ drop: () => {
+ $mdArea.removeClass('is-dropzone-hover');
+ form.find('.div-dropzone-hover').css('opacity', 0);
+ formTextarea.focus();
+ },
+ success(header, response) {
+ const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length;
+ const shouldPad = processingFileCount >= 1;
+
+ pasteText(response.link.markdown, shouldPad);
+ // Show 'Attach a file' link only when all files have been uploaded.
+ if (!processingFileCount) $attachButton.removeClass('hide');
+ addFileToForm(response.link.url);
+ },
+ error: (file, errorMessage = 'Attaching the file failed.', xhr) => {
+ // If 'error' event is fired by dropzone, the second parameter is error message.
+ // If the 'errorMessage' parameter is empty, the default error message is set.
+ // If the 'error' event is fired by backend (xhr) error response, the third parameter is
+ // xhr object (xhr.responseText is error message).
+ // On error we hide the 'Attach' and 'Cancel' buttons
+ // and show an error.
+
+ // If there's xhr error message, let's show it instead of dropzone's one.
+ const message = xhr ? xhr.responseText : errorMessage;
- $uploadingProgressContainer.addClass('hide');
- $cancelButton.addClass('hide');
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ $attachButton.addClass('hide');
+ $cancelButton.addClass('hide');
+ },
+ totaluploadprogress(totalUploadProgress) {
+ updateAttachingMessage(this.files, $attachingFileMessage);
+ $uploadProgress.text(`${Math.round(totalUploadProgress)}%`);
+ },
+ sending: () => {
+ // DOM elements already exist.
+ // Instead of dynamically generating them,
+ // we just either hide or show them.
+ $attachButton.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
+ $uploadingProgressContainer.removeClass('hide');
+ $cancelButton.removeClass('hide');
+ },
+ removedfile: () => {
+ $attachButton.removeClass('hide');
+ $cancelButton.addClass('hide');
+ $uploadingProgressContainer.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
+ },
+ queuecomplete: () => {
+ $('.dz-preview').remove();
+ $('.markdown-area').trigger('input');
+
+ $uploadingProgressContainer.addClass('hide');
+ $cancelButton.addClass('hide');
+ },
+ });
+
+ const child = $(dropzone[0]).children('textarea');
+
+ // removeAllFiles(true) stops uploading files (if any)
+ // and remove them from dropzone files queue.
+ $cancelButton.on('click', (e) => {
+ const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone');
+
+ e.preventDefault();
+ e.stopPropagation();
+ Dropzone.forElement(target).removeAllFiles(true);
+ });
+
+ // If 'error' event is fired, we store a failed files,
+ // clear dropzone files queue, change status of failed files to undefined,
+ // and add that files to the dropzone files queue again.
+ // addFile() adds file to dropzone files queue and upload it.
+ $retryLink.on('click', (e) => {
+ const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone'));
+ const failedFiles = dropzoneInstance.files;
+
+ e.preventDefault();
+
+ // 'true' parameter of removeAllFiles() cancels
+ // uploading of files that are being uploaded at the moment.
+ dropzoneInstance.removeAllFiles(true);
+
+ failedFiles.map((failedFile) => {
+ const file = failedFile;
+
+ if (file.status === Dropzone.ERROR) {
+ file.status = undefined;
+ file.accepted = undefined;
}
- });
-
- const child = $(dropzone[0]).children('textarea');
-
- // removeAllFiles(true) stops uploading files (if any)
- // and remove them from dropzone files queue.
- $cancelButton.on('click', (e) => {
- const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone');
-
- e.preventDefault();
- e.stopPropagation();
- Dropzone.forElement(target).removeAllFiles(true);
- });
-
- // If 'error' event is fired, we store a failed files,
- // clear dropzone files queue, change status of failed files to undefined,
- // and add that files to the dropzone files queue again.
- // addFile() adds file to dropzone files queue and upload it.
- $retryLink.on('click', (e) => {
- const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone'));
- const failedFiles = dropzoneInstance.files;
- e.preventDefault();
-
- // 'true' parameter of removeAllFiles() cancels uploading of files that are being uploaded at the moment.
- dropzoneInstance.removeAllFiles(true);
-
- failedFiles.map((failedFile, i) => {
- const file = failedFile;
-
- if (file.status === Dropzone.ERROR) {
- file.status = undefined;
- file.accepted = undefined;
- }
-
- return dropzoneInstance.addFile(file);
- });
+ return dropzoneInstance.addFile(file);
});
-
- handlePaste = function(event) {
- var filename, image, pasteEvent, text;
- pasteEvent = event.originalEvent;
- if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) {
- image = isImage(pasteEvent);
- if (image) {
- event.preventDefault();
- filename = getFilename(pasteEvent) || 'image.png';
- text = `{{${filename}}}`;
- pasteText(text);
- return uploadFile(image.getAsFile(), filename);
- }
+ });
+ // eslint-disable-next-line consistent-return
+ handlePaste = (event) => {
+ const pasteEvent = event.originalEvent;
+ if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) {
+ const image = isImage(pasteEvent);
+ if (image) {
+ event.preventDefault();
+ const filename = getFilename(pasteEvent) || 'image.png';
+ const text = `{{${filename}}}`;
+ pasteText(text);
+ return uploadFile(image.getAsFile(), filename);
}
- };
-
- isImage = function(data) {
- var i, item;
- i = 0;
- while (i < data.clipboardData.items.length) {
- item = data.clipboardData.items[i];
- if (item.type.indexOf('image') !== -1) {
- return item;
- }
- i += 1;
- }
- return false;
- };
-
- pasteText = function(text, shouldPad) {
- var afterSelection, beforeSelection, caretEnd, caretStart, textEnd;
- var formattedText = text;
- if (shouldPad) formattedText += "\n\n";
- const textarea = child.get(0);
- caretStart = textarea.selectionStart;
- caretEnd = textarea.selectionEnd;
- textEnd = $(child).val().length;
- beforeSelection = $(child).val().substring(0, caretStart);
- afterSelection = $(child).val().substring(caretEnd, textEnd);
- $(child).val(beforeSelection + formattedText + afterSelection);
- textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
- textarea.style.height = `${textarea.scrollHeight}px`;
- formTextarea.get(0).dispatchEvent(new Event('input'));
- return formTextarea.trigger('input');
- };
-
- addFileToForm = function(path) {
- $(form).append('<input type="hidden" name="files[]" value="' + _.escape(path) + '">');
- };
-
- getFilename = function(e) {
- var value;
- if (window.clipboardData && window.clipboardData.getData) {
- value = window.clipboardData.getData('Text');
- } else if (e.clipboardData && e.clipboardData.getData) {
- value = e.clipboardData.getData('text/plain');
- }
- value = value.split("\r");
- return value[0];
- };
-
- const showSpinner = function(e) {
- return $uploadingProgressContainer.removeClass('hide');
- };
-
- const closeSpinner = function() {
- return $uploadingProgressContainer.addClass('hide');
- };
-
- const showError = function(message) {
- $uploadingErrorContainer.removeClass('hide');
- $uploadingErrorMessage.html(message);
- };
-
- const closeAlertMessage = function() {
- return form.find('.div-dropzone-alert').alert('close');
- };
-
- const insertToTextArea = function(filename, url) {
- const $child = $(child);
- $child.val(function(index, val) {
- return val.replace(`{{${filename}}}`, url);
- });
-
- $child.trigger('change');
- };
-
- const appendToTextArea = function(url) {
- return $(child).val(function(index, val) {
- return val + url + "\n";
- });
- };
-
- uploadFile = function(item, filename) {
- var formData;
- formData = new FormData();
- formData.append('file', item, filename);
- return $.ajax({
- url: uploadsPath,
- type: 'POST',
- data: formData,
- dataType: 'json',
- processData: false,
- contentType: false,
- headers: csrf.headers,
- beforeSend: function() {
- showSpinner();
- return closeAlertMessage();
- },
- success: function(e, textStatus, response) {
- return insertToTextArea(filename, response.responseJSON.link.markdown);
- },
- error: function(response) {
- return showError(response.responseJSON.message);
- },
- complete: function() {
- return closeSpinner();
- }
- });
- };
-
- updateAttachingMessage = (files, messageContainer) => {
- let attachingMessage;
- const filesCount = files.filter(function(file) {
- return file.status === 'uploading' ||
- file.status === 'queued';
- }).length;
-
- // Dinamycally change uploading files text depending on files number in
- // dropzone files queue.
- if (filesCount > 1) {
- attachingMessage = 'Attaching ' + filesCount + ' files -';
- } else {
- attachingMessage = 'Attaching a file -';
+ }
+ };
+
+ isImage = (data) => {
+ let i = 0;
+ while (i < data.clipboardData.items.length) {
+ const item = data.clipboardData.items[i];
+ if (item.type.indexOf('image') !== -1) {
+ return item;
}
-
- messageContainer.text(attachingMessage);
- };
-
- form.find('.markdown-selector').click(function(e) {
- e.preventDefault();
- $(this).closest('.gfm-form').find('.div-dropzone').click();
- formTextarea.focus();
+ i += 1;
+ }
+ return false;
+ };
+
+ pasteText = (text, shouldPad) => {
+ let formattedText = text;
+ if (shouldPad) {
+ formattedText += '\n\n';
+ }
+ const textarea = child.get(0);
+ const caretStart = textarea.selectionStart;
+ const caretEnd = textarea.selectionEnd;
+ const textEnd = $(child).val().length;
+ const beforeSelection = $(child).val().substring(0, caretStart);
+ const afterSelection = $(child).val().substring(caretEnd, textEnd);
+ $(child).val(beforeSelection + formattedText + afterSelection);
+ textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ formTextarea.get(0).dispatchEvent(new Event('input'));
+ return formTextarea.trigger('input');
+ };
+
+ addFileToForm = (path) => {
+ $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`);
+ };
+
+ getFilename = (e) => {
+ let value;
+ if (window.clipboardData && window.clipboardData.getData) {
+ value = window.clipboardData.getData('Text');
+ } else if (e.clipboardData && e.clipboardData.getData) {
+ value = e.clipboardData.getData('text/plain');
+ }
+ value = value.split('\r');
+ return value[0];
+ };
+
+ const showSpinner = () => $uploadingProgressContainer.removeClass('hide');
+
+ const closeSpinner = () => $uploadingProgressContainer.addClass('hide');
+
+ const showError = (message) => {
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ };
+
+ const closeAlertMessage = () => form.find('.div-dropzone-alert').alert('close');
+
+ const insertToTextArea = (filename, url) => {
+ const $child = $(child);
+ $child.val((index, val) => val.replace(`{{${filename}}}`, url));
+
+ $child.trigger('change');
+ };
+
+ uploadFile = (item, filename) => {
+ const formData = new FormData();
+ formData.append('file', item, filename);
+ return $.ajax({
+ url: uploadsPath,
+ type: 'POST',
+ data: formData,
+ dataType: 'json',
+ processData: false,
+ contentType: false,
+ headers: csrf.headers,
+ beforeSend: () => {
+ showSpinner();
+ return closeAlertMessage();
+ },
+ success: (e, text, response) => {
+ const md = response.responseJSON.link.markdown;
+ insertToTextArea(filename, md);
+ },
+ error: response => showError(response.responseJSON.message),
+ complete: () => closeSpinner(),
});
- }
-
- return DropzoneInput;
-})();
+ };
+
+ updateAttachingMessage = (files, messageContainer) => {
+ let attachingMessage;
+ const filesCount = files.filter(file => file.status === 'uploading' || file.status === 'queued').length;
+
+ // Dinamycally change uploading files text depending on files number in
+ // dropzone files queue.
+ if (filesCount > 1) {
+ attachingMessage = `Attaching ${filesCount} files -`;
+ } else {
+ attachingMessage = 'Attaching a file -';
+ }
+
+ messageContainer.text(attachingMessage);
+ };
+
+ form.find('.markdown-selector').click(function onMarkdownClick(e) {
+ e.preventDefault();
+ $(this).closest('.gfm-form').find('.div-dropzone').click();
+ formTextarea.focus();
+ });
+}
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index ee71728184f..ada985913bb 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -1,8 +1,7 @@
-/* eslint-disable wrap-iife, func-names, space-before-function-paren, comma-dangle, prefer-template, consistent-return, class-methods-use-this, arrow-body-style, no-unused-vars, no-underscore-dangle, no-new, max-len, no-sequences, no-unused-expressions, no-param-reassign */
/* global dateFormat */
import Pikaday from 'pikaday';
-import DateFix from './lib/utils/datefix';
+import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
class DueDateSelect {
constructor({ $dropdown, $loading } = {}) {
@@ -17,8 +16,8 @@ class DueDateSelect {
this.$value = $block.find('.value');
this.$valueContent = $block.find('.value-content');
this.$sidebarValue = $('.js-due-date-sidebar-value', $block);
- this.fieldName = $dropdown.data('field-name'),
- this.abilityName = $dropdown.data('ability-name'),
+ this.fieldName = $dropdown.data('field-name');
+ this.abilityName = $dropdown.data('ability-name');
this.issueUpdateURL = $dropdown.data('issue-update');
this.rawSelectedDate = null;
@@ -39,20 +38,20 @@ class DueDateSelect {
hidden: () => {
this.$selectbox.hide();
this.$value.css('display', '');
- }
+ },
});
}
initDatePicker() {
const $dueDateInput = $(`input[name='${this.fieldName}']`);
- const dateFix = DateFix.dashedFix($dueDateInput.val());
const calendar = new Pikaday({
field: $dueDateInput.get(0),
theme: 'gitlab-theme',
format: 'yyyy-mm-dd',
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
onSelect: (dateText) => {
- const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd');
- $dueDateInput.val(formattedDate);
+ $dueDateInput.val(calendar.toString(dateText));
if (this.$dropdown.hasClass('js-issue-boards-due-date')) {
gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val();
@@ -60,10 +59,10 @@ class DueDateSelect {
} else {
this.saveDueDate(true);
}
- }
+ },
});
- calendar.setDate(dateFix);
+ calendar.setDate(parsePikadayDate($dueDateInput.val()));
this.$datePicker.append(calendar.el);
this.$datePicker.data('pikaday', calendar);
}
@@ -79,8 +78,8 @@ class DueDateSelect {
gl.issueBoards.BoardsStore.detail.issue.dueDate = '';
this.updateIssueBoardIssue();
} else {
- $("input[name='" + this.fieldName + "']").val('');
- return this.saveDueDate(false);
+ $(`input[name='${this.fieldName}']`).val('');
+ this.saveDueDate(false);
}
});
}
@@ -111,7 +110,7 @@ class DueDateSelect {
this.datePayload = datePayload;
}
- updateIssueBoardIssue () {
+ updateIssueBoardIssue() {
this.$loading.fadeIn();
this.$dropdown.trigger('loading.gl.dropdown');
this.$selectbox.hide();
@@ -149,8 +148,8 @@ class DueDateSelect {
return selectedDateValue.length ?
$('.js-remove-due-date-holder').removeClass('hidden') :
$('.js-remove-due-date-holder').addClass('hidden');
- }
- }).done((data) => {
+ },
+ }).done(() => {
if (isDropdown) {
this.$dropdown.trigger('loaded.gl.dropdown');
this.$dropdown.dropdown('toggle');
@@ -160,27 +159,28 @@ class DueDateSelect {
}
}
-class DueDateSelectors {
+export default class DueDateSelectors {
constructor() {
this.initMilestoneDatePicker();
this.initIssuableSelect();
}
-
+ // eslint-disable-next-line class-methods-use-this
initMilestoneDatePicker() {
- $('.datepicker').each(function() {
+ $('.datepicker').each(function initPikadayMilestone() {
const $datePicker = $(this);
- const dateFix = DateFix.dashedFix($datePicker.val());
const calendar = new Pikaday({
field: $datePicker.get(0),
theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd',
container: $datePicker.parent().get(0),
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
onSelect(dateText) {
- $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
- }
+ $datePicker.val(calendar.toString(dateText));
+ },
});
- calendar.setDate(dateFix);
+ calendar.setDate(parsePikadayDate($datePicker.val()));
$datePicker.data('pikaday', calendar);
});
@@ -191,19 +191,17 @@ class DueDateSelectors {
calendar.setDate(null);
});
}
-
+ // eslint-disable-next-line class-methods-use-this
initIssuableSelect() {
const $loading = $('.js-issuable-update .due_date').find('.block-loading').hide();
$('.js-due-date-select').each((i, dropdown) => {
const $dropdown = $(dropdown);
+ // eslint-disable-next-line no-new
new DueDateSelect({
$dropdown,
- $loading
+ $loading,
});
});
}
}
-
-window.gl = window.gl || {};
-window.gl.DueDateSelectors = DueDateSelectors;
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 6d516a253bb..9e91f72b2ea 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -6,10 +6,11 @@ import _ from 'underscore';
*/
export default class FilterableList {
- constructor(form, filter, holder) {
+ constructor(form, filter, holder, filterInputField = 'filter_groups') {
this.filterForm = form;
this.listFilterElement = filter;
this.listHolderElement = holder;
+ this.filterInputField = filterInputField;
this.isBusy = false;
}
@@ -32,10 +33,10 @@ export default class FilterableList {
onFilterInput() {
const $form = $(this.filterForm);
const queryData = {};
- const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+ const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
- queryData.filter_groups = filterGroupsParam;
+ queryData[this.filterInputField] = filterGroupsParam;
}
this.filterResults(queryData);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index dd24fc44d2a..d2f92929b8a 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -123,8 +123,8 @@ class FilteredSearchVisualTokens {
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
- <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar">
- ${user.name}
+ <img class="avatar s20" src="${user.avatar_url}" alt="">
+ ${_.escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index bc5cd818e1c..67261c1c9b4 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -40,6 +40,10 @@ const createFlashEl = (message, type, isInContentWrapper = false) => `
</div>
`;
+const removeFlashClickListener = (flashEl, fadeTransition) => {
+ flashEl.parentNode.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
+};
+
/*
* Flash banner supports different types of Flash configurations
* along with ability to provide actionConfig which can be used to show
@@ -70,7 +74,7 @@ const createFlash = function createFlash(
flashContainer.innerHTML = createFlashEl(message, type, isInContentWrapper);
const flashEl = flashContainer.querySelector(`.flash-${type}`);
- flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
+ removeFlashClickListener(flashEl, fadeTransition);
if (actionConfig) {
flashEl.innerHTML += createAction(actionConfig);
@@ -90,5 +94,6 @@ export {
createFlashEl,
createAction,
hideFlash,
+ removeFlashClickListener,
};
window.Flash = createFlash;
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 0add7075254..bd63f6f16f0 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -54,7 +54,7 @@ const inputErrorClass = 'gl-field-error-outline';
const errorAnchorSelector = '.gl-field-error-anchor';
const ignoreInputSelector = '.gl-field-error-ignore';
-class GlFieldError {
+export default class GlFieldError {
constructor({ input, formErrors }) {
this.inputElement = $(input);
this.inputDomElement = this.inputElement.get(0);
@@ -159,6 +159,3 @@ class GlFieldError {
this.fieldErrorElement.hide();
}
}
-
-window.gl = window.gl || {};
-window.gl.GlFieldError = GlFieldError;
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index 4bef60264bb..73bcbd93565 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -1,42 +1,40 @@
-/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */
-
-import './gl_field_error';
+import GlFieldError from './gl_field_error';
const customValidationFlag = 'gl-field-error-ignore';
-class GlFieldErrors {
+export default class GlFieldErrors {
constructor(form) {
this.form = $(form);
this.state = {
inputs: [],
- valid: false
+ valid: false,
};
this.initValidators();
}
- initValidators () {
+ initValidators() {
// register selectors here as needed
const validateSelectors = [':text', ':password', '[type=email]']
- .map((selector) => `input${selector}`).join(',');
+ .map(selector => `input${selector}`).join(',');
this.state.inputs = this.form.find(validateSelectors).toArray()
- .filter((input) => !input.classList.contains(customValidationFlag))
- .map((input) => new window.gl.GlFieldError({ input, formErrors: this }));
+ .filter(input => !input.classList.contains(customValidationFlag))
+ .map(input => new GlFieldError({ input, formErrors: this }));
- this.form.on('submit', this.catchInvalidFormSubmit);
+ this.form.on('submit', GlFieldErrors.catchInvalidFormSubmit);
}
/* Neccessary to prevent intercept and override invalid form submit
* because Safari & iOS quietly allow form submission when form is invalid
* and prevents disabling of invalid submit button by application.js */
- catchInvalidFormSubmit (event) {
- const $form = $(event.currentTarget);
+ static catchInvalidFormSubmit(e) {
+ const $form = $(e.currentTarget);
if (!$form.attr('novalidate')) {
- if (!event.currentTarget.checkValidity()) {
- event.preventDefault();
- event.stopPropagation();
+ if (!e.currentTarget.checkValidity()) {
+ e.preventDefault();
+ e.stopPropagation();
}
}
}
@@ -50,11 +48,9 @@ class GlFieldErrors {
});
}
- focusOnFirstInvalid () {
- const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0];
+ focusOnFirstInvalid() {
+ const firstInvalid = this.state.inputs
+ .filter(input => !input.inputDomElement.validity.valid)[0];
firstInvalid.inputElement.focus();
}
}
-
-window.gl = window.gl || {};
-window.gl.GlFieldErrors = GlFieldErrors;
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 4e8141b2956..48cd43d3348 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -1,104 +1,99 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-new, max-len */
-/* global GitLab */
-/* global DropzoneInput */
/* global autosize */
import GfmAutoComplete from './gfm_auto_complete';
-
-window.gl = window.gl || {};
-
-function GLForm(form, enableGFM = false) {
- this.form = form;
- this.textarea = this.form.find('textarea.js-gfm-input');
- this.enableGFM = enableGFM;
- // Before we start, we should clean up any previous data for this form
- this.destroy();
- // Setup the form
- this.setupForm();
- this.form.data('gl-form', this);
-}
-
-GLForm.prototype.destroy = function() {
- // Clean form listeners
- this.clearEventListeners();
- if (this.autoComplete) {
- this.autoComplete.destroy();
+import dropzoneInput from './dropzone_input';
+
+export default class GLForm {
+ constructor(form, enableGFM = false) {
+ this.form = form;
+ this.textarea = this.form.find('textarea.js-gfm-input');
+ this.enableGFM = enableGFM;
+ // Before we start, we should clean up any previous data for this form
+ this.destroy();
+ // Setup the form
+ this.setupForm();
+ this.form.data('gl-form', this);
}
- return this.form.data('gl-form', null);
-};
-GLForm.prototype.setupForm = function() {
- var isNewForm;
- isNewForm = this.form.is(':not(.gfm-form)');
- this.form.removeClass('js-new-note-form');
- if (isNewForm) {
- this.form.find('.div-dropzone').remove();
- this.form.addClass('gfm-form');
- // remove notify commit author checkbox for non-commit notes
- gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
- this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
- this.autoComplete.setup(this.form.find('.js-gfm-input'), {
- emojis: true,
- members: this.enableGFM,
- issues: this.enableGFM,
- milestones: this.enableGFM,
- mergeRequests: this.enableGFM,
- labels: this.enableGFM,
- });
- new DropzoneInput(this.form);
- autosize(this.textarea);
+ destroy() {
+ // Clean form listeners
+ this.clearEventListeners();
+ if (this.autoComplete) {
+ this.autoComplete.destroy();
+ }
+ this.form.data('gl-form', null);
}
- // form and textarea event listeners
- this.addEventListeners();
- gl.text.init(this.form);
- // hide discard button
- this.form.find('.js-note-discard').hide();
- this.form.show();
- if (this.isAutosizeable) this.setupAutosize();
-};
-GLForm.prototype.setupAutosize = function () {
- this.textarea.off('autosize:resized')
- .on('autosize:resized', this.setHeightData.bind(this));
+ setupForm() {
+ const isNewForm = this.form.is(':not(.gfm-form)');
+ this.form.removeClass('js-new-note-form');
+ if (isNewForm) {
+ this.form.find('.div-dropzone').remove();
+ this.form.addClass('gfm-form');
+ // remove notify commit author checkbox for non-commit notes
+ gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
+ this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
+ this.autoComplete.setup(this.form.find('.js-gfm-input'), {
+ emojis: true,
+ members: this.enableGFM,
+ issues: this.enableGFM,
+ milestones: this.enableGFM,
+ mergeRequests: this.enableGFM,
+ labels: this.enableGFM,
+ });
+ dropzoneInput(this.form);
+ autosize(this.textarea);
+ }
+ // form and textarea event listeners
+ this.addEventListeners();
+ gl.text.init(this.form);
+ // hide discard button
+ this.form.find('.js-note-discard').hide();
+ this.form.show();
+ if (this.isAutosizeable) this.setupAutosize();
+ }
- this.textarea.off('mouseup.autosize')
- .on('mouseup.autosize', this.destroyAutosize.bind(this));
+ setupAutosize() {
+ this.textarea.off('autosize:resized')
+ .on('autosize:resized', this.setHeightData.bind(this));
- setTimeout(() => {
- autosize(this.textarea);
- this.textarea.css('resize', 'vertical');
- }, 0);
-};
+ this.textarea.off('mouseup.autosize')
+ .on('mouseup.autosize', this.destroyAutosize.bind(this));
-GLForm.prototype.setHeightData = function () {
- this.textarea.data('height', this.textarea.outerHeight());
-};
+ setTimeout(() => {
+ autosize(this.textarea);
+ this.textarea.css('resize', 'vertical');
+ }, 0);
+ }
-GLForm.prototype.destroyAutosize = function () {
- const outerHeight = this.textarea.outerHeight();
+ setHeightData() {
+ this.textarea.data('height', this.textarea.outerHeight());
+ }
- if (this.textarea.data('height') === outerHeight) return;
+ destroyAutosize() {
+ const outerHeight = this.textarea.outerHeight();
- autosize.destroy(this.textarea);
+ if (this.textarea.data('height') === outerHeight) return;
- this.textarea.data('height', outerHeight);
- this.textarea.outerHeight(outerHeight);
- this.textarea.css('max-height', window.outerHeight);
-};
+ autosize.destroy(this.textarea);
-GLForm.prototype.clearEventListeners = function() {
- this.textarea.off('focus');
- this.textarea.off('blur');
- return gl.text.removeListeners(this.form);
-};
+ this.textarea.data('height', outerHeight);
+ this.textarea.outerHeight(outerHeight);
+ this.textarea.css('max-height', window.outerHeight);
+ }
-GLForm.prototype.addEventListeners = function() {
- this.textarea.on('focus', function() {
- return $(this).closest('.md-area').addClass('is-focused');
- });
- return this.textarea.on('blur', function() {
- return $(this).closest('.md-area').removeClass('is-focused');
- });
-};
+ clearEventListeners() {
+ this.textarea.off('focus');
+ this.textarea.off('blur');
+ gl.text.removeListeners(this.form);
+ }
-window.gl.GLForm = GLForm;
+ addEventListeners() {
+ this.textarea.on('focus', function focusTextArea() {
+ $(this).closest('.md-area').addClass('is-focused');
+ });
+ this.textarea.on('blur', function blurTextArea() {
+ $(this).closest('.md-area').removeClass('is-focused');
+ });
+ }
+}
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
new file mode 100644
index 00000000000..2c0b6ab4ea8
--- /dev/null
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -0,0 +1,194 @@
+<script>
+/* global Flash */
+
+import eventHub from '../event_hub';
+import { getParameterByName } from '../../lib/utils/common_utils';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import { COMMON_STR } from '../constants';
+
+import groupsComponent from './groups.vue';
+
+export default {
+ components: {
+ loadingIcon,
+ groupsComponent,
+ },
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ hideProjects: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: true,
+ isSearchEmpty: false,
+ searchEmptyMessage: '',
+ };
+ },
+ computed: {
+ groups() {
+ return this.store.getGroups();
+ },
+ pageInfo() {
+ return this.store.getPaginationInfo();
+ },
+ },
+ methods: {
+ fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
+ return this.service.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
+ .then((res) => {
+ if (updatePagination) {
+ this.updatePagination(res.headers);
+ }
+
+ return res;
+ })
+ .then(res => res.json())
+ .catch(() => {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ Flash(COMMON_STR.FAILURE);
+ });
+ },
+ fetchAllGroups() {
+ const page = getParameterByName('page') || null;
+ const sortBy = getParameterByName('sort') || null;
+ const archived = getParameterByName('archived') || null;
+ const filterGroupsBy = getParameterByName('filter') || null;
+
+ this.isLoading = true;
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination: true,
+ }).then((res) => {
+ this.isLoading = false;
+ this.updateGroups(res, Boolean(filterGroupsBy));
+ });
+ },
+ fetchPage(page, filterGroupsBy, sortBy, archived) {
+ this.isLoading = true;
+
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination: true,
+ }).then((res) => {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
+ window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
+
+ this.updateGroups(res);
+ });
+ },
+ toggleChildren(group) {
+ const parentGroup = group;
+ if (!parentGroup.isOpen) {
+ if (parentGroup.children.length === 0) {
+ parentGroup.isChildrenLoading = true;
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ parentId: parentGroup.id,
+ }).then((res) => {
+ this.store.setGroupChildren(parentGroup, res);
+ }).catch(() => {
+ parentGroup.isChildrenLoading = false;
+ });
+ } else {
+ parentGroup.isOpen = true;
+ }
+ } else {
+ parentGroup.isOpen = false;
+ }
+ },
+ leaveGroup(group, parentGroup) {
+ const targetGroup = group;
+ targetGroup.isBeingRemoved = true;
+ this.service.leaveGroup(targetGroup.leavePath)
+ .then(res => res.json())
+ .then((res) => {
+ $.scrollTo(0);
+ this.store.removeGroup(targetGroup, parentGroup);
+ Flash(res.notice, 'notice');
+ })
+ .catch((err) => {
+ let message = COMMON_STR.FAILURE;
+ if (err.status === 403) {
+ message = COMMON_STR.LEAVE_FORBIDDEN;
+ }
+ Flash(message);
+ targetGroup.isBeingRemoved = false;
+ });
+ },
+ updatePagination(headers) {
+ this.store.setPaginationInfo(headers);
+ },
+ updateGroups(groups, fromSearch) {
+ this.isSearchEmpty = groups ? groups.length === 0 : false;
+ if (fromSearch) {
+ this.store.setSearchedGroups(groups);
+ } else {
+ this.store.setGroups(groups);
+ }
+ },
+ },
+ created() {
+ this.searchEmptyMessage = this.hideProjects ?
+ COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
+
+ eventHub.$on('fetchPage', this.fetchPage);
+ eventHub.$on('toggleChildren', this.toggleChildren);
+ eventHub.$on('leaveGroup', this.leaveGroup);
+ eventHub.$on('updatePagination', this.updatePagination);
+ eventHub.$on('updateGroups', this.updateGroups);
+ },
+ mounted() {
+ this.fetchAllGroups();
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchPage', this.fetchPage);
+ eventHub.$off('toggleChildren', this.toggleChildren);
+ eventHub.$off('leaveGroup', this.leaveGroup);
+ eventHub.$off('updatePagination', this.updatePagination);
+ eventHub.$off('updateGroups', this.updateGroups);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <loading-icon
+ class="loading-animation prepend-top-20"
+ size="2"
+ v-if="isLoading"
+ :label="s__('GroupsTree|Loading groups')"
+ />
+ <groups-component
+ v-if="!isLoading"
+ :groups="groups"
+ :search-empty="isSearchEmpty"
+ :search-empty-message="searchEmptyMessage"
+ :page-info="pageInfo"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 7cc6c4b0359..e60221fa08d 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -1,15 +1,27 @@
<script>
+import { n__ } from '../../locale';
+import { MAX_CHILDREN_COUNT } from '../constants';
+
export default {
props: {
- groups: {
- type: Object,
- required: true,
- },
- baseGroup: {
+ parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
+ groups: {
+ type: Array,
+ required: false,
+ default: () => ([]),
+ },
+ },
+ computed: {
+ hasMoreChildren() {
+ return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
+ },
+ moreChildrenStats() {
+ return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length);
+ },
},
};
</script>
@@ -20,8 +32,20 @@ export default {
v-for="(group, index) in groups"
:key="index"
:group="group"
- :base-group="baseGroup"
- :collection="groups"
+ :parent-group="parentGroup"
/>
+ <li
+ v-if="hasMoreChildren"
+ class="group-row">
+ <a
+ :href="parentGroup.relativePath"
+ class="group-row-contents has-more-items">
+ <i
+ class="fa fa-external-link"
+ aria-hidden="true"
+ />
+ {{moreChildrenStats}}
+ </a>
+ </li>
</ul>
</template>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2060410e991..356a95c05ca 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -2,49 +2,28 @@
import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub';
+import itemCaret from './item_caret.vue';
+import itemTypeIcon from './item_type_icon.vue';
+import itemStats from './item_stats.vue';
+import itemActions from './item_actions.vue';
+
export default {
components: {
identicon,
+ itemCaret,
+ itemTypeIcon,
+ itemStats,
+ itemActions,
},
props: {
- group: {
- type: Object,
- required: true,
- },
- baseGroup: {
+ parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
- collection: {
+ group: {
type: Object,
- required: false,
- default: () => ({}),
- },
- },
- methods: {
- onClickRowGroup(e) {
- e.stopPropagation();
-
- // Skip for buttons
- if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
- if (this.group.hasSubgroups) {
- eventHub.$emit('toggleSubGroups', this.group);
- } else {
- window.location.href = this.group.groupPath;
- }
- }
- },
- onLeaveGroup(e) {
- e.preventDefault();
-
- // eslint-disable-next-line no-alert
- if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
- this.leaveGroup();
- }
- },
- leaveGroup() {
- eventHub.$emit('leaveGroup', this.group, this.collection);
+ required: true,
},
},
computed: {
@@ -53,51 +32,33 @@ export default {
},
rowClass() {
return {
- 'group-row': true,
'is-open': this.group.isOpen,
- 'has-subgroups': this.group.hasSubgroups,
- 'no-description': !this.group.description,
+ 'has-children': this.hasChildren,
+ 'has-description': this.group.description,
+ 'being-removed': this.group.isBeingRemoved,
};
},
- visibilityIcon() {
- return {
- fa: true,
- 'fa-globe': this.group.visibility === 'public',
- 'fa-shield': this.group.visibility === 'internal',
- 'fa-lock': this.group.visibility === 'private',
- };
+ hasChildren() {
+ return this.group.childrenCount > 0;
},
- fullPath() {
- let fullPath = '';
-
- if (this.group.isOrphan) {
- // check if current group is baseGroup
- if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
- // Remove baseGroup prefix from our current group.fullName. e.g:
- // baseGroup.fullName: `level1`
- // group.fullName: `level1 / level2 / level3`
- // Result: `level2 / level3`
- const gfn = this.group.fullName;
- const bfn = this.baseGroup.fullName;
- const length = bfn.length;
- const start = gfn.indexOf(bfn);
- const extraPrefixChars = 3;
-
- fullPath = gfn.substr(start + length + extraPrefixChars);
+ hasAvatar() {
+ return this.group.avatarUrl !== null;
+ },
+ isGroup() {
+ return this.group.type === 'group';
+ },
+ },
+ methods: {
+ onClickRowGroup(e) {
+ const NO_EXPAND_CLS = 'no-expand';
+ if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
+ e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
+ if (this.hasChildren) {
+ eventHub.$emit('toggleChildren', this.group);
} else {
- fullPath = this.group.fullName;
+ gl.utils.visitUrl(this.group.relativePath);
}
- } else {
- fullPath = this.group.name;
}
-
- return fullPath;
- },
- hasGroups() {
- return Object.keys(this.group.subGroups).length > 0;
- },
- hasAvatar() {
- return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
},
},
};
@@ -108,98 +69,36 @@ export default {
@click.stop="onClickRowGroup"
:id="groupDomId"
:class="rowClass"
+ class="group-row"
>
<div
class="group-row-contents">
- <div
- class="controls">
- <a
- v-if="group.canEdit"
- class="edit-group btn"
- :href="group.editPath">
- <i
- class="fa fa-cogs"
- aria-hidden="true"
- >
- </i>
- </a>
- <a
- @click="onLeaveGroup"
- :href="group.leavePath"
- class="leave-group btn"
- title="Leave this group">
- <i
- class="fa fa-sign-out"
- aria-hidden="true"
- >
- </i>
- </a>
- </div>
- <div
- class="stats">
- <span
- class="number-projects">
- <i
- class="fa fa-bookmark"
- aria-hidden="true"
- >
- </i>
- {{group.numberProjects}}
- </span>
- <span
- class="number-users">
- <i
- class="fa fa-users"
- aria-hidden="true"
- >
- </i>
- {{group.numberUsers}}
- </span>
- <span
- class="group-visibility">
- <i
- :class="visibilityIcon"
- aria-hidden="true"
- >
- </i>
- </span>
- </div>
+ <item-actions
+ v-if="isGroup"
+ :group="group"
+ :parent-group="parentGroup"
+ />
+ <item-stats
+ :item="group"
+ />
<div
class="folder-toggle-wrap">
- <span
- class="folder-caret"
- v-if="group.hasSubgroups">
- <i
- v-if="group.isOpen"
- class="fa fa-caret-down"
- aria-hidden="true"
- >
- </i>
- <i
- v-if="!group.isOpen"
- class="fa fa-caret-right"
- aria-hidden="true"
- >
- </i>
- </span>
- <span class="folder-icon">
- <i
- v-if="group.isOpen"
- class="fa fa-folder-open"
- aria-hidden="true"
- >
- </i>
- <i
- v-if="!group.isOpen"
- class="fa fa-folder"
- aria-hidden="true">
- </i>
- </span>
+ <item-caret
+ :is-group-open="group.isOpen"
+ />
+ <item-type-icon
+ :item-type="group.type"
+ :is-group-open="group.isOpen"
+ />
</div>
<div
- class="avatar-container s40 hidden-xs">
+ class="avatar-container s40 hidden-xs"
+ :class="{ 'content-loading': group.isChildrenLoading }"
+ >
<a
- :href="group.groupPath">
+ :href="group.relativePath"
+ class="no-expand"
+ >
<img
v-if="hasAvatar"
class="avatar s40"
@@ -215,19 +114,22 @@ export default {
<div
class="title">
<a
- :href="group.groupPath">{{fullPath}}</a>
- <template v-if="group.permissions.humanGroupAccess">
- as
- <span class="access-type">{{group.permissions.humanGroupAccess}}</span>
- </template>
+ :href="group.relativePath"
+ class="no-expand">{{group.fullName}}</a>
+ <span
+ v-if="group.permission"
+ class="access-type"
+ >
+ {{s__('GroupsTreeRole|as')}} {{group.permission}}
+ </span>
</div>
<div
class="description">{{group.description}}</div>
</div>
<group-folder
- v-if="group.isOpen && hasGroups"
- :groups="group.subGroups"
- :baseGroup="group"
+ v-if="group.isOpen && hasChildren"
+ :parent-group="group"
+ :groups="group.children"
/>
</li>
</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index d17a43b048a..75a2bf34887 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -4,24 +4,33 @@ import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
export default {
+ components: {
+ tablePagination,
+ },
props: {
groups: {
- type: Object,
+ type: Array,
required: true,
},
pageInfo: {
type: Object,
required: true,
},
- },
- components: {
- tablePagination,
+ searchEmpty: {
+ type: Boolean,
+ required: true,
+ },
+ searchEmptyMessage: {
+ type: String,
+ required: true,
+ },
},
methods: {
change(page) {
const filterGroupsParam = getParameterByName('filter_groups');
const sortParam = getParameterByName('sort');
- eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
+ const archivedParam = getParameterByName('archived');
+ eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
},
},
};
@@ -29,10 +38,17 @@ export default {
<template>
<div class="groups-list-tree-container">
+ <div
+ v-if="searchEmpty"
+ class="has-no-search-results">
+ {{searchEmptyMessage}}
+ </div>
<group-folder
+ v-if="!searchEmpty"
:groups="groups"
/>
<table-pagination
+ v-if="!searchEmpty"
:change="change"
:pageInfo="pageInfo"
/>
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
new file mode 100644
index 00000000000..7eff19e2e5a
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -0,0 +1,93 @@
+<script>
+import { s__ } from '../../locale';
+import tooltip from '../../vue_shared/directives/tooltip';
+import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
+import eventHub from '../event_hub';
+import { COMMON_STR } from '../constants';
+
+export default {
+ components: {
+ PopupDialog,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ parentGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ group: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dialogStatus: false,
+ };
+ },
+ computed: {
+ leaveBtnTitle() {
+ return COMMON_STR.LEAVE_BTN_TITLE;
+ },
+ editBtnTitle() {
+ return COMMON_STR.EDIT_BTN_TITLE;
+ },
+ leaveConfirmationMessage() {
+ return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`);
+ },
+ },
+ methods: {
+ onLeaveGroup() {
+ this.dialogStatus = true;
+ },
+ leaveGroup(leaveConfirmed) {
+ this.dialogStatus = false;
+ if (leaveConfirmed) {
+ eventHub.$emit('leaveGroup', this.group, this.parentGroup);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="controls">
+ <a
+ v-tooltip
+ v-if="group.canEdit"
+ :href="group.editPath"
+ :title="editBtnTitle"
+ :aria-label="editBtnTitle"
+ data-container="body"
+ class="edit-group btn no-expand">
+ <i
+ class="fa fa-cogs"
+ aria-hidden="true"/>
+ </a>
+ <a
+ v-tooltip
+ v-if="group.canLeave"
+ @click.prevent="onLeaveGroup"
+ :href="group.leavePath"
+ :title="leaveBtnTitle"
+ :aria-label="leaveBtnTitle"
+ data-container="body"
+ class="leave-group btn no-expand">
+ <i
+ class="fa fa-sign-out"
+ aria-hidden="true"/>
+ </a>
+ <popup-dialog
+ v-show="dialogStatus"
+ :primary-button-label="__('Leave')"
+ kind="warning"
+ :title="__('Are you sure?')"
+ :text="__('Are you sure you want to leave this group?')"
+ :body="leaveConfirmationMessage"
+ @submit="leaveGroup"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
new file mode 100644
index 00000000000..959b984816f
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ props: {
+ isGroupOpen: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="folder-caret">
+ <i
+ :class="iconClass"
+ class="fa"
+ aria-hidden="true"/>
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
new file mode 100644
index 00000000000..9f8ac138fc3
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -0,0 +1,98 @@
+<script>
+import tooltip from '../../vue_shared/directives/tooltip';
+import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.item.visibility];
+ },
+ visibilityTooltip() {
+ if (this.item.type === ITEM_TYPE.GROUP) {
+ return GROUP_VISIBILITY_TYPE[this.item.visibility];
+ }
+ return PROJECT_VISIBILITY_TYPE[this.item.visibility];
+ },
+ isProject() {
+ return this.item.type === ITEM_TYPE.PROJECT;
+ },
+ isGroup() {
+ return this.item.type === ITEM_TYPE.GROUP;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="stats">
+ <span
+ v-tooltip
+ v-if="isGroup"
+ :title="s__('Subgroups')"
+ class="number-subgroups"
+ data-placement="top"
+ data-container="body">
+ <i
+ class="fa fa-folder"
+ aria-hidden="true"
+ />
+ {{item.subgroupCount}}
+ </span>
+ <span
+ v-tooltip
+ v-if="isGroup"
+ :title="s__('Projects')"
+ class="number-projects"
+ data-placement="top"
+ data-container="body">
+ <i
+ class="fa fa-bookmark"
+ aria-hidden="true"
+ />
+ {{item.projectCount}}
+ </span>
+ <span
+ v-tooltip
+ v-if="isGroup"
+ :title="s__('Members')"
+ class="number-users"
+ data-placement="top"
+ data-container="body">
+ <i
+ class="fa fa-users"
+ aria-hidden="true"
+ />
+ {{item.memberCount}}
+ </span>
+ <span
+ v-if="isProject"
+ class="project-stars">
+ <i
+ class="fa fa-star"
+ aria-hidden="true"
+ />
+ {{item.starCount}}
+ </span>
+ <span
+ v-tooltip
+ :title="visibilityTooltip"
+ data-placement="left"
+ data-container="body"
+ class="item-visibility">
+ <i
+ :class="visibilityIcon"
+ class="fa"
+ aria-hidden="true"
+ />
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
new file mode 100644
index 00000000000..c02a8ad6d8c
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -0,0 +1,34 @@
+<script>
+import { ITEM_TYPE } from '../constants';
+
+export default {
+ props: {
+ itemType: {
+ type: String,
+ required: true,
+ },
+ isGroupOpen: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ if (this.itemType === ITEM_TYPE.GROUP) {
+ return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder';
+ }
+ return 'fa-bookmark';
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="item-type-icon">
+ <i
+ :class="iconClass"
+ class="fa"
+ aria-hidden="true"/>
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
new file mode 100644
index 00000000000..6fde41414b3
--- /dev/null
+++ b/app/assets/javascripts/groups/constants.js
@@ -0,0 +1,35 @@
+import { __, s__ } from '../locale';
+
+export const MAX_CHILDREN_COUNT = 20;
+
+export const COMMON_STR = {
+ FAILURE: __('An error occurred. Please try again.'),
+ LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
+ LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
+ EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
+ GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
+ GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
+};
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
+};
+
+export const GROUP_VISIBILITY_TYPE = {
+ public: __('Public - The group and any public projects can be viewed without any authentication.'),
+ internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'),
+ private: __('Private - The group and its projects can only be viewed by members.'),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ public: __('Public - The project can be accessed without any authentication.'),
+ internal: __('Internal - The project can be accessed by any logged in user.'),
+ private: __('Private - Project access must be granted explicitly to each user.'),
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ public: 'fa-globe',
+ internal: 'fa-shield',
+ private: 'fa-lock',
+};
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
index 83b102764ba..2db233b09da 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -3,12 +3,13 @@ import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList {
- constructor({ form, filter, holder, filterEndpoint, pagePath }) {
- super(form, filter, holder);
+ constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
+ super(form, filter, holder, filterInputField);
this.form = form;
this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath;
- this.$dropdown = $('.js-group-filter-dropdown-wrap');
+ this.filterInputField = filterInputField;
+ this.$dropdown = $(dropdownSel);
}
getFilterEndpoint() {
@@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList {
bindEvents() {
super.bindEvents();
- this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
- this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
}
- onFormSubmit(e) {
- e.preventDefault();
-
- const $form = $(this.form);
- const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+ onFilterInput() {
const queryData = {};
+ const $form = $(this.form);
+ const archivedParam = getParameterByName('archived', window.location.href);
+ const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
- queryData.filter_groups = filterGroupsParam;
+ queryData[this.filterInputField] = filterGroupsParam;
+ }
+
+ if (archivedParam) {
+ queryData.archived = archivedParam;
}
this.filterResults(queryData);
- this.setDefaultFilterOption();
+
+ if (this.setDefaultFilterOption) {
+ this.setDefaultFilterOption();
+ }
}
setDefaultFilterOption() {
- const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
+ const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
this.$dropdown.find('.dropdown-label').text(defaultOption);
}
@@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault();
const queryData = {};
- const sortParam = getParameterByName('sort', e.currentTarget.href);
+
+ // Get type of option selected from dropdown
+ const currentTargetClassList = e.currentTarget.parentElement.classList;
+ const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
+ const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
+
+ // Get option query param, also preserve currently applied query param
+ const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
+ const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
if (sortParam) {
queryData.sort = sortParam;
}
+ if (archivedParam) {
+ queryData.archived = archivedParam;
+ }
+
this.filterResults(queryData);
// Active selected option
- this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+ if (isOptionFilterBySort) {
+ this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+ this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
+ } else if (isOptionFilterByArchivedProjects) {
+ this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active');
+ }
+
+ $(e.target).addClass('is-active');
// Clear current value on search form
- this.form.querySelector('[name="filter_groups"]').value = '';
+ this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
}
onFilterSuccess(data, xhr, queryData) {
- super.onFilterSuccess(data, xhr, queryData);
+ const currentPath = this.getPagePath(queryData);
const paginationData = {
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
@@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList {
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
};
- eventHub.$emit('updateGroups', data);
+ window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
+
+ eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
eventHub.$emit('updatePagination', paginationData);
}
}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index 600bae24b52..8b850765a1b 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -1,16 +1,17 @@
import Vue from 'vue';
-import Flash from '../flash';
+import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list';
-import GroupsComponent from './components/groups.vue';
-import GroupFolder from './components/group_folder.vue';
-import GroupItem from './components/group_item.vue';
-import GroupsStore from './stores/groups_store';
-import GroupsService from './services/groups_service';
-import eventHub from './event_hub';
-import { getParameterByName } from '../lib/utils/common_utils';
+import GroupsStore from './store/groups_store';
+import GroupsService from './service/groups_service';
+
+import groupsApp from './components/app.vue';
+import groupFolderComponent from './components/group_folder.vue';
+import groupItemComponent from './components/group_item.vue';
+
+Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('dashboard-group-app');
+ const el = document.getElementById('js-groups-tree');
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
@@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
- Vue.component('groups-component', GroupsComponent);
- Vue.component('group-folder', GroupFolder);
- Vue.component('group-item', GroupItem);
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
// eslint-disable-next-line no-new
new Vue({
el,
+ components: {
+ groupsApp,
+ },
data() {
- this.store = new GroupsStore();
- this.service = new GroupsService(el.dataset.endpoint);
+ const dataset = this.$options.el.dataset;
+ const hideProjects = dataset.hideProjects === 'true';
+ const store = new GroupsStore(hideProjects);
+ const service = new GroupsService(dataset.endpoint);
return {
- store: this.store,
- isLoading: true,
- state: this.store.state,
+ store,
+ service,
+ hideProjects,
loading: true,
};
},
- computed: {
- isEmpty() {
- return Object.keys(this.state.groups).length === 0;
- },
- },
- methods: {
- fetchGroups(parentGroup) {
- let parentId = null;
- let getGroups = null;
- let page = null;
- let sort = null;
- let pageParam = null;
- let sortParam = null;
- let filterGroups = null;
- let filterGroupsParam = null;
-
- if (parentGroup) {
- parentId = parentGroup.id;
- } else {
- this.isLoading = true;
- }
-
- pageParam = getParameterByName('page');
- if (pageParam) {
- page = pageParam;
- }
-
- filterGroupsParam = getParameterByName('filter_groups');
- if (filterGroupsParam) {
- filterGroups = filterGroupsParam;
- }
-
- sortParam = getParameterByName('sort');
- if (sortParam) {
- sort = sortParam;
- }
-
- getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
- getGroups
- .then(response => response.json())
- .then((response) => {
- this.isLoading = false;
-
- this.updateGroups(response, parentGroup);
- })
- .catch(this.handleErrorResponse);
-
- return getGroups;
- },
- fetchPage(page, filterGroups, sort) {
- this.isLoading = true;
-
- return this.service
- .getGroups(null, page, filterGroups, sort)
- .then((response) => {
- this.isLoading = false;
- $.scrollTo(0);
-
- const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
- window.history.replaceState({
- page: currentPath,
- }, document.title, currentPath);
-
- return response.json().then((data) => {
- this.updateGroups(data);
- this.updatePagination(response.headers);
- });
- })
- .catch(this.handleErrorResponse);
- },
- toggleSubGroups(parentGroup = null) {
- if (!parentGroup.isOpen) {
- this.store.resetGroups(parentGroup);
- this.fetchGroups(parentGroup);
- }
-
- this.store.toggleSubGroups(parentGroup);
- },
- leaveGroup(group, collection) {
- this.service.leaveGroup(group.leavePath)
- .then(resp => resp.json())
- .then((response) => {
- $.scrollTo(0);
-
- this.store.removeGroup(group, collection);
-
- // eslint-disable-next-line no-new
- new Flash(response.notice, 'notice');
- })
- .catch((error) => {
- let message = 'An error occurred. Please try again.';
-
- if (error.status === 403) {
- message = 'Failed to leave the group. Please make sure you are not the only owner';
- }
-
- // eslint-disable-next-line no-new
- new Flash(message);
- });
- },
- updateGroups(groups, parentGroup) {
- this.store.setGroups(groups, parentGroup);
- },
- updatePagination(headers) {
- this.store.storePagination(headers);
- },
- handleErrorResponse() {
- this.isLoading = false;
- $.scrollTo(0);
-
- // eslint-disable-next-line no-new
- new Flash('An error occurred. Please try again.');
- },
- },
- created() {
- eventHub.$on('fetchPage', this.fetchPage);
- eventHub.$on('toggleSubGroups', this.toggleSubGroups);
- eventHub.$on('leaveGroup', this.leaveGroup);
- eventHub.$on('updateGroups', this.updateGroups);
- eventHub.$on('updatePagination', this.updatePagination);
- },
beforeMount() {
+ const dataset = this.$options.el.dataset;
let groupFilterList = null;
- const form = document.querySelector('form#group-filter-form');
- const filter = document.querySelector('.js-groups-list-filter');
- const holder = document.querySelector('.js-groups-list-holder');
+ const form = document.querySelector(dataset.formSel);
+ const filter = document.querySelector(dataset.filterSel);
+ const holder = document.querySelector(dataset.holderSel);
const opts = {
form,
filter,
holder,
- filterEndpoint: el.dataset.endpoint,
- pagePath: el.dataset.path,
+ filterEndpoint: dataset.endpoint,
+ pagePath: dataset.path,
+ dropdownSel: dataset.dropdownSel,
+ filterInputField: 'filter',
};
groupFilterList = new GroupFilterableList(opts);
groupFilterList.initSearch();
},
- mounted() {
- this.fetchGroups()
- .then((response) => {
- this.updatePagination(response.headers);
- this.isLoading = false;
- })
- .catch(this.handleErrorResponse);
- },
- beforeDestroy() {
- eventHub.$off('fetchPage', this.fetchPage);
- eventHub.$off('toggleSubGroups', this.toggleSubGroups);
- eventHub.$off('leaveGroup', this.leaveGroup);
- eventHub.$off('updateGroups', this.updateGroups);
- eventHub.$off('updatePagination', this.updatePagination);
+ render(createElement) {
+ return createElement('groups-app', {
+ props: {
+ store: this.store,
+ service: this.service,
+ hideProjects: this.hideProjects,
+ },
+ });
},
});
});
diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js
new file mode 100644
index 00000000000..8e273579aae
--- /dev/null
+++ b/app/assets/javascripts/groups/new_group_child.js
@@ -0,0 +1,62 @@
+import DropLab from '../droplab/drop_lab';
+import ISetter from '../droplab/plugins/input_setter';
+
+const InputSetter = Object.assign({}, ISetter);
+
+const NEW_PROJECT = 'new-project';
+const NEW_SUBGROUP = 'new-subgroup';
+
+export default class NewGroupChild {
+ constructor(buttonWrapper) {
+ this.buttonWrapper = buttonWrapper;
+ this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
+ this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
+ this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
+
+ this.newGroupPath = this.buttonWrapper.dataset.projectPath;
+ this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
+
+ this.init();
+ }
+
+ init() {
+ this.initDroplab();
+ this.bindEvents();
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+ this.droplab.init(
+ this.dropdownToggle,
+ this.dropdownList,
+ [InputSetter],
+ this.getDroplabConfig(),
+ );
+ }
+
+ getDroplabConfig() {
+ return {
+ InputSetter: [{
+ input: this.newGroupChildButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ }, {
+ input: this.newGroupChildButton,
+ valueAttribute: 'data-text',
+ }],
+ };
+ }
+
+ bindEvents() {
+ this.newGroupChildButton
+ .addEventListener('click', this.onClickNewGroupChildButton.bind(this));
+ }
+
+ onClickNewGroupChildButton(e) {
+ if (e.target.dataset.action === NEW_PROJECT) {
+ gl.utils.visitUrl(this.newGroupPath);
+ } else if (e.target.dataset.action === NEW_SUBGROUP) {
+ gl.utils.visitUrl(this.subgroupPath);
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js
index 97e02fcb76d..639410384c2 100644
--- a/app/assets/javascripts/groups/services/groups_service.js
+++ b/app/assets/javascripts/groups/service/groups_service.js
@@ -8,7 +8,7 @@ export default class GroupsService {
this.groups = Vue.resource(endpoint);
}
- getGroups(parentId, page, filterGroups, sort) {
+ getGroups(parentId, page, filterGroups, sort, archived) {
const data = {};
if (parentId) {
@@ -20,12 +20,16 @@ export default class GroupsService {
}
if (filterGroups) {
- data.filter_groups = filterGroups;
+ data.filter = filterGroups;
}
if (sort) {
data.sort = sort;
}
+
+ if (archived) {
+ data.archived = archived;
+ }
}
return this.groups.get(data);
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
new file mode 100644
index 00000000000..a1689f4c5cc
--- /dev/null
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -0,0 +1,105 @@
+import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
+
+export default class GroupsStore {
+ constructor(hideProjects) {
+ this.state = {};
+ this.state.groups = [];
+ this.state.pageInfo = {};
+ this.hideProjects = hideProjects;
+ }
+
+ setGroups(rawGroups) {
+ if (rawGroups && rawGroups.length) {
+ this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup));
+ } else {
+ this.state.groups = [];
+ }
+ }
+
+ setSearchedGroups(rawGroups) {
+ const formatGroups = groups => groups.map((group) => {
+ const formattedGroup = this.formatGroupItem(group);
+ if (formattedGroup.children && formattedGroup.children.length) {
+ formattedGroup.children = formatGroups(formattedGroup.children);
+ }
+ return formattedGroup;
+ });
+
+ if (rawGroups && rawGroups.length) {
+ this.state.groups = formatGroups(rawGroups);
+ } else {
+ this.state.groups = [];
+ }
+ }
+
+ setGroupChildren(parentGroup, children) {
+ const updatedParentGroup = parentGroup;
+ updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild));
+ updatedParentGroup.isOpen = true;
+ updatedParentGroup.isChildrenLoading = false;
+ }
+
+ getGroups() {
+ return this.state.groups;
+ }
+
+ setPaginationInfo(pagination = {}) {
+ let paginationInfo;
+
+ if (Object.keys(pagination).length) {
+ const normalizedHeaders = normalizeHeaders(pagination);
+ paginationInfo = parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = pagination;
+ }
+
+ this.state.pageInfo = paginationInfo;
+ }
+
+ getPaginationInfo() {
+ return this.state.pageInfo;
+ }
+
+ formatGroupItem(rawGroupItem) {
+ const groupChildren = rawGroupItem.children || [];
+ const groupIsOpen = (groupChildren.length > 0) || false;
+ const childrenCount = this.hideProjects ?
+ rawGroupItem.subgroup_count :
+ rawGroupItem.children_count;
+
+ return {
+ id: rawGroupItem.id,
+ name: rawGroupItem.name,
+ fullName: rawGroupItem.full_name,
+ description: rawGroupItem.description,
+ visibility: rawGroupItem.visibility,
+ avatarUrl: rawGroupItem.avatar_url,
+ relativePath: rawGroupItem.relative_path,
+ editPath: rawGroupItem.edit_path,
+ leavePath: rawGroupItem.leave_path,
+ canEdit: rawGroupItem.can_edit,
+ canLeave: rawGroupItem.can_leave,
+ type: rawGroupItem.type,
+ permission: rawGroupItem.permission,
+ children: groupChildren,
+ isOpen: groupIsOpen,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
+ parentId: rawGroupItem.parent_id,
+ childrenCount,
+ projectCount: rawGroupItem.project_count,
+ subgroupCount: rawGroupItem.subgroup_count,
+ memberCount: rawGroupItem.number_users_with_delimiter,
+ starCount: rawGroupItem.star_count,
+ };
+ }
+
+ removeGroup(group, parentGroup) {
+ const updatedParentGroup = parentGroup;
+ if (updatedParentGroup.children && updatedParentGroup.children.length) {
+ updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id);
+ } else {
+ this.state.groups = this.state.groups.filter(child => group.id !== child.id);
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js
deleted file mode 100644
index f59ec677603..00000000000
--- a/app/assets/javascripts/groups/stores/groups_store.js
+++ /dev/null
@@ -1,167 +0,0 @@
-import Vue from 'vue';
-import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
-
-export default class GroupsStore {
- constructor() {
- this.state = {};
- this.state.groups = {};
- this.state.pageInfo = {};
- }
-
- setGroups(rawGroups, parent) {
- const parentGroup = parent;
- const tree = this.buildTree(rawGroups, parentGroup);
-
- if (parentGroup) {
- parentGroup.subGroups = tree;
- } else {
- this.state.groups = tree;
- }
-
- return tree;
- }
-
- // eslint-disable-next-line class-methods-use-this
- resetGroups(parent) {
- const parentGroup = parent;
- parentGroup.subGroups = {};
- }
-
- storePagination(pagination = {}) {
- let paginationInfo;
-
- if (Object.keys(pagination).length) {
- const normalizedHeaders = normalizeHeaders(pagination);
- paginationInfo = parseIntPagination(normalizedHeaders);
- } else {
- paginationInfo = pagination;
- }
-
- this.state.pageInfo = paginationInfo;
- }
-
- buildTree(rawGroups, parentGroup) {
- const groups = this.decorateGroups(rawGroups);
- const tree = {};
- const mappedGroups = {};
- const orphans = [];
-
- // Map groups to an object
- groups.map((group) => {
- mappedGroups[`id${group.id}`] = group;
- mappedGroups[`id${group.id}`].subGroups = {};
- return group;
- });
-
- Object.keys(mappedGroups).map((key) => {
- const currentGroup = mappedGroups[key];
- if (currentGroup.parentId) {
- // If the group is not at the root level, add it to its parent array of subGroups.
- const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
- if (findParentGroup) {
- mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
- mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
- } else if (parentGroup && parentGroup.id === currentGroup.parentId) {
- tree[`id${currentGroup.id}`] = currentGroup;
- } else {
- // No parent found. We save it for later processing
- orphans.push(currentGroup);
-
- // Add to tree to preserve original order
- tree[`id${currentGroup.id}`] = currentGroup;
- }
- } else {
- // If the group is at the top level, add it to first level elements array.
- tree[`id${currentGroup.id}`] = currentGroup;
- }
-
- return key;
- });
-
- if (orphans.length) {
- orphans.map((orphan) => {
- let found = false;
- const currentOrphan = orphan;
-
- Object.keys(tree).map((key) => {
- const group = tree[key];
-
- if (
- group &&
- currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
- // Make sure the currently selected orphan is not the same as the group
- // we are checking here otherwise it will end up in an infinite loop
- currentOrphan.id !== group.id
- ) {
- group.subGroups[currentOrphan.id] = currentOrphan;
- group.isOpen = true;
- currentOrphan.isOrphan = true;
- found = true;
-
- // Delete if group was put at the top level. If not the group will be displayed twice.
- if (tree[`id${currentOrphan.id}`]) {
- delete tree[`id${currentOrphan.id}`];
- }
- }
-
- return key;
- });
-
- if (!found) {
- currentOrphan.isOrphan = true;
-
- tree[`id${currentOrphan.id}`] = currentOrphan;
- }
-
- return orphan;
- });
- }
-
- return tree;
- }
-
- decorateGroups(rawGroups) {
- this.groups = rawGroups.map(this.decorateGroup);
- return this.groups;
- }
-
- // eslint-disable-next-line class-methods-use-this
- decorateGroup(rawGroup) {
- return {
- id: rawGroup.id,
- fullName: rawGroup.full_name,
- fullPath: rawGroup.full_path,
- avatarUrl: rawGroup.avatar_url,
- name: rawGroup.name,
- hasSubgroups: rawGroup.has_subgroups,
- canEdit: rawGroup.can_edit,
- description: rawGroup.description,
- webUrl: rawGroup.web_url,
- groupPath: rawGroup.group_path,
- parentId: rawGroup.parent_id,
- visibility: rawGroup.visibility,
- leavePath: rawGroup.leave_path,
- editPath: rawGroup.edit_path,
- isOpen: false,
- isOrphan: false,
- numberProjects: rawGroup.number_projects_with_delimiter,
- numberUsers: rawGroup.number_users_with_delimiter,
- permissions: {
- humanGroupAccess: rawGroup.permissions.human_group_access,
- },
- subGroups: {},
- };
- }
-
- // eslint-disable-next-line class-methods-use-this
- removeGroup(group, collection) {
- Vue.delete(collection, `id${group.id}`);
- }
-
- // eslint-disable-next-line class-methods-use-this
- toggleSubGroups(toggleGroup) {
- const group = toggleGroup;
- group.isOpen = !group.isOpen;
- return group;
- }
-}
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
index 29e3d2ea94e..32a1a269f9a 100644
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -4,6 +4,8 @@
/* global IssuableContext */
/* global Sidebar */
+import DueDateSelectors from './due_date_select';
+
export default () => {
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
@@ -13,6 +15,6 @@ export default () => {
new LabelsSelect();
new IssuableContext(sidebarOptions.currentUser);
gl.Subscription.bindAll('.subscription');
- new gl.DueDateSelectors();
+ new DueDateSelectors();
window.sidebar = new Sidebar();
};
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 470c39c6f76..cd2562bc6a9 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,12 +1,12 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */
/* global GitLab */
/* global Autosave */
-/* global dateFormat */
import Pikaday from 'pikaday';
import UsersSelect from './users_select';
import GfmAutoComplete from './gfm_auto_complete';
import ZenMode from './zen_mode';
+import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
(function() {
this.IssuableForm = (function() {
@@ -38,11 +38,13 @@ import ZenMode from './zen_mode';
theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd',
container: $issuableDueDate.parent().get(0),
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
onSelect: function(dateText) {
- $issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
+ $issuableDueDate.val(calendar.toString(dateText));
}
});
- calendar.setDate(new Date($issuableDueDate.val()));
+ calendar.setDate(parsePikadayDate($issuableDueDate.val()));
}
}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index eecb56cb185..d1aa83ea57f 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -24,6 +24,11 @@ export default {
required: true,
type: Boolean,
},
+ showInlineEditButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
issuableRef: {
type: String,
required: true,
@@ -222,20 +227,25 @@ export default {
<div v-else>
<title-component
:issuable-ref="issuableRef"
+ :can-update="canUpdate"
:title-html="state.titleHtml"
- :title-text="state.titleText" />
+ :title-text="state.titleText"
+ :show-inline-edit-button="showInlineEditButton"
+ />
<description-component
v-if="state.descriptionHtml"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
- :task-status="state.taskStatus" />
+ :task-status="state.taskStatus"
+ />
<edited-component
v-if="hasUpdated"
:updated-at="state.updatedAt"
:updated-by-name="state.updatedByName"
- :updated-by-path="state.updatedByPath" />
+ :updated-by-path="state.updatedByPath"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index a9dabd4cff1..00002709ac6 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -1,5 +1,8 @@
<script>
import animateMixin from '../mixins/animate';
+ import eventHub from '../event_hub';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import { spriteIcon } from '../../lib/utils/common_utils';
export default {
mixins: [animateMixin],
@@ -15,6 +18,11 @@
type: String,
required: true,
},
+ canUpdate: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
titleHtml: {
type: String,
required: true,
@@ -23,6 +31,14 @@
type: String,
required: true,
},
+ showInlineEditButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ directives: {
+ tooltip,
},
watch: {
titleHtml() {
@@ -30,24 +46,46 @@
this.animateChange();
},
},
+ computed: {
+ pencilIcon() {
+ return spriteIcon('pencil', 'link-highlight');
+ },
+ },
methods: {
setPageTitle() {
const currentPageTitleScope = this.titleEl.innerText.split('·');
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·');
},
+ edit() {
+ eventHub.$emit('open.form');
+ },
},
};
</script>
<template>
- <h2
- class="title"
- :class="{
- 'issue-realtime-pre-pulse': preAnimation,
- 'issue-realtime-trigger-pulse': pulseAnimation
- }"
- v-html="titleHtml"
- >
- </h2>
+ <div class="title-container">
+ <h2
+ class="title"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="titleHtml"
+ >
+ </h2>
+ <button
+ v-tooltip
+ v-if="showInlineEditButton && canUpdate"
+ type="button"
+ class="btn-blank btn-edit note-action-button"
+ v-html="pencilIcon"
+ title="Edit title and description"
+ data-placement="bottom"
+ data-container="body"
+ @click="edit"
+ >
+ </button>
+ </div>
</template>
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
index 3f6f40d47ba..6d671845f8e 100644
--- a/app/assets/javascripts/jobs/components/header.vue
+++ b/app/assets/javascripts/jobs/components/header.vue
@@ -43,16 +43,6 @@
type: 'link',
});
}
-
- if (this.job.retry_path) {
- actions.push({
- label: 'Retry',
- path: this.job.retry_path,
- cssClass: 'js-retry-button btn btn-inverted-secondary visible-md-block visible-lg-block',
- type: 'ujs-link',
- });
- }
-
return actions;
},
},
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index d479f7ed682..84602cf9207 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -285,7 +285,7 @@ import CreateLabelDropdown from './create_label';
},
hidden: function() {
var isIssueIndex, isMRIndex, page, selectedLabels;
- page = $('body').data('page');
+ page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide();
@@ -325,7 +325,7 @@ import CreateLabelDropdown from './create_label';
$loading.fadeOut();
};
- page = $('body').data('page');
+ page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 423a25fbdfa..07899777a1e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -1,5 +1,5 @@
-export const getPagePath = (index = 0) => $('body').data('page').split(':')[index];
+export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index];
export const isInGroupsPage = () => getPagePath() === 'groups';
@@ -403,7 +403,11 @@ export const setCiStatusFavicon = (pageUrl) => {
});
};
-export const spriteIcon = icon => `<svg><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
+export const spriteIcon = (icon, className = '') => {
+ const classAttribute = className.length > 0 ? `class="${className}"` : '';
+
+ return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
+};
export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
diff --git a/app/assets/javascripts/lib/utils/datefix.js b/app/assets/javascripts/lib/utils/datefix.js
index 990dc3f6d1a..e98c9068367 100644
--- a/app/assets/javascripts/lib/utils/datefix.js
+++ b/app/assets/javascripts/lib/utils/datefix.js
@@ -1,8 +1,29 @@
-const DateFix = {
- dashedFix(val) {
- const [y, m, d] = val.split('-');
- return new Date(y, m - 1, d);
- },
+
+export const pad = (val, len = 2) => (`0${val}`).slice(-len);
+
+/**
+ * Formats dates in Pickaday
+ * @param {String} dateString Date in yyyy-mm-dd format
+ * @return {Date} UTC format
+ */
+export const parsePikadayDate = (dateString) => {
+ const parts = dateString.split('-');
+ const year = parseInt(parts[0], 10);
+ const month = parseInt(parts[1] - 1, 10);
+ const day = parseInt(parts[2], 10);
+
+ return new Date(year, month, day);
};
-export default DateFix;
+/**
+ * Used `onSelect` method in pickaday
+ * @param {Date} date UTC format
+ * @return {String} Date formated in yyyy-mm-dd
+ */
+export const pikadayToString = (date) => {
+ const day = pad(date.getDate());
+ const month = pad(date.getMonth() + 1);
+ const year = date.getFullYear();
+
+ return `${year}-${month}-${day}`;
+};
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 78c7a094127..1aa63216baf 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -85,7 +85,7 @@ w.gl.utils.getLocationHash = function(url) {
return hashIndex === -1 ? null : url.substring(hashIndex + 1);
};
-w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href);
+w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(window.location.href);
// eslint-disable-next-line import/prefer-default-export
export function visitUrl(url, external = false) {
@@ -96,7 +96,7 @@ export function visitUrl(url, external = false) {
otherWindow.opener = null;
otherWindow.location = url;
} else {
- document.location.href = url;
+ window.location.href = url;
}
}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 8d7608ce0f4..4cf07e99161 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -21,15 +21,6 @@ window._ = _;
window.Dropzone = Dropzone;
window.Sortable = Sortable;
-// shortcuts
-import './shortcuts';
-import './shortcuts_blob';
-import './shortcuts_dashboard_navigation';
-import './shortcuts_navigation';
-import './shortcuts_find_file';
-import './shortcuts_issuable';
-import './shortcuts_network';
-
// templates
import './templates/issuable_template_selector';
import './templates/issuable_template_selectors';
@@ -53,7 +44,6 @@ import './aside';
import './autosave';
import loadAwardsHandler from './awards_handler';
import bp from './breakpoints';
-import './broadcast_message';
import './commits';
import './compare';
import './compare_autocomplete';
@@ -61,10 +51,8 @@ import './confirm_danger_modal';
import './copy_as_gfm';
import './copy_to_clipboard';
import './diff';
-import './dropzone_input';
-import './due_date_select';
import './files_comment_button';
-import Flash from './flash';
+import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
import './gl_field_error';
import './gl_field_errors';
@@ -84,8 +72,6 @@ import './layout_nav';
import LazyLoader from './lazy_loader';
import './line_highlighter';
import './logo';
-import './member_expiration_date';
-import './members';
import './merge_request';
import './merge_request_tabs';
import './milestone';
@@ -339,4 +325,10 @@ $(function () {
event.preventDefault();
gl.utils.visitUrl(`${action}${$(this).serialize()}`);
});
+
+ const flashContainer = document.querySelector('.flash-container');
+
+ if (flashContainer && flashContainer.children.length) {
+ removeFlashClickListener(flashContainer.children[0]);
+ }
});
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index cc9016e74da..84e70e35bad 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -1,55 +1,53 @@
-/* global dateFormat */
-
import Pikaday from 'pikaday';
-
-(() => {
- // Add datepickers to all `js-access-expiration-date` elements. If those elements are
- // children of an element with the `clearable-input` class, and have a sibling
- // `js-clear-input` element, then show that element when there is a value in the
- // datepicker, and make clicking on that element clear the field.
- //
- window.gl = window.gl || {};
- gl.MemberExpirationDate = (selector = '.js-access-expiration-date') => {
- function toggleClearInput() {
- $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== '');
- }
- const inputs = $(selector);
-
- inputs.each((i, el) => {
- const $input = $(el);
-
- const calendar = new Pikaday({
- field: $input.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- minDate: new Date(),
- container: $input.parent().get(0),
- onSelect(dateText) {
- $input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
-
- $input.trigger('change');
-
- toggleClearInput.call($input);
- },
- });
-
- calendar.setDate(new Date($input.val()));
- $input.data('pikaday', calendar);
+import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
+
+// Add datepickers to all `js-access-expiration-date` elements. If those elements are
+// children of an element with the `clearable-input` class, and have a sibling
+// `js-clear-input` element, then show that element when there is a value in the
+// datepicker, and make clicking on that element clear the field.
+//
+export default function memberExpirationDate(selector = '.js-access-expiration-date') {
+ function toggleClearInput() {
+ $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== '');
+ }
+ const inputs = $(selector);
+
+ inputs.each((i, el) => {
+ const $input = $(el);
+
+ const calendar = new Pikaday({
+ field: $input.get(0),
+ theme: 'gitlab-theme animate-picker',
+ format: 'yyyy-mm-dd',
+ minDate: new Date(),
+ container: $input.parent().get(0),
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
+ onSelect(dateText) {
+ $input.val(calendar.toString(dateText));
+
+ $input.trigger('change');
+
+ toggleClearInput.call($input);
+ },
});
- inputs.next('.js-clear-input').on('click', function clicked(event) {
- event.preventDefault();
+ calendar.setDate(parsePikadayDate($input.val()));
+ $input.data('pikaday', calendar);
+ });
- const input = $(this).closest('.clearable-input').find(selector);
- const calendar = input.data('pikaday');
+ inputs.next('.js-clear-input').on('click', function clicked(event) {
+ event.preventDefault();
- calendar.setDate(null);
- input.trigger('change');
- toggleClearInput.call(input);
- });
+ const input = $(this).closest('.clearable-input').find(selector);
+ const calendar = input.data('pikaday');
+
+ calendar.setDate(null);
+ input.trigger('change');
+ toggleClearInput.call(input);
+ });
- inputs.on('blur', toggleClearInput);
+ inputs.on('blur', toggleClearInput);
- inputs.each(toggleClearInput);
- };
-}).call(window);
+ inputs.each(toggleClearInput);
+}
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index 8291b8c4a70..6264750a4fb 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -1,81 +1,74 @@
-/* eslint-disable class-methods-use-this */
-(() => {
- window.gl = window.gl || {};
-
- class Members {
- constructor() {
- this.addListeners();
- this.initGLDropdown();
- }
+export default class Members {
+ constructor() {
+ this.addListeners();
+ this.initGLDropdown();
+ }
- addListeners() {
- $('.project_member, .group_member').off('ajax:success').on('ajax:success', this.removeRow);
- $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this));
- $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this));
- gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
- }
+ addListeners() {
+ $('.project_member, .group_member').off('ajax:success').on('ajax:success', this.removeRow);
+ $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this));
+ $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this));
+ gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
+ }
- initGLDropdown() {
- $('.js-member-permissions-dropdown').each((i, btn) => {
- const $btn = $(btn);
+ initGLDropdown() {
+ $('.js-member-permissions-dropdown').each((i, btn) => {
+ const $btn = $(btn);
- $btn.glDropdown({
- selectable: true,
- isSelectable(selected, $el) {
- return !$el.hasClass('is-active');
- },
- fieldName: $btn.data('field-name'),
- id(selected, $el) {
- return $el.data('id');
- },
- toggleLabel(selected, $el) {
- return $el.text();
- },
- clicked: (options) => {
- this.formSubmit(null, options.$el);
- },
- });
+ $btn.glDropdown({
+ selectable: true,
+ isSelectable(selected, $el) {
+ return !$el.hasClass('is-active');
+ },
+ fieldName: $btn.data('field-name'),
+ id(selected, $el) {
+ return $el.data('id');
+ },
+ toggleLabel(selected, $el) {
+ return $el.text();
+ },
+ clicked: (options) => {
+ this.formSubmit(null, options.$el);
+ },
});
- }
-
- removeRow(e) {
- const $target = $(e.target);
+ });
+ }
+ // eslint-disable-next-line class-methods-use-this
+ removeRow(e) {
+ const $target = $(e.target);
- if ($target.hasClass('btn-remove')) {
- $target.closest('.member')
- .fadeOut(function fadeOutMemberRow() {
- $(this).remove();
- });
- }
+ if ($target.hasClass('btn-remove')) {
+ $target.closest('.member')
+ .fadeOut(function fadeOutMemberRow() {
+ $(this).remove();
+ });
}
+ }
- formSubmit(e, $el = null) {
- const $this = e ? $(e.currentTarget) : $el;
- const { $toggle, $dateInput } = this.getMemberListItems($this);
-
- $this.closest('form').trigger('submit.rails');
-
- $toggle.disable();
- $dateInput.disable();
- }
+ formSubmit(e, $el = null) {
+ const $this = e ? $(e.currentTarget) : $el;
+ const { $toggle, $dateInput } = this.getMemberListItems($this);
- formSuccess(e) {
- const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member'));
+ $this.closest('form').trigger('submit.rails');
- $toggle.enable();
- $dateInput.enable();
- }
+ $toggle.disable();
+ $dateInput.disable();
+ }
- getMemberListItems($el) {
- const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('el-id')}`);
+ formSuccess(e) {
+ const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member'));
- return {
- $memberListItem,
- $toggle: $memberListItem.find('.dropdown-menu-toggle'),
- $dateInput: $memberListItem.find('.js-access-expiration-date'),
- };
- }
+ $toggle.enable();
+ $dateInput.enable();
}
+ // eslint-disable-next-line class-methods-use-this
+ getMemberListItems($el) {
+ const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('el-id')}`);
- gl.Members = Members;
-})();
+ return {
+ $memberListItem,
+ $toggle: $memberListItem.find('.dropdown-menu-toggle'),
+ $dateInput: $memberListItem.find('.js-access-expiration-date'),
+ };
+ }
+}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 4675b1fcb8f..e7d5325a509 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -146,8 +146,10 @@ import _ from 'underscore';
clicked: function(options) {
const { $el, e } = options;
let selected = options.selectedObj;
+
var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
- page = $('body').data('page');
+ if (!selected) return;
+ page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
isSelecting = (selected.name !== selectedMilestone);
diff --git a/app/assets/javascripts/network/network_bundle.js b/app/assets/javascripts/network/network_bundle.js
index 8aae2ad201c..129f1724cb8 100644
--- a/app/assets/javascripts/network/network_bundle.js
+++ b/app/assets/javascripts/network/network_bundle.js
@@ -1,6 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, camelcase, comma-dangle, consistent-return, max-len */
-/* global ShortcutsNetwork */
+import ShortcutsNetwork from '../shortcuts_network';
import Network from './network';
$(function() {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index cf7322ba1da..9c008da1a5d 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -13,21 +13,19 @@ import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
import autosize from 'vendor/autosize';
-import Dropzone from 'dropzone';
import 'vendor/jquery.caret'; // required by jquery.atwho
import 'vendor/jquery.atwho';
import AjaxCache from '~/lib/utils/ajax_cache';
import Flash from './flash';
import CommentTypeToggle from './comment_type_toggle';
+import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler';
import './autosave';
-import './dropzone_input';
import TaskList from './task_list';
import { ajaxPost, isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils';
import imageDiffHelper from './image_diff/helpers/index';
window.autosize = autosize;
-window.Dropzone = Dropzone;
function normalizeNewlines(str) {
return str.replace(/\r\n/g, '\n');
@@ -557,7 +555,7 @@ export default class Notes {
*/
setupNoteForm(form) {
var textarea, key;
- new gl.GLForm(form, this.enableGFM);
+ this.glForm = new GLForm(form, this.enableGFM);
textarea = form.find('.js-note-text');
key = [
'Note',
@@ -1152,7 +1150,7 @@ export default class Notes {
var targetId = $originalContentEl.data('target-id');
var targetType = $originalContentEl.data('target-type');
- new gl.GLForm($editForm.find('form'), this.enableGFM);
+ this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm.find('form')
.attr('action', postUrl)
@@ -1257,7 +1255,7 @@ export default class Notes {
}
static checkMergeRequestStatus() {
- if (getPagePath(1) === 'merge_requests') {
+ if (getPagePath(1) === 'merge_requests' && gl.mrWidget) {
gl.mrWidget.checkStatus();
}
}
diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
index 50c725aa3d5..f1cf6e92ef5 100644
--- a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
+++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Translate from '../vue_shared/translate';
+import GlFieldErrors from '../gl_field_errors';
import intervalPatternInput from './components/interval_pattern_input.vue';
import TimezoneDropdown from './components/timezone_dropdown';
import TargetBranchDropdown from './components/target_branch_dropdown';
@@ -39,7 +40,7 @@ document.addEventListener('DOMContentLoaded', () => {
gl.timezoneDropdown = new TimezoneDropdown();
gl.targetBranchDropdown = new TargetBranchDropdown();
- gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement);
+ gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
setupPipelineVariableList($('.js-pipeline-variable-list'));
});
diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
index a4d50a52315..55c93923cc8 100644
--- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
+++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
@@ -81,7 +81,11 @@ export default class PrometheusMetrics {
loadActiveMetrics() {
this.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
backOff((next, stop) => {
- $.getJSON(this.activeMetricsEndpoint)
+ $.ajax({
+ url: this.activeMetricsEndpoint,
+ dataType: 'json',
+ global: false,
+ })
.done((res) => {
if (res && res.success) {
stop(res);
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index 41ea9742406..ac1c3ec253c 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -57,7 +57,7 @@
},
showError(message) {
- Flash((errorMessages[message]));
+ Flash(errorMessages[message]);
},
},
};
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index 4ce1571b0aa..e917279947e 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -57,7 +57,7 @@
},
showError(message) {
- Flash((errorMessages[message]));
+ Flash(errorMessages[message]);
},
},
};
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
index e40382e7afc..208c3c39866 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -38,7 +38,7 @@ export default {
tag: element.name,
revision: element.revision,
shortRevision: element.short_revision,
- size: element.size,
+ size: element.total_size,
layers: element.layers,
location: element.location,
createdAt: element.created_at,
diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue
index cc60aa5939c..0a89a9f16cb 100644
--- a/app/assets/javascripts/repo/components/repo.vue
+++ b/app/assets/javascripts/repo/components/repo.vue
@@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper';
import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
export default {
- data: () => Store,
+ data() {
+ return Store;
+ },
mixins: [RepoMixin],
components: {
RepoSidebar,
diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue
index 6d8cc964eb2..185cd90ac06 100644
--- a/app/assets/javascripts/repo/components/repo_commit_section.vue
+++ b/app/assets/javascripts/repo/components/repo_commit_section.vue
@@ -3,12 +3,20 @@ import Flash from '../../flash';
import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
import Service from '../services/repo_service';
+import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
+import { visitUrl } from '../../lib/utils/url_utility';
export default {
- data: () => Store,
-
mixins: [RepoMixin],
+ data() {
+ return Store;
+ },
+
+ components: {
+ PopupDialog,
+ },
+
computed: {
showCommitable() {
return this.isCommitable && this.changedFiles.length;
@@ -28,7 +36,16 @@ export default {
},
methods: {
- makeCommit() {
+ commitToNewBranch(status) {
+ if (status) {
+ this.showNewBranchDialog = false;
+ this.tryCommit(null, true, true);
+ } else {
+ // reset the state
+ }
+ },
+
+ makeCommit(newBranch) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const commitMessage = this.commitMessage;
const actions = this.changedFiles.map(f => ({
@@ -36,19 +53,63 @@ export default {
file_path: f.path,
content: f.newContent,
}));
+ const branch = newBranch ? `${this.currentBranch}-${this.currentShortHash}` : this.currentBranch;
const payload = {
- branch: Store.currentBranch,
+ branch,
commit_message: commitMessage,
actions,
};
- Store.submitCommitsLoading = true;
+ if (newBranch) {
+ payload.start_branch = this.currentBranch;
+ }
+ this.submitCommitsLoading = true;
Service.commitFiles(payload)
- .then(this.resetCommitState)
- .catch(() => Flash('An error occurred while committing your changes'));
+ .then(() => {
+ this.resetCommitState();
+ if (this.startNewMR) {
+ this.redirectToNewMr(branch);
+ } else {
+ this.redirectToBranch(branch);
+ }
+ })
+ .catch(() => {
+ Flash('An error occurred while committing your changes');
+ });
+ },
+
+ tryCommit(e, skipBranchCheck = false, newBranch = false) {
+ if (skipBranchCheck) {
+ this.makeCommit(newBranch);
+ } else {
+ Store.setBranchHash()
+ .then(() => {
+ if (Store.branchChanged) {
+ Store.showNewBranchDialog = true;
+ return;
+ }
+ this.makeCommit(newBranch);
+ })
+ .catch(() => {
+ Flash('An error occurred while committing your changes');
+ });
+ }
+ },
+
+ redirectToNewMr(branch) {
+ visitUrl(this.newMrTemplateUrl.replace('{{source_branch}}', branch));
+ },
+
+ redirectToBranch(branch) {
+ visitUrl(this.customBranchURL.replace('{{branch}}', branch));
},
resetCommitState() {
this.submitCommitsLoading = false;
+ this.openedFiles = this.openedFiles.map((file) => {
+ const f = file;
+ f.changed = false;
+ return f;
+ });
this.changedFiles = [];
this.commitMessage = '';
this.editMode = false;
@@ -62,9 +123,17 @@ export default {
<div
v-if="showCommitable"
id="commit-area">
+ <popup-dialog
+ v-if="showNewBranchDialog"
+ :primary-button-label="__('Create new branch')"
+ kind="primary"
+ :title="__('Branch has changed')"
+ :text="__('This branch has changed since you started editing. Would you like to create a new branch?')"
+ @submit="commitToNewBranch"
+ />
<form
class="form-horizontal"
- @submit.prevent="makeCommit">
+ @submit.prevent="tryCommit">
<fieldset>
<div class="form-group">
<label class="col-md-4 control-label staged-files">
@@ -117,7 +186,7 @@ export default {
class="btn btn-success">
<i
v-if="submitCommitsLoading"
- class="fa fa-spinner fa-spin"
+ class="js-commit-loading-icon fa fa-spinner fa-spin"
aria-hidden="true"
aria-label="loading">
</i>
@@ -126,6 +195,14 @@ export default {
</span>
</button>
</div>
+ <div class="col-md-offset-4 col-md-6">
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" v-model="startNewMR">
+ <span>Start a <strong>new merge request</strong> with these changes</span>
+ </label>
+ </div>
+ </div>
</fieldset>
</form>
</div>
diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue
index 353142edeb7..e6e8b2e5205 100644
--- a/app/assets/javascripts/repo/components/repo_edit_button.vue
+++ b/app/assets/javascripts/repo/components/repo_edit_button.vue
@@ -3,7 +3,9 @@ import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
export default {
- data: () => Store,
+ data() {
+ return Store;
+ },
mixins: [RepoMixin],
computed: {
buttonLabel() {
diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue
index 02d9c775046..4639bee6d66 100644
--- a/app/assets/javascripts/repo/components/repo_editor.vue
+++ b/app/assets/javascripts/repo/components/repo_editor.vue
@@ -5,7 +5,9 @@ import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
const RepoEditor = {
- data: () => Store,
+ data() {
+ return Store;
+ },
destroyed() {
if (Helper.monacoInstance) {
@@ -22,7 +24,8 @@ const RepoEditor = {
const monacoInstance = Helper.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
- contextmenu: false,
+ contextmenu: true,
+ scrollBeyondLastLine: false,
});
Helper.monacoInstance = monacoInstance;
@@ -92,7 +95,7 @@ const RepoEditor = {
},
blobRaw() {
- if (Helper.monacoInstance && !this.isTree) {
+ if (Helper.monacoInstance) {
this.setupEditor();
}
},
diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue
index 8b9cbd23456..c7e69340f17 100644
--- a/app/assets/javascripts/repo/components/repo_file.vue
+++ b/app/assets/javascripts/repo/components/repo_file.vue
@@ -1,107 +1,78 @@
<script>
-import TimeAgoMixin from '../../vue_shared/mixins/timeago';
+ import timeAgoMixin from '../../vue_shared/mixins/timeago';
+ import eventHub from '../event_hub';
+ import repoMixin from '../mixins/repo_mixin';
-const RepoFile = {
- mixins: [TimeAgoMixin],
- props: {
- file: {
- type: Object,
- required: true,
+ export default {
+ mixins: [
+ repoMixin,
+ timeAgoMixin,
+ ],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
},
- isMini: {
- type: Boolean,
- required: false,
- default: false,
+ computed: {
+ fileIcon() {
+ const classObj = {
+ 'fa-spinner fa-spin': this.file.loading,
+ [this.file.icon]: !this.file.loading,
+ 'fa-folder-open': !this.file.loading && this.file.opened,
+ };
+ return classObj;
+ },
+ levelIndentation() {
+ return {
+ marginLeft: `${this.file.level * 16}px`,
+ };
+ },
},
- loading: {
- type: Object,
- required: false,
- default() { return { tree: false }; },
+ methods: {
+ linkClicked(file) {
+ eventHub.$emit('fileNameClicked', file);
+ },
},
- hasFiles: {
- type: Boolean,
- required: false,
- default: false,
- },
- activeFile: {
- type: Object,
- required: true,
- },
- },
-
- computed: {
- canShowFile() {
- return !this.loading.tree || this.hasFiles;
- },
-
- fileIcon() {
- const classObj = {
- 'fa-spinner fa-spin': this.file.loading,
- [this.file.icon]: !this.file.loading,
- };
- return classObj;
- },
-
- fileIndentation() {
- return {
- 'margin-left': `${this.file.level * 10}px`,
- };
- },
-
- activeFileClass() {
- return {
- active: this.activeFile.url === this.file.url,
- };
- },
- },
-
- methods: {
- linkClicked(file) {
- this.$emit('linkclicked', file);
- },
- },
-};
-
-export default RepoFile;
+ };
</script>
<template>
-<tr
- v-if="canShowFile"
- class="file"
- :class="activeFileClass"
- @click.prevent="linkClicked(file)">
- <td>
- <i
- class="fa fa-fw file-icon"
- :class="fileIcon"
- :style="fileIndentation"
- aria-label="file icon">
- </i>
- <a
- :href="file.url"
- class="repo-file-name"
- :title="file.url">
- {{file.name}}
- </a>
- </td>
+ <tr
+ class="file"
+ @click.prevent="linkClicked(file)">
+ <td>
+ <i
+ class="fa fa-fw file-icon"
+ :class="fileIcon"
+ :style="levelIndentation"
+ aria-hidden="true"
+ >
+ </i>
+ <a
+ :href="file.url"
+ class="repo-file-name"
+ >
+ {{ file.name }}
+ </a>
+ </td>
- <template v-if="!isMini">
- <td class="hidden-sm hidden-xs">
- <div class="commit-message">
- <a @click.stop :href="file.lastCommitUrl">
- {{file.lastCommitMessage}}
+ <template v-if="!isMini">
+ <td class="hidden-sm hidden-xs">
+ <a
+ @click.stop
+ :href="file.lastCommit.url"
+ class="commit-message"
+ >
+ {{ file.lastCommit.message }}
</a>
- </div>
- </td>
+ </td>
- <td class="hidden-xs text-right">
- <span
- class="commit-update"
- :title="tooltipTitle(file.lastCommitUpdate)">
- {{timeFormated(file.lastCommitUpdate)}}
- </span>
- </td>
- </template>
-</tr>
+ <td class="commit-update hidden-xs text-right">
+ <span :title="tooltipTitle(file.lastCommit.updatedAt)">
+ {{ timeFormated(file.lastCommit.updatedAt) }}
+ </span>
+ </td>
+ </template>
+ </tr>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue
index e43ef366f47..03cd219e718 100644
--- a/app/assets/javascripts/repo/components/repo_file_buttons.vue
+++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue
@@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper';
import RepoMixin from '../mixins/repo_mixin';
const RepoFileButtons = {
- data: () => Store,
+ data() {
+ return Store;
+ },
mixins: [RepoMixin],
diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue
deleted file mode 100644
index 6a15755f029..00000000000
--- a/app/assets/javascripts/repo/components/repo_file_options.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<script>
-const RepoFileOptions = {
- props: {
- isMini: {
- type: Boolean,
- required: false,
- default: false,
- },
- projectName: {
- type: String,
- required: true,
- },
- },
-};
-
-export default RepoFileOptions;
-</script>
-
-<template>
- <tr v-if="isMini" class="repo-file-options">
- <td>
- <span class="title">{{projectName}}</span>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue
index bc8c64c8362..832b45b2b29 100644
--- a/app/assets/javascripts/repo/components/repo_loading_file.vue
+++ b/app/assets/javascripts/repo/components/repo_loading_file.vue
@@ -1,43 +1,23 @@
<script>
-const RepoLoadingFile = {
- props: {
- loading: {
- type: Object,
- required: false,
- default: {},
- },
- hasFiles: {
- type: Boolean,
- required: false,
- default: false,
- },
- isMini: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- showGhostLines() {
- return this.loading.tree && !this.hasFiles;
- },
- },
+ import repoMixin from '../mixins/repo_mixin';
- methods: {
- lineOfCode(n) {
- return `skeleton-line-${n}`;
+ export default {
+ mixins: [
+ repoMixin,
+ ],
+ methods: {
+ lineOfCode(n) {
+ return `skeleton-line-${n}`;
+ },
},
- },
-};
-
-export default RepoLoadingFile;
+ };
</script>
<template>
<tr
- v-if="showGhostLines"
- class="loading-file">
+ class="loading-file"
+ aria-label="Loading files"
+ >
<td>
<div
class="animation-container animation-container-small">
@@ -48,29 +28,28 @@ export default RepoLoadingFile;
</div>
</div>
</td>
-
- <td
- v-if="!isMini"
- class="hidden-sm hidden-xs">
- <div class="animation-container">
- <div
- v-for="n in 6"
- :key="n"
- :class="lineOfCode(n)">
+ <template v-if="!isMini">
+ <td
+ class="hidden-sm hidden-xs">
+ <div class="animation-container">
+ <div
+ v-for="n in 6"
+ :key="n"
+ :class="lineOfCode(n)">
+ </div>
</div>
- </div>
- </td>
+ </td>
- <td
- v-if="!isMini"
- class="hidden-xs">
- <div class="animation-container animation-container-small">
- <div
- v-for="n in 6"
- :key="n"
- :class="lineOfCode(n)">
+ <td
+ class="hidden-xs">
+ <div class="animation-container animation-container-small animation-container-right">
+ <div
+ v-for="n in 6"
+ :key="n"
+ :class="lineOfCode(n)">
+ </div>
</div>
- </div>
- </td>
+ </td>
+ </template>
</tr>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue
index bbdbdc61e38..c4bf6dcdec2 100644
--- a/app/assets/javascripts/repo/components/repo_prev_directory.vue
+++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue
@@ -1,38 +1,38 @@
<script>
-import RepoMixin from '../mixins/repo_mixin';
+ import eventHub from '../event_hub';
+ import repoMixin from '../mixins/repo_mixin';
-const RepoPreviousDirectory = {
- props: {
- prevUrl: {
- type: String,
- required: true,
+ export default {
+ mixins: [
+ repoMixin,
+ ],
+ props: {
+ prevUrl: {
+ type: String,
+ required: true,
+ },
},
- },
-
- mixins: [RepoMixin],
-
- computed: {
- colSpanCondition() {
- return this.isMini ? undefined : 3;
+ computed: {
+ colSpanCondition() {
+ return this.isMini ? undefined : 3;
+ },
},
- },
-
- methods: {
- linkClicked(file) {
- this.$emit('linkclicked', file);
+ methods: {
+ linkClicked(file) {
+ eventHub.$emit('goToPreviousDirectoryClicked', file);
+ },
},
- },
-};
-
-export default RepoPreviousDirectory;
+ };
</script>
<template>
-<tr class="prev-directory">
- <td
- :colspan="colSpanCondition"
- @click.prevent="linkClicked(prevUrl)">
- <a :href="prevUrl">..</a>
- </td>
-</tr>
+ <tr class="file prev-directory">
+ <td
+ :colspan="colSpanCondition"
+ class="table-cell"
+ @click.prevent="linkClicked(prevUrl)"
+ >
+ <a :href="prevUrl">...</a>
+ </td>
+ </tr>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue
index a87bef6084a..b5be771d539 100644
--- a/app/assets/javascripts/repo/components/repo_preview.vue
+++ b/app/assets/javascripts/repo/components/repo_preview.vue
@@ -4,7 +4,9 @@
import Store from '../stores/repo_store';
export default {
- data: () => Store,
+ data() {
+ return Store;
+ },
computed: {
html() {
return this.activeFile.html;
diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue
index e0f3c33003a..5832e603907 100644
--- a/app/assets/javascripts/repo/components/repo_sidebar.vue
+++ b/app/assets/javascripts/repo/components/repo_sidebar.vue
@@ -1,9 +1,10 @@
<script>
+import _ from 'underscore';
import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
import Store from '../stores/repo_store';
+import eventHub from '../event_hub';
import RepoPreviousDirectory from './repo_prev_directory.vue';
-import RepoFileOptions from './repo_file_options.vue';
import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin';
@@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin';
export default {
mixins: [RepoMixin],
components: {
- 'repo-file-options': RepoFileOptions,
'repo-previous-directory': RepoPreviousDirectory,
'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile,
},
-
created() {
window.addEventListener('popstate', this.checkHistory);
},
destroyed() {
+ eventHub.$off('fileNameClicked', this.fileClicked);
+ eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
window.removeEventListener('popstate', this.checkHistory);
},
+ mounted() {
+ eventHub.$on('fileNameClicked', this.fileClicked);
+ eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
+ },
+ data() {
+ return Store;
+ },
+ computed: {
+ flattendFiles() {
+ const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)]));
- data: () => Store,
-
+ return _.chain(this.files)
+ .map(arr => [arr, mapFiles(arr)])
+ .flatten()
+ .value();
+ },
+ },
methods: {
checkHistory() {
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
@@ -52,21 +67,21 @@ export default {
},
fileClicked(clickedFile, lineNumber) {
- let file = clickedFile;
+ const file = clickedFile;
+
if (file.loading) return;
- file.loading = true;
if (file.type === 'tree' && file.opened) {
- file = Store.removeChildFilesOfTree(file);
- file.loading = false;
+ Helper.setDirectoryToClosed(file);
Store.setActiveLine(lineNumber);
} else {
const openFile = Helper.getFileFromPath(file.url);
+
if (openFile) {
- file.loading = false;
Store.setActiveFiles(openFile);
Store.setActiveLine(lineNumber);
} else {
+ file.loading = true;
Service.url = file.url;
Helper.getContent(file)
.then(() => {
@@ -81,7 +96,7 @@ export default {
goToPreviousDirectoryClicked(prevURL) {
Service.url = prevURL;
- Helper.getContent(null)
+ Helper.getContent(null, true)
.then(() => Helper.scrollTabsRight())
.catch(Helper.loadingError);
},
@@ -92,38 +107,43 @@ export default {
<template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}">
<table class="table">
- <thead v-if="!isMini">
+ <thead>
<tr>
- <th class="name">Name</th>
- <th class="hidden-sm hidden-xs last-commit">Last commit</th>
- <th class="hidden-xs last-update text-right">Last update</th>
+ <th
+ v-if="isMini"
+ class="repo-file-options title"
+ >
+ <strong class="clgray">
+ {{ projectName }}
+ </strong>
+ </th>
+ <template v-else>
+ <th class="name">
+ Name
+ </th>
+ <th class="hidden-sm hidden-xs last-commit">
+ Last commit
+ </th>
+ <th class="hidden-xs last-update text-right">
+ Last update
+ </th>
+ </template>
</tr>
</thead>
<tbody>
- <repo-file-options
- :is-mini="isMini"
- :project-name="projectName"
- />
<repo-previous-directory
- v-if="isRoot"
+ v-if="!isRoot && !loading.tree"
:prev-url="prevURL"
- @linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
+ />
<repo-loading-file
+ v-if="!flattendFiles.length && loading.tree"
v-for="n in 5"
:key="n"
- :loading="loading"
- :has-files="!!files.length"
- :is-mini="isMini"
/>
<repo-file
- v-for="file in files"
+ v-for="file in flattendFiles"
:key="file.id"
:file="file"
- :is-mini="isMini"
- @linkclicked="fileClicked(file)"
- :is-tree="isTree"
- :has-files="!!files.length"
- :active-file="activeFile"
/>
</tbody>
</table>
diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue
index 0d0c34ec741..098715915b0 100644
--- a/app/assets/javascripts/repo/components/repo_tab.vue
+++ b/app/assets/javascripts/repo/components/repo_tab.vue
@@ -26,11 +26,13 @@ const RepoTab = {
},
methods: {
- tabClicked: Store.setActiveFiles,
-
+ tabClicked(file) {
+ Store.setActiveFiles(file);
+ },
closeTab(file) {
if (file.changed) return;
- this.$emit('tabclosed', file);
+
+ Store.removeFromOpenedFiles(file);
},
},
};
@@ -39,25 +41,28 @@ export default RepoTab;
</script>
<template>
-<li @click="tabClicked(tab)">
- <a
- href="#0"
- class="close"
- @click.stop.prevent="closeTab(tab)"
- :aria-label="closeLabel">
- <i
- class="fa"
- :class="changedClass"
- aria-hidden="true">
- </i>
- </a>
+ <li
+ :class="{ active : tab.active }"
+ @click="tabClicked(tab)"
+ >
+ <button
+ type="button"
+ class="close-btn"
+ @click.stop.prevent="closeTab(tab)"
+ :aria-label="closeLabel">
+ <i
+ class="fa"
+ :class="changedClass"
+ aria-hidden="true">
+ </i>
+ </button>
- <a
- href="#"
- class="repo-tab"
- :title="tab.url"
- @click.prevent="tabClicked(tab)">
- {{tab.name}}
- </a>
-</li>
+ <a
+ href="#"
+ class="repo-tab"
+ :title="tab.url"
+ @click.prevent="tabClicked(tab)">
+ {{tab.name}}
+ </a>
+ </li>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue
index 9c5bfc5d0cf..b57cd0960de 100644
--- a/app/assets/javascripts/repo/components/repo_tabs.vue
+++ b/app/assets/javascripts/repo/components/repo_tabs.vue
@@ -1,36 +1,29 @@
<script>
-import Store from '../stores/repo_store';
-import RepoTab from './repo_tab.vue';
-import RepoMixin from '../mixins/repo_mixin';
+ import Store from '../stores/repo_store';
+ import RepoTab from './repo_tab.vue';
+ import RepoMixin from '../mixins/repo_mixin';
-const RepoTabs = {
- mixins: [RepoMixin],
-
- components: {
- 'repo-tab': RepoTab,
- },
-
- data: () => Store,
-
- methods: {
- tabClosed(file) {
- Store.removeFromOpenedFiles(file);
+ export default {
+ mixins: [RepoMixin],
+ components: {
+ 'repo-tab': RepoTab,
},
- },
-};
-
-export default RepoTabs;
+ data() {
+ return Store;
+ },
+ };
</script>
<template>
-<ul id="tabs">
- <repo-tab
- v-for="tab in openedFiles"
- :key="tab.id"
- :tab="tab"
- :class="{'active' : tab.active}"
- @tabclosed="tabClosed"
- />
- <li class="tabs-divider" />
-</ul>
+ <ul
+ id="tabs"
+ class="list-unstyled"
+ >
+ <repo-tab
+ v-for="tab in openedFiles"
+ :key="tab.id"
+ :tab="tab"
+ />
+ <li class="tabs-divider" />
+ </ul>
</template>
diff --git a/app/assets/javascripts/repo/event_hub.js b/app/assets/javascripts/repo/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/repo/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js
index 46204598e1d..dfaf9caaee7 100644
--- a/app/assets/javascripts/repo/helpers/repo_helper.js
+++ b/app/assets/javascripts/repo/helpers/repo_helper.js
@@ -1,3 +1,4 @@
+import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
import Service from '../services/repo_service';
import Store from '../stores/repo_store';
import Flash from '../../flash';
@@ -25,10 +26,6 @@ const RepoHelper = {
key: '',
- isTree(data) {
- return Object.hasOwnProperty.call(data, 'blobs');
- },
-
Time: window.performance
&& window.performance.now
? window.performance
@@ -58,13 +55,20 @@ const RepoHelper = {
},
setDirectoryOpen(tree, title) {
- const file = tree;
- if (!file) return undefined;
+ if (!tree) return;
+
+ Object.assign(tree, {
+ opened: true,
+ });
+
+ RepoHelper.updateHistoryEntry(tree.url, title);
+ },
- file.opened = true;
- file.icon = 'fa-folder-open';
- RepoHelper.updateHistoryEntry(file.url, title);
- return file;
+ setDirectoryToClosed(entry) {
+ Object.assign(entry, {
+ opened: false,
+ files: [],
+ });
},
isRenderable() {
@@ -81,63 +85,23 @@ const RepoHelper = {
.catch(RepoHelper.loadingError);
},
- // when you open a directory you need to put the directory files under
- // the directory... This will merge the list of the current directory and the new list.
- getNewMergedList(inDirectory, currentList, newList) {
- const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
- if (!inDirectory) return newListSorted;
- const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url);
- if (!indexOfFile) return newListSorted;
- return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
- },
-
- // within the get new merged list this does the merging of the current list of files
- // and the new list of files. The files are never "in" another directory they just
- // appear like they are because of the margin.
- mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
- newList.reverse().forEach((newFile) => {
- const fileIndex = indexOfFile + 1;
- const file = newFile;
- file.level = inDirectory.level + 1;
- oldList.splice(fileIndex, 0, file);
- });
-
- return oldList;
- },
-
- compareFilesCaseInsensitive(a, b) {
- const aName = a.name.toLowerCase();
- const bName = b.name.toLowerCase();
- if (a.level > 0) return 0;
- if (aName < bName) { return -1; }
- if (aName > bName) { return 1; }
- return 0;
- },
+ getContent(treeOrFile, emptyFiles = false) {
+ let file = treeOrFile;
- isRoot(url) {
- // the url we are requesting -> split by the project URL. Grab the right side.
- const isRoot = !!url.split(Store.projectUrl)[1]
- // remove the first "/"
- .slice(1)
- // split this by "/"
- .split('/')
- // remove the first two items of the array... usually /tree/master.
- .slice(2)
- // we want to know the length of the array.
- // If greater than 0 not root.
- .length;
- return isRoot;
- },
+ if (!Store.files.length) {
+ Store.loading.tree = true;
+ }
- getContent(treeOrFile) {
- let file = treeOrFile;
return Service.getContent()
.then((response) => {
const data = response.data;
if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title'];
+ if (response.headers && response.headers['is-root'] && !Store.isInitialRoot) {
+ Store.isRoot = convertPermissionToBoolean(response.headers['is-root']);
+ Store.isInitialRoot = Store.isRoot;
+ }
- Store.isTree = RepoHelper.isTree(data);
- if (!Store.isTree) {
+ if (file && file.type === 'blob') {
if (!file) file = data;
Store.binary = data.binary;
@@ -145,38 +109,40 @@ const RepoHelper = {
// file might be undefined
RepoHelper.setBinaryDataAsBase64(data);
Store.setViewToPreview();
- } else if (!Store.isPreviewView()) {
- if (!data.render_error) {
- Service.getRaw(data.raw_path)
- .then((rawResponse) => {
- Store.blobRaw = rawResponse.data;
- data.plain = rawResponse.data;
- RepoHelper.setFile(data, file);
- }).catch(RepoHelper.loadingError);
- }
+ } else if (!Store.isPreviewView() && !data.render_error) {
+ Service.getRaw(data.raw_path)
+ .then((rawResponse) => {
+ Store.blobRaw = rawResponse.data;
+ data.plain = rawResponse.data;
+ RepoHelper.setFile(data, file);
+ }).catch(RepoHelper.loadingError);
}
if (Store.isPreviewView()) {
RepoHelper.setFile(data, file);
}
+ } else {
+ Store.loading.tree = false;
+ RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
- // if the file tree is empty
- if (Store.files.length === 0) {
- const parentURL = Service.blobURLtoParentTree(Service.url);
- Service.url = parentURL;
- RepoHelper.getContent();
+ if (emptyFiles) {
+ Store.files = [];
}
- } else {
- // it's a tree
- if (!file) Store.isRoot = RepoHelper.isRoot(Service.url);
- file = RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
- const newDirectory = RepoHelper.dataToListOfFiles(data);
- Store.addFilesToDirectory(file, Store.files, newDirectory);
+
+ this.addToDirectory(file, data);
+
Store.prevURL = Service.blobURLtoParentTree(Service.url);
}
}).catch(RepoHelper.loadingError);
},
+ addToDirectory(file, data) {
+ const tree = file || Store;
+ const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0));
+
+ tree.files = files;
+ },
+
setFile(data, file) {
const newFile = data;
newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh.
@@ -190,57 +156,39 @@ const RepoHelper = {
Store.setActiveFiles(newFile);
},
- serializeBlob(blob) {
- const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
- simpleBlob.lastCommitMessage = blob.last_commit.message;
- simpleBlob.lastCommitUpdate = blob.last_commit.committed_date;
- simpleBlob.loading = false;
-
- return simpleBlob;
- },
-
- serializeTree(tree) {
- return RepoHelper.serializeRepoEntity('tree', tree);
- },
-
- serializeSubmodule(submodule) {
- return RepoHelper.serializeRepoEntity('submodule', submodule);
- },
-
- serializeRepoEntity(type, entity) {
+ serializeRepoEntity(type, entity, level = 0) {
const { url, name, icon, last_commit } = entity;
- const returnObj = {
+
+ return {
type,
name,
url,
+ level,
icon: `fa-${icon}`,
- level: 0,
+ files: [],
loading: false,
+ opened: false,
+ // eslint-disable-next-line camelcase
+ lastCommit: last_commit ? {
+ url: `${Store.projectUrl}/commit/${last_commit.id}`,
+ message: last_commit.message,
+ updatedAt: last_commit.committed_date,
+ } : {},
};
-
- if (entity.last_commit) {
- returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`;
- } else {
- returnObj.lastCommitUrl = '';
- }
- return returnObj;
},
scrollTabsRight() {
- // wait for the transition. 0.1 seconds.
- setTimeout(() => {
- const tabs = document.getElementById('tabs');
- if (!tabs) return;
- tabs.scrollLeft = tabs.scrollWidth;
- }, 200);
+ const tabs = document.getElementById('tabs');
+ if (!tabs) return;
+ tabs.scrollLeft = tabs.scrollWidth;
},
- dataToListOfFiles(data) {
+ dataToListOfFiles(data, level) {
const { blobs, trees, submodules } = data;
return [
- ...blobs.map(blob => RepoHelper.serializeBlob(blob)),
- ...trees.map(tree => RepoHelper.serializeTree(tree)),
- ...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
+ ...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)),
+ ...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)),
+ ...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)),
];
},
diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js
index 7d0123e3d3a..65dee7d5fd1 100644
--- a/app/assets/javascripts/repo/index.js
+++ b/app/assets/javascripts/repo/index.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import Service from './services/repo_service';
import Store from './stores/repo_store';
import Repo from './components/repo.vue';
@@ -31,8 +32,13 @@ function setInitialStore(data) {
Store.projectUrl = data.projectUrl;
Store.canCommit = data.canCommit;
Store.onTopOfBranch = data.onTopOfBranch;
+ Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
+ Store.customBranchURL = decodeURIComponent(data.blobUrl);
+ Store.isRoot = convertPermissionToBoolean(data.root);
+ Store.isInitialRoot = convertPermissionToBoolean(data.root);
Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
Store.checkIsCommitable();
+ Store.setBranchHash();
}
function initRepo(el) {
diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js
index 830685f7e6e..d68d71a4629 100644
--- a/app/assets/javascripts/repo/services/repo_service.js
+++ b/app/assets/javascripts/repo/services/repo_service.js
@@ -64,6 +64,10 @@ const RepoService = {
return urlArray.join('/');
},
+ getBranch() {
+ return Api.branchSingle(Store.projectId, Store.currentBranch);
+ },
+
commitFiles(payload) {
return Api.commitMultiple(Store.projectId, payload)
.then(this.commitFlash);
diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js
index c633f538c1b..49d7317a17e 100644
--- a/app/assets/javascripts/repo/stores/repo_store.js
+++ b/app/assets/javascripts/repo/stores/repo_store.js
@@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service';
const RepoStore = {
- monaco: {},
monacoLoading: false,
service: '',
canCommit: false,
onTopOfBranch: false,
editMode: false,
- isTree: false,
- isRoot: false,
+ isRoot: null,
+ isInitialRoot: null,
prevURL: '',
projectId: '',
projectName: '',
@@ -23,6 +22,7 @@ const RepoStore = {
title: '',
status: false,
},
+ showNewBranchDialog: false,
activeFile: Helper.getDefaultActiveFile(),
activeFileIndex: 0,
activeLine: -1,
@@ -31,22 +31,27 @@ const RepoStore = {
isCommitable: false,
binary: false,
currentBranch: '',
+ startNewMR: false,
+ currentHash: '',
+ currentShortHash: '',
+ customBranchURL: '',
+ newMrTemplateUrl: '',
+ branchChanged: false,
commitMessage: '',
- binaryTypes: {
- png: false,
- md: false,
- svg: false,
- unknown: false,
- },
loading: {
tree: false,
blob: false,
},
- resetBinaryTypes() {
- Object.keys(RepoStore.binaryTypes).forEach((key) => {
- RepoStore.binaryTypes[key] = false;
- });
+ setBranchHash() {
+ return Service.getBranch()
+ .then((data) => {
+ if (RepoStore.currentHash !== '' && data.commit.id !== RepoStore.currentHash) {
+ RepoStore.branchChanged = true;
+ }
+ RepoStore.currentHash = data.commit.id;
+ RepoStore.currentShortHash = data.commit.short_id;
+ });
},
// mutations
@@ -54,10 +59,6 @@ const RepoStore = {
RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
},
- addFilesToDirectory(inDirectory, currentList, newList) {
- RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
- },
-
toggleRawPreview() {
RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
@@ -111,30 +112,6 @@ const RepoStore = {
RepoStore.activeFileLabel = 'Display source';
},
- removeChildFilesOfTree(tree) {
- let foundTree = false;
- const treeToClose = tree;
- let canStopSearching = false;
- RepoStore.files = RepoStore.files.filter((file) => {
- const isItTheTreeWeWant = file.url === treeToClose.url;
- // if it's the next tree
- if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
- canStopSearching = true;
- return true;
- }
- if (canStopSearching) return true;
-
- if (isItTheTreeWeWant) foundTree = true;
-
- if (foundTree) return file.level <= treeToClose.level;
- return true;
- });
-
- treeToClose.opened = false;
- treeToClose.icon = 'fa-folder';
- return treeToClose;
- },
-
removeFromOpenedFiles(file) {
if (file.type === 'tree') return;
let foundIndex;
@@ -168,6 +145,7 @@ const RepoStore = {
if (openedFilesAlreadyExists) return;
openFile.changed = false;
+ openFile.active = true;
RepoStore.openedFiles.push(openFile);
},
diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js
index f63b99ba1c5..ebe7a99ffae 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -1,128 +1,116 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, prefer-arrow-callback, consistent-return, object-shorthand, no-unused-vars, one-var, one-var-declaration-per-line, no-else-return, comma-dangle, max-len */
-/* global Mousetrap */
import Cookies from 'js-cookie';
import Mousetrap from 'mousetrap';
-
import findAndFollowLink from './shortcuts_dashboard_navigation';
-(function() {
- this.Shortcuts = (function() {
- function Shortcuts(skipResetBindings) {
- this.onToggleHelp = this.onToggleHelp.bind(this);
- this.enabledHelp = [];
- if (!skipResetBindings) {
- Mousetrap.reset();
- }
- Mousetrap.bind('?', this.onToggleHelp);
- Mousetrap.bind('s', Shortcuts.focusSearch);
- Mousetrap.bind('f', (e => this.focusFilter(e)));
- Mousetrap.bind('p b', this.onTogglePerfBar);
-
- const findFileURL = document.body.dataset.findFile;
-
- Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity'));
- Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues'));
- Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests'));
- Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects'));
- Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups'));
- Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones'));
- Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets'));
-
- Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], this.toggleMarkdownPreview);
- if (typeof findFileURL !== "undefined" && findFileURL !== null) {
- Mousetrap.bind('t', function() {
- return gl.utils.visitUrl(findFileURL);
- });
- }
+const defaultStopCallback = Mousetrap.stopCallback;
+Mousetrap.stopCallback = (e, element, combo) => {
+ if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) {
+ return false;
+ }
+
+ return defaultStopCallback(e, element, combo);
+};
+
+export default class Shortcuts {
+ constructor(skipResetBindings) {
+ this.onToggleHelp = this.onToggleHelp.bind(this);
+ this.enabledHelp = [];
+ if (!skipResetBindings) {
+ Mousetrap.reset();
}
+ Mousetrap.bind('?', this.onToggleHelp);
+ Mousetrap.bind('s', Shortcuts.focusSearch);
+ Mousetrap.bind('f', this.focusFilter.bind(this));
+ Mousetrap.bind('p b', Shortcuts.onTogglePerfBar);
- Shortcuts.prototype.onToggleHelp = function(e) {
- e.preventDefault();
- return Shortcuts.toggleHelp(this.enabledHelp);
- };
+ const findFileURL = document.body.dataset.findFile;
+
+ Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity'));
+ Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues'));
+ Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests'));
+ Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects'));
+ Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups'));
+ Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones'));
+ Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets'));
+
+ Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], Shortcuts.toggleMarkdownPreview);
- Shortcuts.prototype.onTogglePerfBar = function(e) {
+ if (typeof findFileURL !== 'undefined' && findFileURL !== null) {
+ Mousetrap.bind('t', () => {
+ gl.utils.visitUrl(findFileURL);
+ });
+ }
+
+ $(document).on('click.more_help', '.js-more-help-button', function clickMoreHelp(e) {
+ $(this).remove();
+ $('.hidden-shortcut').show();
e.preventDefault();
- const performanceBarCookieName = 'perf_bar_enabled';
- if (Cookies.get(performanceBarCookieName) === 'true') {
- Cookies.remove(performanceBarCookieName, { path: '/' });
- } else {
- Cookies.set(performanceBarCookieName, 'true', { path: '/' });
- }
- gl.utils.refreshCurrentPage();
- };
-
- Shortcuts.prototype.toggleMarkdownPreview = function(e) {
- // Check if short-cut was triggered while in Write Mode
- const $target = $(e.target);
- const $form = $target.closest('form');
-
- if ($target.hasClass('js-note-text')) {
- $('.js-md-preview-button', $form).focus();
- }
- return $(document).triggerHandler('markdown-preview:toggle', [e]);
- };
-
- Shortcuts.toggleHelp = function(location) {
- var $modal;
- $modal = $('#modal-shortcuts');
- if ($modal.length) {
- $modal.modal('toggle');
- return;
- }
- return $.ajax({
- url: gon.shortcuts_path,
- dataType: 'script',
- success: function(e) {
- var i, l, len, results;
- if (location && location.length > 0) {
- results = [];
- for (i = 0, len = location.length; i < len; i += 1) {
- l = location[i];
- results.push($(l).show());
- }
- return results;
- } else {
- $('.hidden-shortcut').show();
- return $('.js-more-help-button').remove();
+ });
+ }
+
+ onToggleHelp(e) {
+ e.preventDefault();
+ Shortcuts.toggleHelp(this.enabledHelp);
+ }
+
+ static onTogglePerfBar(e) {
+ e.preventDefault();
+ const performanceBarCookieName = 'perf_bar_enabled';
+ if (Cookies.get(performanceBarCookieName) === 'true') {
+ Cookies.remove(performanceBarCookieName, { path: '/' });
+ } else {
+ Cookies.set(performanceBarCookieName, 'true', { path: '/' });
+ }
+ gl.utils.refreshCurrentPage();
+ }
+
+ static toggleMarkdownPreview(e) {
+ // Check if short-cut was triggered while in Write Mode
+ const $target = $(e.target);
+ const $form = $target.closest('form');
+
+ if ($target.hasClass('js-note-text')) {
+ $('.js-md-preview-button', $form).focus();
+ }
+ $(document).triggerHandler('markdown-preview:toggle', [e]);
+ }
+
+ static toggleHelp(location) {
+ const $modal = $('#modal-shortcuts');
+
+ if ($modal.length) {
+ $modal.modal('toggle');
+ }
+
+ $.ajax({
+ url: gon.shortcuts_path,
+ dataType: 'script',
+ success() {
+ if (location && location.length > 0) {
+ const results = [];
+ for (let i = 0, len = location.length; i < len; i += 1) {
+ results.push($(location[i]).show());
}
+ return results;
}
- });
- };
-
- Shortcuts.prototype.focusFilter = function(e) {
- if (this.filterInput == null) {
- this.filterInput = $('input[type=search]', '.nav-controls');
- }
- this.filterInput.focus();
- return e.preventDefault();
- };
-
- Shortcuts.focusSearch = function(e) {
- $('#search').focus();
- return e.preventDefault();
- };
-
- return Shortcuts;
- })();
-
- $(document).on('click.more_help', '.js-more-help-button', function(e) {
- $(this).remove();
- $('.hidden-shortcut').show();
- return e.preventDefault();
- });
-
- Mousetrap.stopCallback = (function() {
- var defaultStopCallback;
- defaultStopCallback = Mousetrap.stopCallback;
- return function(e, element, combo) {
- // allowed shortcuts if textarea, input, contenteditable are focused
- if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) {
- return false;
- } else {
- return defaultStopCallback.apply(this, arguments);
- }
- };
- })();
-}).call(window);
+
+ $('.hidden-shortcut').show();
+ return $('.js-more-help-button').remove();
+ },
+ });
+ }
+
+ focusFilter(e) {
+ if (!this.filterInput) {
+ this.filterInput = $('input[type=search]', '.nav-controls');
+ }
+ this.filterInput.focus();
+ e.preventDefault();
+ }
+
+ static focusSearch(e) {
+ $('#search').focus();
+ e.preventDefault();
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js
index ccbf7c59165..fbc57bb4304 100644
--- a/app/assets/javascripts/shortcuts_blob.js
+++ b/app/assets/javascripts/shortcuts_blob.js
@@ -1,7 +1,6 @@
/* global Mousetrap */
-/* global Shortcuts */
-import './shortcuts';
+import Shortcuts from './shortcuts';
const defaults = {
skipResetBindings: false,
diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js
index b18b6139b35..81286c0010c 100644
--- a/app/assets/javascripts/shortcuts_find_file.js
+++ b/app/assets/javascripts/shortcuts_find_file.js
@@ -1,38 +1,30 @@
-/* eslint-disable func-names, space-before-function-paren, max-len, one-var, no-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife */
/* global Mousetrap */
-/* global ShortcutsNavigation */
-import './shortcuts_navigation';
+import ShortcutsNavigation from './shortcuts_navigation';
-(function() {
- var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
- hasProp = {}.hasOwnProperty;
+export default class ShortcutsFindFile extends ShortcutsNavigation {
+ constructor(projectFindFile) {
+ super();
- this.ShortcutsFindFile = (function(superClass) {
- extend(ShortcutsFindFile, superClass);
+ const oldStopCallback = Mousetrap.stopCallback;
+ this.projectFindFile = projectFindFile;
- function ShortcutsFindFile(projectFindFile) {
- var _oldStopCallback;
- this.projectFindFile = projectFindFile;
- ShortcutsFindFile.__super__.constructor.call(this);
- _oldStopCallback = Mousetrap.stopCallback;
- Mousetrap.stopCallback = (function(_this) {
- // override to fire shortcuts action when focus in textbox
- return function(event, element, combo) {
- if (element === _this.projectFindFile.inputElement[0] && (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')) {
- // when press up/down key in textbox, cusor prevent to move to home/end
- event.preventDefault();
- return false;
- }
- return _oldStopCallback(event, element, combo);
- };
- })(this);
- Mousetrap.bind('up', this.projectFindFile.selectRowUp);
- Mousetrap.bind('down', this.projectFindFile.selectRowDown);
- Mousetrap.bind('esc', this.projectFindFile.goToTree);
- Mousetrap.bind('enter', this.projectFindFile.goToBlob);
- }
+ Mousetrap.stopCallback = (e, element, combo) => {
+ if (
+ element === this.projectFindFile.inputElement[0] &&
+ (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')
+ ) {
+ // when press up/down key in textbox, cusor prevent to move to home/end
+ event.preventDefault();
+ return false;
+ }
- return ShortcutsFindFile;
- })(ShortcutsNavigation);
-}).call(window);
+ return oldStopCallback(e, element, combo);
+ };
+
+ Mousetrap.bind('up', this.projectFindFile.selectRowUp);
+ Mousetrap.bind('down', this.projectFindFile.selectRowDown);
+ Mousetrap.bind('esc', this.projectFindFile.goToTree);
+ Mousetrap.bind('enter', this.projectFindFile.goToBlob);
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index 78b257bf192..fc97938e3d1 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -1,100 +1,74 @@
-/* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, one-var-declaration-per-line, quotes, prefer-arrow-callback, consistent-return, prefer-template, no-mixed-operators */
/* global Mousetrap */
-/* global ShortcutsNavigation */
/* global sidebar */
import _ from 'underscore';
import 'mousetrap';
-import './shortcuts_navigation';
-
-(function() {
- var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
- hasProp = {}.hasOwnProperty;
-
- this.ShortcutsIssuable = (function(superClass) {
- extend(ShortcutsIssuable, superClass);
-
- function ShortcutsIssuable(isMergeRequest) {
- ShortcutsIssuable.__super__.constructor.call(this);
- Mousetrap.bind('a', this.openSidebarDropdown.bind(this, 'assignee'));
- Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
- Mousetrap.bind('r', (function(_this) {
- return function() {
- _this.replyWithSelectedText(isMergeRequest);
- return false;
- };
- })(this));
- Mousetrap.bind('e', (function(_this) {
- return function() {
- _this.editIssue();
- return false;
- };
- })(this));
- Mousetrap.bind('l', this.openSidebarDropdown.bind(this, 'labels'));
- if (isMergeRequest) {
- this.enabledHelp.push('.hidden-shortcut.merge_requests');
- } else {
- this.enabledHelp.push('.hidden-shortcut.issues');
- }
+import ShortcutsNavigation from './shortcuts_navigation';
+
+export default class ShortcutsIssuable extends ShortcutsNavigation {
+ constructor(isMergeRequest) {
+ super();
+
+ this.$replyField = isMergeRequest ? $('.js-main-target-form #note_note') : $('.js-main-target-form .js-vue-comment-form');
+ this.editBtn = document.querySelector('.issuable-edit');
+
+ Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee'));
+ Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone'));
+ Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
+ Mousetrap.bind('r', this.replyWithSelectedText.bind(this));
+ Mousetrap.bind('e', this.editIssue.bind(this));
+
+ if (isMergeRequest) {
+ this.enabledHelp.push('.hidden-shortcut.merge_requests');
+ } else {
+ this.enabledHelp.push('.hidden-shortcut.issues');
}
+ }
+
+ replyWithSelectedText() {
+ const documentFragment = window.gl.utils.getSelectedFragment();
+
+ if (!documentFragment) {
+ this.$replyField.focus();
+ return false;
+ }
+
+ const el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
+ const selected = window.gl.CopyAsGFM.nodeToGFM(el);
- ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
- var quote, documentFragment, el, selected, separator;
- let replyField;
-
- if (isMergeRequest) {
- replyField = $('.js-main-target-form #note_note');
- } else {
- replyField = $('.js-main-target-form .js-vue-comment-form');
- }
-
- documentFragment = window.gl.utils.getSelectedFragment();
- if (!documentFragment) {
- replyField.focus();
- return;
- }
-
- el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
- selected = window.gl.CopyAsGFM.nodeToGFM(el);
-
- if (selected.trim() === "") {
- return;
- }
- quote = _.map(selected.split("\n"), function(val) {
- return ("> " + val).trim() + "\n";
- });
-
- // If replyField already has some content, add a newline before our quote
- separator = replyField.val().trim() !== "" && "\n\n" || '';
- replyField.val(function(a, current) {
- return current + separator + quote.join('') + "\n";
- });
-
- // Trigger autosave
- replyField.trigger('input').trigger('change');
-
- // Trigger autosize
- var event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- replyField.get(0).dispatchEvent(event);
-
- // Focus the input field
- return replyField.focus();
- };
-
- ShortcutsIssuable.prototype.editIssue = function() {
- var $editBtn;
- $editBtn = $('.issuable-edit');
- // Need to click the element as on issues, editing is inline
- // on merge request, editing is on a different page
- $editBtn.get(0).click();
- };
-
- ShortcutsIssuable.prototype.openSidebarDropdown = function(name) {
- sidebar.openDropdown(name);
+ if (selected.trim() === '') {
return false;
- };
+ }
+
+ const quote = _.map(selected.split('\n'), val => `${(`> ${val}`).trim()}\n`);
+
+ // If replyField already has some content, add a newline before our quote
+ const separator = (this.$replyField.val().trim() !== '' && '\n\n') || '';
+ this.$replyField.val((a, current) => `${current}${separator}${quote.join('')}\n`)
+ .trigger('input')
+ .trigger('change');
+
+ // Trigger autosize
+ const event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ this.$replyField.get(0).dispatchEvent(event);
+
+ // Focus the input field
+ this.$replyField.focus();
+
+ return false;
+ }
+
+ editIssue() {
+ // Need to click the element as on issues, editing is inline
+ // on merge request, editing is on a different page
+ this.editBtn.click();
+
+ return false;
+ }
- return ShortcutsIssuable;
- })(ShortcutsNavigation);
-}).call(window);
+ static openSidebarDropdown(name) {
+ sidebar.openDropdown(name);
+ return false;
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js
index 55bae0c08a1..b4562701a3e 100644
--- a/app/assets/javascripts/shortcuts_navigation.js
+++ b/app/assets/javascripts/shortcuts_navigation.js
@@ -1,36 +1,27 @@
-/* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */
/* global Mousetrap */
-/* global Shortcuts */
import findAndFollowLink from './shortcuts_dashboard_navigation';
-import './shortcuts';
+import Shortcuts from './shortcuts';
-(function() {
- var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
- hasProp = {}.hasOwnProperty;
+export default class ShortcutsNavigation extends Shortcuts {
+ constructor() {
+ super();
- this.ShortcutsNavigation = (function(superClass) {
- extend(ShortcutsNavigation, superClass);
+ Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
+ Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-project-activity'));
+ Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
+ Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
+ Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
+ Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network'));
+ Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts'));
+ Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues'));
+ Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards'));
+ Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests'));
+ Mousetrap.bind('g t', () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki'));
+ Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets'));
+ Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue'));
- function ShortcutsNavigation() {
- ShortcutsNavigation.__super__.constructor.call(this);
- Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
- Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-project-activity'));
- Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
- Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
- Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
- Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network'));
- Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts'));
- Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues'));
- Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards'));
- Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests'));
- Mousetrap.bind('g t', () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki'));
- Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets'));
- Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue'));
- this.enabledHelp.push('.hidden-shortcut.project');
- }
-
- return ShortcutsNavigation;
- })(Shortcuts);
-}).call(window);
+ this.enabledHelp.push('.hidden-shortcut.project');
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js
index cc44082efa9..21823085ac4 100644
--- a/app/assets/javascripts/shortcuts_network.js
+++ b/app/assets/javascripts/shortcuts_network.js
@@ -1,28 +1,17 @@
-/* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, max-len */
/* global Mousetrap */
-/* global ShortcutsNavigation */
+import ShortcutsNavigation from './shortcuts_navigation';
-import './shortcuts_navigation';
+export default class ShortcutsNetwork extends ShortcutsNavigation {
+ constructor(graph) {
+ super();
-(function() {
- var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
- hasProp = {}.hasOwnProperty;
+ Mousetrap.bind(['left', 'h'], graph.scrollLeft);
+ Mousetrap.bind(['right', 'l'], graph.scrollRight);
+ Mousetrap.bind(['up', 'k'], graph.scrollUp);
+ Mousetrap.bind(['down', 'j'], graph.scrollDown);
+ Mousetrap.bind(['shift+up', 'shift+k'], graph.scrollTop);
+ Mousetrap.bind(['shift+down', 'shift+j'], graph.scrollBottom);
- this.ShortcutsNetwork = (function(superClass) {
- extend(ShortcutsNetwork, superClass);
-
- function ShortcutsNetwork(graph) {
- this.graph = graph;
- ShortcutsNetwork.__super__.constructor.call(this);
- Mousetrap.bind(['left', 'h'], this.graph.scrollLeft);
- Mousetrap.bind(['right', 'l'], this.graph.scrollRight);
- Mousetrap.bind(['up', 'k'], this.graph.scrollUp);
- Mousetrap.bind(['down', 'j'], this.graph.scrollDown);
- Mousetrap.bind(['shift+up', 'shift+k'], this.graph.scrollTop);
- Mousetrap.bind(['shift+down', 'shift+j'], this.graph.scrollBottom);
- this.enabledHelp.push('.hidden-shortcut.network');
- }
-
- return ShortcutsNetwork;
- })(ShortcutsNavigation);
-}).call(window);
+ this.enabledHelp.push('.hidden-shortcut.network');
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_wiki.js b/app/assets/javascripts/shortcuts_wiki.js
index 8a075062a48..59b967dbe09 100644
--- a/app/assets/javascripts/shortcuts_wiki.js
+++ b/app/assets/javascripts/shortcuts_wiki.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
/* global Mousetrap */
-/* global ShortcutsNavigation */
+import ShortcutsNavigation from './shortcuts_navigation';
import findAndFollowLink from './shortcuts_dashboard_navigation';
export default class ShortcutsWiki extends ShortcutsNavigation {
diff --git a/app/assets/javascripts/users/index.js b/app/assets/javascripts/users/index.js
index 33a83f8dae5..9fd8452a2b6 100644
--- a/app/assets/javascripts/users/index.js
+++ b/app/assets/javascripts/users/index.js
@@ -1,7 +1,7 @@
import Cookies from 'js-cookie';
import UserTabs from './user_tabs';
-export default function initUserProfile(action) {
+function initUserProfile(action) {
// place profile avatars to top
$('.profile-groups-avatars').tooltip({
placement: 'top',
@@ -17,3 +17,9 @@ export default function initUserProfile(action) {
$(this).parents('.project-limit-message').remove();
});
}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const page = $('body').attr('data-page');
+ const action = page.split(':')[1];
+ initUserProfile(action);
+});
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 73676bd6de7..a0883b32593 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -424,7 +424,7 @@ function UsersSelect(currentUser, els) {
}
var isIssueIndex, isMRIndex, page, selected;
- page = $('body').data('page');
+ page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index af4187fab46..8c0d9b9cda8 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,5 +1,6 @@
<script>
import Flash from '../../../flash';
+ import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
@@ -85,7 +86,7 @@
/*
GLForm class handles all the toolbar buttons
*/
- return new gl.GLForm($(this.$refs['gl-form']), true);
+ return new GLForm($(this.$refs['gl-form']), true);
},
beforeDestroy() {
const glForm = $(this.$refs['gl-form']).data('gl-form');
diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue
index 9279b50cd55..7d8c5936b7d 100644
--- a/app/assets/javascripts/vue_shared/components/popup_dialog.vue
+++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue
@@ -16,6 +16,11 @@ export default {
required: false,
default: 'primary',
},
+ closeKind: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
closeButtonLabel: {
type: String,
required: false,
@@ -33,6 +38,11 @@ export default {
[`btn-${this.kind}`]: true,
};
},
+ btnCancelKindClass() {
+ return {
+ [`btn-${this.closeKind}`]: true,
+ };
+ },
},
methods: {
@@ -70,7 +80,8 @@ export default {
<div class="modal-footer">
<button
type="button"
- class="btn btn-default"
+ class="btn"
+ :class="btnCancelKindClass"
@click="emitSubmit(false)">
{{closeButtonLabel}}
</button>
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 dd9a2ebb184..1ac61a3c39b 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
@@ -7,6 +7,7 @@
Sample configuration:
<user-avatar-image
+ :lazy="true"
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
@@ -16,11 +17,17 @@
*/
import defaultAvatarUrl from 'images/no_avatar.png';
+import { placeholderImage } from '../../../lazy_loader';
import tooltip from '../../directives/tooltip';
export default {
name: 'UserAvatarImage',
props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
imgSrc: {
type: String,
required: false,
@@ -56,18 +63,21 @@ export default {
tooltip,
},
computed: {
+ // API response sends null when gravatar is disabled and
+ // 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;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
tooltipContainer() {
return this.tooltipText ? 'body' : null;
},
avatarSizeClass() {
return `s${this.size}`;
},
- // API response sends null when gravatar is disabled and
- // we provide an empty string when we use it inside user avatar link.
- // In both cases we should render the defaultAvatarUrl
- imageSource() {
- return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- },
},
};
</script>
@@ -76,11 +86,16 @@ export default {
<img
v-tooltip
class="avatar"
- :class="[avatarSizeClass, cssClasses]"
- :src="imageSource"
+ :class="{
+ lazy,
+ [avatarSizeClass]: true,
+ [cssClasses]: true
+ }"
+ :src="resultantSrcAttribute"
:width="size"
:height="size"
:alt="imgAlt"
+ :data-src="sanitizedSource"
:data-container="tooltipContainer"
:data-placement="tooltipPlacement"
:title="tooltipText"
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 99c7644e4d9..cba7b9227cd 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -11,8 +11,6 @@ import Dropzone from 'dropzone';
import 'mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
-window.Dropzone = Dropzone;
-
//
// ### Events
//
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 0d7a5cba928..aa61ddc6a2c 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -7,6 +7,7 @@
@import "framework/animations";
@import "framework/avatar";
@import "framework/asciidoctor";
+@import "framework/banner";
@import "framework/blocks";
@import "framework/buttons";
@import "framework/badges";
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index f0e6b23757f..374988bb590 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -198,6 +198,13 @@ a {
height: 12px;
}
+ &.animation-container-right {
+ .skeleton-line-2 {
+ left: 0;
+ right: 150px;
+ }
+ }
+
&::before {
animation-duration: 1s;
animation-fill-mode: forwards;
diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss
new file mode 100644
index 00000000000..6433b0c7855
--- /dev/null
+++ b/app/assets/stylesheets/framework/banner.scss
@@ -0,0 +1,25 @@
+.banner-callout {
+ display: flex;
+ position: relative;
+ flex-wrap: wrap;
+
+ .banner-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ opacity: 1;
+
+ .dismiss-icon {
+ color: $gl-text-color;
+ font-size: $gl-font-size;
+ }
+ }
+
+ .banner-graphic {
+ margin: 20px auto;
+ }
+
+ &.banner-non-empty-state {
+ border-bottom: 1px solid $border-color;
+ }
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 9dcf332eee2..a9d804e735d 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -838,6 +838,7 @@
a {
padding: 8px 40px;
+ &.is-indeterminate::before,
&.is-active::before {
left: 16px;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 588ec1ff3bc..5833ef939e9 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -10,6 +10,10 @@
border: 0;
}
+ &.file-holder-bottom-radius {
+ border-radius: 0 0 $border-radius-small $border-radius-small;
+ }
+
&.readme-holder {
margin: $gl-padding 0;
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index badc7b0eba3..d43f998cb82 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -281,6 +281,57 @@ ul.indent-list {
// Specific styles for tree list
+@keyframes spin-avatar {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.groups-list-tree-container {
+ .has-no-search-results {
+ text-align: center;
+ padding: $gl-padding;
+ font-style: italic;
+ color: $well-light-text-color;
+ }
+
+ > .group-list-tree > .group-row.has-children:first-child {
+ border-top: none;
+ }
+}
+
+.group-list-tree .avatar-container.content-loading {
+ position: relative;
+
+ > a,
+ > a .avatar {
+ height: 100%;
+ border-radius: 50%;
+ }
+
+ > a {
+ padding: 2px;
+ }
+
+ > a .avatar {
+ border: 2px solid $white-normal;
+
+ &.identicon {
+ line-height: 30px;
+ }
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ background-color: transparent;
+ border: 2px outset $kdb-border;
+ border-radius: 50%;
+ animation: spin-avatar 3s infinite linear;
+ }
+}
+
.group-list-tree {
.folder-toggle-wrap {
float: left;
@@ -293,7 +344,7 @@ ul.indent-list {
}
.folder-caret,
- .folder-icon {
+ .item-type-icon {
display: inline-block;
}
@@ -301,11 +352,11 @@ ul.indent-list {
width: 15px;
}
- .folder-icon {
+ .item-type-icon {
width: 20px;
}
- > .group-row:not(.has-subgroups) {
+ > .group-row:not(.has-children) {
.folder-caret .fa {
opacity: 0;
}
@@ -351,12 +402,23 @@ ul.indent-list {
top: 30px;
bottom: 0;
}
+
+ &.being-removed {
+ opacity: 0.5;
+ }
}
}
.group-row {
padding: 0;
- border: none;
+
+ &.has-children {
+ border-top: none;
+ }
+
+ &:first-child {
+ border-top: 1px solid $white-normal;
+ }
&:last-of-type {
.group-row-contents:not(:hover) {
@@ -379,6 +441,25 @@ ul.indent-list {
.avatar-container > a {
width: 100%;
}
+
+ &.has-more-items {
+ display: block;
+ padding: 20px 10px;
+ }
+ }
+}
+
+ul.group-list-tree {
+ li.group-row {
+ &.has-description {
+ .title {
+ line-height: inherit;
+ }
+ }
+
+ .title {
+ line-height: $list-text-height;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/new-sidebar.scss b/app/assets/stylesheets/framework/new-sidebar.scss
index caf4c7a40b1..17fa31c450d 100644
--- a/app/assets/stylesheets/framework/new-sidebar.scss
+++ b/app/assets/stylesheets/framework/new-sidebar.scss
@@ -90,7 +90,7 @@ $new-sidebar-collapsed-width: 50px;
top: $header-height;
bottom: 0;
left: 0;
- background-color: $gray-normal;
+ background-color: $gray-light;
box-shadow: inset -2px 0 0 $border-color;
transform: translate3d(0, 0, 0);
@@ -466,7 +466,7 @@ $new-sidebar-collapsed-width: 50px;
@media (max-width: $screen-xs-max) {
+ .breadcrumbs-links {
- padding-left: 17px;
+ padding-left: $gl-padding;
border-left: 1px solid $gl-text-color-quaternary;
}
}
diff --git a/app/assets/stylesheets/framework/secondary-navigation-elements.scss b/app/assets/stylesheets/framework/secondary-navigation-elements.scss
index 5c96b3b78e7..3fd2549b143 100644
--- a/app/assets/stylesheets/framework/secondary-navigation-elements.scss
+++ b/app/assets/stylesheets/framework/secondary-navigation-elements.scss
@@ -6,6 +6,7 @@
margin: 0;
list-style: none;
height: auto;
+ border-bottom: 1px solid $border-color;
li {
display: flex;
@@ -24,6 +25,7 @@
&:focus {
text-decoration: none;
color: $black;
+ border-bottom: 2px solid $gray-darkest;
.badge {
color: $black;
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 58f6e62b06a..621eec4f158 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -64,6 +64,10 @@
z-index: 999;
}
+.select2-drop-mask {
+ z-index: 998;
+}
+
.select2-drop.select2-drop-above.select2-drop-active {
border-top: 1px solid $dropdown-border-color;
margin-top: -6px;
diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss
index 93baf73cb78..98f28987a82 100644
--- a/app/assets/stylesheets/framework/tooltips.scss
+++ b/app/assets/stylesheets/framework/tooltips.scss
@@ -3,5 +3,5 @@
border-radius: $border-radius-default;
line-height: 16px;
font-weight: $gl-font-weight-normal;
- padding: $gl-btn-padding;
+ padding: 8px;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 089a67a7c98..d5ca23ff870 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -233,6 +233,7 @@ $container-text-max-width: 540px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
$border-radius-default: 4px;
+$border-radius-small: 2px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 5538e46a6c4..8d6f30e3b84 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -4,6 +4,6 @@
}
.alert-block {
- margin-bottom: 20px;
+ margin-bottom: 10px;
}
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index ffb5fc94475..09f831dcb29 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -707,11 +707,11 @@
.frame.click-to-comment {
position: relative;
- cursor: url(icon_image_comment.svg)
+ cursor: image-url('icon_image_comment.svg')
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto;
// Retina cursor
- cursor: -webkit-image-set(url(icon_image_comment.svg) 1x, url(icon_image_comment@2x.svg) 2x)
+ cursor: -webkit-image-set(image-url('icon_image_comment.svg') 1x, image-url('icon_image_comment@2x.svg') 2x)
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto;
.comment-indicator {
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index d3cd4d507de..edfafa79c44 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -4,7 +4,7 @@
border-right: 1px solid $border-color;
border-left: 1px solid $border-color;
border-bottom: none;
- border-radius: 2px;
+ border-radius: $border-radius-small $border-radius-small 0 0;
background: $gray-normal;
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 6f6c6839975..9b7dda9b648 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -26,14 +26,117 @@
}
}
-.groups-header {
- @media (min-width: $screen-sm-min) {
- .nav-links {
- width: 35%;
+.group-nav-container .nav-controls {
+ display: flex;
+ align-items: flex-start;
+ padding: $gl-padding-top 0;
+ border-bottom: 1px solid $border-color;
+
+ .group-filter-form {
+ flex: 1;
+ }
+
+ .dropdown-menu-align-right {
+ margin-top: 0;
+ }
+
+ .new-project-subgroup {
+ .dropdown-primary {
+ min-width: 115px;
+ }
+
+ .dropdown-toggle {
+ .dropdown-btn-icon {
+ pointer-events: none;
+ color: inherit;
+ margin-left: 0;
+ }
}
- .nav-controls {
- width: 65%;
+ .dropdown-menu {
+ min-width: 280px;
+ margin-top: 2px;
+ }
+
+ li:not(.divider) {
+ padding: 0;
+
+ &.droplab-item-selected {
+ .icon-container {
+ .list-item-checkmark {
+ visibility: visible;
+ }
+ }
+ }
+
+ .menu-item {
+ padding: 8px 4px;
+
+ &:hover {
+ background-color: $gray-darker;
+ color: $theme-gray-900;
+ }
+ }
+
+ .icon-container {
+ float: left;
+ padding-left: 6px;
+
+ .list-item-checkmark {
+ visibility: hidden;
+ }
+ }
+
+ .description {
+ font-size: 14px;
+
+ strong {
+ display: block;
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+ }
+ }
+
+ @media (max-width: $screen-sm-max) {
+ &,
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-new {
+ display: block;
+ }
+
+ .group-filter-form,
+ .dropdown {
+ margin-bottom: 10px;
+ margin-right: 0;
+ }
+
+ .group-filter-form,
+ .dropdown .dropdown-toggle,
+ .btn-new {
+ width: 100%;
+ }
+
+ .dropdown .dropdown-toggle .fa-chevron-down {
+ position: absolute;
+ top: 11px;
+ right: 8px;
+ }
+
+ .new-project-subgroup {
+ display: flex;
+ align-items: flex-start;
+
+ .dropdown-primary {
+ flex: 1;
+ }
+
+ .dropdown-menu {
+ width: 100%;
+ max-width: inherit;
+ min-width: inherit;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index dae3ec7ac42..48532503263 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -72,12 +72,22 @@
}
}
+ .title-container {
+ display: flex;
+ }
+
.title {
padding: 0;
margin-bottom: 16px;
border-bottom: none;
}
+ .btn-edit {
+ margin-left: auto;
+ // Set height to match title height
+ height: 2em;
+ }
+
// Border around images in issue and MR descriptions.
.description img:not(.emoji) {
border: 1px solid $white-normal;
@@ -117,7 +127,7 @@
}
.right-sidebar {
- a,
+ a:not(.btn-retry),
.btn-link {
color: inherit;
}
@@ -459,7 +469,7 @@
}
}
- a {
+ a:not(.btn-retry) {
&:hover {
color: $md-link-color;
text-decoration: none;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 96b7db3b85d..ebad429c2ba 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -531,14 +531,13 @@ ul.notes {
padding: 0;
min-width: 16px;
color: $gray-darkest;
+ fill: $gray-darkest;
.fa {
position: relative;
font-size: 16px;
}
-
-
svg {
height: 16px;
width: 16px;
@@ -566,6 +565,7 @@ ul.notes {
.link-highlight {
color: $gl-link-color;
+ fill: $gl-link-color;
svg {
fill: $gl-link-color;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 61dc9f13d50..bd385db9692 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -634,10 +634,14 @@ a.deploy-project-label {
}
.project-import {
- .form-group {
+ .import-btn-container {
margin-bottom: 0;
}
+ .toggle-import-form {
+ padding-bottom: 10px;
+ }
+
.import-buttons {
padding-left: 0;
display: -webkit-flex;
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index c36fe25f74d..ea37ccf5e3d 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -153,28 +153,13 @@
overflow-x: auto;
li {
- animation: swipeRightAppear ease-in 0.1s;
- animation-iteration-count: 1;
- transform-origin: 0% 50%;
- list-style-type: none;
+ position: relative;
background: $gray-normal;
- display: inline-block;
padding: #{$gl-padding / 2} $gl-padding;
border-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
- white-space: nowrap;
cursor: pointer;
- &.remove {
- animation: swipeRightDissapear ease-in 0.1s;
- animation-iteration-count: 1;
- transform-origin: 0% 50%;
-
- a {
- width: 0;
- }
- }
-
&.active {
background: $white-light;
border-bottom: none;
@@ -182,17 +167,21 @@
a {
@include str-truncated(100px);
- color: $black;
+ color: $gl-text-color;
vertical-align: middle;
text-decoration: none;
margin-right: 12px;
+ }
- &.close {
- width: auto;
- font-size: 15px;
- opacity: 1;
- margin-right: -6px;
- }
+ .close-btn {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ padding: 0;
+ background: none;
+ border: 0;
+ font-size: $gl-font-size;
+ transform: translateY(-50%);
}
.close-icon:hover {
@@ -201,9 +190,6 @@
.close-icon,
.unsaved-icon {
- float: right;
- margin-top: 3px;
- margin-left: 15px;
color: $gray-darkest;
}
@@ -222,9 +208,7 @@
#repo-file-buttons {
background-color: $white-light;
- border-bottom: 1px solid $white-normal;
padding: 5px 10px;
- position: relative;
border-top: 1px solid $white-normal;
}
@@ -287,37 +271,23 @@
overflow: auto;
}
- table {
+ .table {
margin-bottom: 0;
}
tr {
- animation: fadein 0.5s;
- cursor: pointer;
-
- &.repo-file-options td {
- padding: 0;
- border-top: none;
- background: $gray-light;
+ .repo-file-options {
+ padding: 2px 16px;
width: 100%;
- display: inline-block;
-
- &:first-child {
- border-top-left-radius: 2px;
- }
+ }
- .title {
- display: inline-block;
- font-size: 10px;
- text-transform: uppercase;
- font-weight: $gl-font-weight-bold;
- color: $gray-darkest;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: middle;
- padding: 2px 16px;
- }
+ .title {
+ font-size: 10px;
+ text-transform: uppercase;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: middle;
}
.file-icon {
@@ -329,11 +299,13 @@
}
}
+ .file {
+ cursor: pointer;
+ }
+
a {
@include str-truncated(250px);
color: $almost-black;
- display: inline-block;
- vertical-align: middle;
}
}
}
diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb
new file mode 100644
index 00000000000..9d4f97aa443
--- /dev/null
+++ b/app/controllers/concerns/group_tree.rb
@@ -0,0 +1,24 @@
+module GroupTree
+ def render_group_tree(groups)
+ @groups = if params[:filter].present?
+ Gitlab::GroupHierarchy.new(groups.search(params[:filter]))
+ .base_and_ancestors
+ else
+ # Only show root groups if no parent-id is given
+ groups.where(parent_id: params[:parent_id])
+ end
+ @groups = @groups.with_selects_for_list(archived: params[:archived])
+ .sort(@sort = params[:sort])
+ .page(params[:page])
+
+ respond_to do |format|
+ format.html
+ format.json do
+ serializer = GroupChildSerializer.new(current_user: current_user)
+ .with_pagination(request, response)
+ serializer.expand_hierarchy if params[:filter].present?
+ render json: serializer.represent(@groups)
+ end
+ end
+ end
+end
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index 7ed18fb481c..025769f512a 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,33 +1,8 @@
class Dashboard::GroupsController < Dashboard::ApplicationController
- def index
- @sort = params[:sort] || 'created_desc'
-
- @groups =
- if params[:parent_id] && Group.supports_nested_groups?
- parent = Group.find_by(id: params[:parent_id])
-
- if can?(current_user, :read_group, parent)
- GroupsFinder.new(current_user, parent: parent).execute
- else
- Group.none
- end
- else
- current_user.groups
- end
+ include GroupTree
- @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
- @groups = @groups.includes(:route)
- @groups = @groups.sort(@sort)
- @groups = @groups.page(params[:page])
-
- respond_to do |format|
- format.html
- format.json do
- render json: GroupSerializer
- .new(current_user: @current_user)
- .with_pagination(request, response)
- .represent(@groups)
- end
- end
+ def index
+ groups = GroupsFinder.new(current_user, all_available: false).execute
+ render_group_tree(groups)
end
end
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 81883c543ba..fa0a0f68fbc 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -1,17 +1,7 @@
class Explore::GroupsController < Explore::ApplicationController
- def index
- @groups = GroupsFinder.new(current_user).execute
- @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
- @groups = @groups.sort(@sort = params[:sort])
- @groups = @groups.page(params[:page])
+ include GroupTree
- respond_to do |format|
- format.html
- format.json do
- render json: {
- html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
- }
- end
- end
+ def index
+ render_group_tree GroupsFinder.new(current_user).execute
end
end
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
new file mode 100644
index 00000000000..b474f5d15ee
--- /dev/null
+++ b/app/controllers/groups/children_controller.rb
@@ -0,0 +1,39 @@
+module Groups
+ class ChildrenController < Groups::ApplicationController
+ before_action :group
+
+ def index
+ parent = if params[:parent_id].present?
+ GroupFinder.new(current_user).execute(id: params[:parent_id])
+ else
+ @group
+ end
+
+ if parent.nil?
+ render_404
+ return
+ end
+
+ setup_children(parent)
+
+ respond_to do |format|
+ format.json do
+ serializer = GroupChildSerializer
+ .new(current_user: current_user)
+ .with_pagination(request, response)
+ serializer.expand_hierarchy(parent) if params[:filter].present?
+ render json: serializer.represent(@children)
+ end
+ end
+ end
+
+ protected
+
+ def setup_children(parent)
+ @children = GroupDescendantsFinder.new(current_user: current_user,
+ parent_group: parent,
+ params: params).execute
+ @children = @children.page(params[:page])
+ end
+ end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index a962d82e3b5..e23a82d01be 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -46,15 +46,11 @@ class GroupsController < Groups::ApplicationController
end
def show
- setup_projects
-
respond_to do |format|
- format.html
-
- format.json do
- render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
- }
+ format.html do
+ @has_children = GroupDescendantsFinder.new(current_user: current_user,
+ parent_group: @group,
+ params: params).has_children?
end
format.atom do
@@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController
end
end
- def subgroups
- return not_found unless Group.supports_nested_groups?
-
- @nested_groups = GroupsFinder.new(current_user, parent: group).execute
- @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
- end
-
def activity
respond_to do |format|
format.html
@@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController
protected
- def setup_projects
- set_non_archived_param
- params[:sort] ||= 'latest_activity_desc'
- @sort = params[:sort]
-
- options = {}
- options[:only_owned] = true if params[:shared] == '0'
- options[:only_shared] = true if params[:shared] == '1'
-
- @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute
- @projects = @projects.includes(:namespace)
- @projects = @projects.page(params[:page]) if params[:name].blank?
- end
-
def authorize_create_group!
allowed = if params[:parent_id].present?
parent = Group.find_by(id: params[:parent_id])
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index d7dd8ddcb7d..9e79852e378 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -2,7 +2,6 @@ class Projects::ApplicationController < ApplicationController
include RoutableActions
skip_before_action :authenticate_user!
- before_action :redirect_git_extension
before_action :project
before_action :repository
layout 'project'
@@ -11,15 +10,6 @@ class Projects::ApplicationController < ApplicationController
private
- def redirect_git_extension
- # Redirect from
- # localhost/group/project.git
- # to
- # localhost/group/project
- #
- redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git'
- end
-
def project
return @project if @project
return nil unless params[:project_id] || params[:id]
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 4a841bf2073..d48284a4429 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -48,6 +48,8 @@ class Projects::CommitsController < Projects::ApplicationController
private
def set_commits
+ render_404 unless request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
+
@limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
search = params[:search]
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
index 28afef101a9..366524b0783 100644
--- a/app/controllers/projects/merge_requests/conflicts_controller.rb
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -53,7 +53,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) }
- rescue Gitlab::Conflict::ResolutionError => e
+ rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e
render status: :bad_request, json: { message: e.message }
end
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index f3719059f88..756f7e5df8c 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController
format.json do
page_title @path.presence || _("Files"), @ref, @project.name_with_namespace
+ response.header['is-root'] = @path.empty?
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
Gitlab::GitalyClient.allow_n_plus_1_calls do
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index e90b75672ae..db543d688a0 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -4,6 +4,7 @@ class ProjectsController < Projects::ApplicationController
include PreviewMarkdown
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
+ before_action :redirect_git_extension, only: [:show]
before_action :project, except: [:index, :new, :create]
before_action :repository, except: [:index, :new, :create]
before_action :assign_ref_vars, only: [:show], if: :repo_exists?
@@ -125,7 +126,7 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_project, @project)
::Projects::DestroyService.new(@project, current_user, {}).async_execute
- flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace }
+ flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.name_with_namespace }
redirect_to dashboard_projects_path, status: 302
rescue Projects::DestroyService::DestroyError => ex
@@ -389,4 +390,13 @@ class ProjectsController < Projects::ApplicationController
def project_export_enabled
render_404 unless current_application_settings.project_export_enabled?
end
+
+ def redirect_git_extension
+ # Redirect from
+ # localhost/group/project.git
+ # to
+ # localhost/group/project
+ #
+ redirect_to request.original_url.sub(/\.git\/?\Z/, '') if params[:format] == 'git'
+ end
end
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
new file mode 100644
index 00000000000..1a5f6063437
--- /dev/null
+++ b/app/finders/group_descendants_finder.rb
@@ -0,0 +1,153 @@
+# GroupDescendantsFinder
+#
+# Used to find and filter all subgroups and projects of a passed parent group
+# visible to a specified user.
+#
+# When passing a `filter` param, the search is performed over all nested levels
+# of the `parent_group`. All ancestors for a search result are loaded
+#
+# Arguments:
+# current_user: The user for which the children should be visible
+# parent_group: The group to find children of
+# params:
+# Supports all params that the `ProjectsFinder` and `GroupProjectsFinder`
+# support.
+#
+# filter: string - is aliased to `search` for consistency with the frontend
+# archived: string - `only` or `true`.
+# `non_archived` is passed to the `ProjectFinder`s if none
+# was given.
+class GroupDescendantsFinder
+ attr_reader :current_user, :parent_group, :params
+
+ def initialize(current_user: nil, parent_group:, params: {})
+ @current_user = current_user
+ @parent_group = parent_group
+ @params = params.reverse_merge(non_archived: params[:archived].blank?)
+ end
+
+ def execute
+ # The children array might be extended with the ancestors of projects when
+ # filtering. In that case, take the maximum so the array does not get limited
+ # Otherwise, allow paginating through all results
+ #
+ all_required_elements = children
+ all_required_elements |= ancestors_for_projects if params[:filter]
+ total_count = [all_required_elements.size, paginator.total_count].max
+
+ Kaminari.paginate_array(all_required_elements, total_count: total_count)
+ end
+
+ def has_children?
+ projects.any? || subgroups.any?
+ end
+
+ private
+
+ def children
+ @children ||= paginator.paginate(params[:page])
+ end
+
+ def paginator
+ @paginator ||= Gitlab::MultiCollectionPaginator.new(subgroups, projects,
+ per_page: params[:per_page])
+ end
+
+ def direct_child_groups
+ GroupsFinder.new(current_user,
+ parent: parent_group,
+ all_available: true).execute
+ end
+
+ def all_visible_descendant_groups
+ groups_table = Group.arel_table
+ visible_to_user = groups_table[:visibility_level]
+ .in(Gitlab::VisibilityLevel.levels_for_user(current_user))
+ if current_user
+ authorized_groups = GroupsFinder.new(current_user,
+ all_available: false)
+ .execute.as('authorized')
+ authorized_to_user = groups_table.project(1).from(authorized_groups)
+ .where(authorized_groups[:id].eq(groups_table[:id]))
+ .exists
+ visible_to_user = visible_to_user.or(authorized_to_user)
+ end
+
+ hierarchy_for_parent
+ .descendants
+ .where(visible_to_user)
+ end
+
+ def subgroups_matching_filter
+ all_visible_descendant_groups
+ .search(params[:filter])
+ end
+
+ # When filtering we want all to preload all the ancestors upto the specified
+ # parent group.
+ #
+ # - root
+ # - subgroup
+ # - nested-group
+ # - project
+ #
+ # So when searching 'project', on the 'subgroup' page we want to preload
+ # 'nested-group' but not 'subgroup' or 'root'
+ def ancestors_for_groups(base_for_ancestors)
+ Gitlab::GroupHierarchy.new(base_for_ancestors)
+ .base_and_ancestors(upto: parent_group.id)
+ end
+
+ def ancestors_for_projects
+ projects_to_load_ancestors_of = projects.where.not(namespace: parent_group)
+ groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id))
+ ancestors_for_groups(groups_to_load_ancestors_of)
+ .with_selects_for_list(archived: params[:archived])
+ end
+
+ def subgroups
+ return Group.none unless Group.supports_nested_groups?
+
+ # When filtering subgroups, we want to find all matches withing the tree of
+ # descendants to show to the user
+ groups = if params[:filter]
+ ancestors_for_groups(subgroups_matching_filter)
+ else
+ direct_child_groups
+ end
+ groups.with_selects_for_list(archived: params[:archived]).order_by(sort)
+ end
+
+ def direct_child_projects
+ GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
+ .execute
+ end
+
+ # Finds all projects nested under `parent_group` or any of its descendant
+ # groups
+ def projects_matching_filter
+ projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id))
+ params_with_search = params.merge(search: params[:filter])
+
+ ProjectsFinder.new(params: params_with_search,
+ current_user: current_user,
+ project_ids_relation: projects_nested_in_group).execute
+ end
+
+ def projects
+ projects = if params[:filter]
+ projects_matching_filter
+ else
+ direct_child_projects
+ end
+ projects.with_route.order_by(sort)
+ end
+
+ def sort
+ params.fetch(:sort, 'id_asc')
+ end
+
+ def hierarchy_for_parent
+ @hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id))
+ end
+end
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index f2d3b90b8e2..6e8733bb49c 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder
else
collection_without_user
end
-
union(projects)
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 8d02d5de5c3..4754a67450f 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -309,4 +309,8 @@ module ApplicationHelper
def show_new_repo?
cookies["new_repo"] == "true" && body_data_page != 'projects:show'
end
+
+ def locale_path
+ asset_path("locale/#{Gitlab::I18n.locale}/app.js")
+ end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 7bd34df5c95..1ee8911bb1a 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -108,6 +108,34 @@ module ApplicationSettingsHelper
options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues)
end
+ def circuitbreaker_failure_count_help_text
+ health_link = link_to(s_('AdminHealthPageLink|health page'), admin_health_check_path)
+ api_link = link_to(s_('CircuitBreakerApiLink|circuitbreaker api'), help_page_path("api/repository_storage_health"))
+ message = _("The number of failures of after which GitLab will completely "\
+ "prevent access to the storage. The number of failures can be "\
+ "reset in the admin interface: %{link_to_health_page} or using "\
+ "the %{api_documentation_link}.")
+ message = message % { link_to_health_page: health_link, api_documentation_link: api_link }
+
+ message.html_safe
+ end
+
+ def circuitbreaker_failure_wait_time_help_text
+ _("When access to a storage fails. GitLab will prevent access to the "\
+ "storage for the time specified here. This allows the filesystem to "\
+ "recover. Repositories on failing shards are temporarly unavailable")
+ end
+
+ def circuitbreaker_failure_reset_time_help_text
+ _("The time in seconds GitLab will keep failure information. When no "\
+ "failures occur during this time, information about the mount is reset.")
+ end
+
+ def circuitbreaker_storage_timeout_help_text
+ _("The time in seconds GitLab will try to access storage. After this time a "\
+ "timeout error will be raised.")
+ end
+
def visible_attributes
[
:admin_notification_email,
@@ -116,6 +144,10 @@ module ApplicationSettingsHelper
:akismet_api_key,
:akismet_enabled,
:auto_devops_enabled,
+ :circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_failure_wait_time,
+ :circuitbreaker_storage_timeout,
:clientside_sentry_dsn,
:clientside_sentry_enabled,
:container_registry_token_expire_delay,
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 7713fb0b9f8..baa2d6e375e 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -314,20 +314,12 @@ module IssuablesHelper
@issuable_templates ||=
case issuable
when Issue
- issue_template_names
+ ref_project.repository.issue_template_names
when MergeRequest
- merge_request_template_names
+ ref_project.repository.merge_request_template_names
end
end
- def merge_request_template_names
- @merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project)
- end
-
- def issue_template_names
- @issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project)
- end
-
def selected_template(issuable)
params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] }
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 0d7347ed30d..8e822ed0ea2 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -36,7 +36,8 @@ module PreferencesHelper
def project_view_choices
[
['Files and Readme (default)', :files],
- ['Activity', :activity]
+ ['Activity', :activity],
+ ['Readme', :readme]
]
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 1b542ed2a96..b05eb93b465 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -42,6 +42,17 @@ module SortingHelper
options
end
+ def groups_sort_options_hash
+ options = {
+ sort_value_recently_created => sort_title_recently_created,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_oldest_updated => sort_title_oldest_updated
+ }
+
+ options
+ end
+
def member_sort_options_hash
{
sort_value_access_level_asc => sort_title_access_level_asc,
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index c0cc60d5ebf..d3b8debb0fd 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -33,6 +33,8 @@ class ApplicationSetting < ActiveRecord::Base
attr_accessor :domain_whitelist_raw, :domain_blacklist_raw
+ default_value_for :id, 1
+
validates :uuid, presence: true
validates :session_expire_delay,
@@ -151,6 +153,13 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
numericality: { greater_than_or_equal_to: 0 }
+ validates :circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_wait_time,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_storage_timeout,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 954d4e4d779..ad0bc2e2ead 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -156,7 +156,9 @@ class Blob < SimpleDelegator
end
def file_type
- Gitlab::FileDetector.type_of(path)
+ name = File.basename(path)
+
+ Gitlab::FileDetector.type_of(path) || Gitlab::FileDetector.type_of(name)
end
def video?
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
new file mode 100644
index 00000000000..01957da0bf3
--- /dev/null
+++ b/app/models/concerns/group_descendant.rb
@@ -0,0 +1,56 @@
+module GroupDescendant
+ # Returns the hierarchy of a project or group in the from of a hash upto a
+ # given top.
+ #
+ # > project.hierarchy
+ # => { parent_group => { child_group => project } }
+ def hierarchy(hierarchy_top = nil, preloaded = nil)
+ preloaded ||= ancestors_upto(hierarchy_top)
+ expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
+ end
+
+ # Merges all hierarchies of the given groups or projects into an array of
+ # hashes. All ancestors need to be loaded into the given `descendants` to avoid
+ # queries down the line.
+ #
+ # > GroupDescendant.merge_hierarchy([project, child_group, child_group2, parent])
+ # => { parent => [{ child_group => project}, child_group2] }
+ def self.build_hierarchy(descendants, hierarchy_top = nil)
+ descendants = Array.wrap(descendants).uniq
+ return [] if descendants.empty?
+
+ unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
+ raise ArgumentError.new('element is not a hierarchy')
+ end
+
+ all_hierarchies = descendants.map do |descendant|
+ descendant.hierarchy(hierarchy_top, descendants)
+ end
+
+ Gitlab::Utils::MergeHash.merge(all_hierarchies)
+ end
+
+ private
+
+ def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded)
+ parent = hierarchy_top if hierarchy_top && child.parent_id == hierarchy_top.id
+ parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
+
+ if parent.nil? && !child.parent_id.nil?
+ raise ArgumentError.new('parent was not preloaded')
+ end
+
+ if parent.nil? && hierarchy_top.present?
+ raise ArgumentError.new('specified top is not part of the tree')
+ end
+
+ if parent && parent != hierarchy_top
+ expand_hierarchy_for_child(parent,
+ { parent => hierarchy },
+ hierarchy_top,
+ preloaded)
+ else
+ hierarchy
+ end
+ end
+end
diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb
new file mode 100644
index 00000000000..dcb3b2b5ff3
--- /dev/null
+++ b/app/models/concerns/loaded_in_group_list.rb
@@ -0,0 +1,72 @@
+module LoadedInGroupList
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def with_counts(archived:)
+ selects_including_counts = [
+ 'namespaces.*',
+ "(#{project_count_sql(archived).to_sql}) AS preloaded_project_count",
+ "(#{member_count_sql.to_sql}) AS preloaded_member_count",
+ "(#{subgroup_count_sql.to_sql}) AS preloaded_subgroup_count"
+ ]
+
+ select(selects_including_counts)
+ end
+
+ def with_selects_for_list(archived: nil)
+ with_route.with_counts(archived: archived)
+ end
+
+ private
+
+ def project_count_sql(archived = nil)
+ projects = Project.arel_table
+ namespaces = Namespace.arel_table
+
+ base_count = projects.project(Arel.star.count.as('preloaded_project_count'))
+ .where(projects[:namespace_id].eq(namespaces[:id]))
+ if archived == 'only'
+ base_count.where(projects[:archived].eq(true))
+ elsif Gitlab::Utils.to_boolean(archived)
+ base_count
+ else
+ base_count.where(projects[:archived].not_eq(true))
+ end
+ end
+
+ def subgroup_count_sql
+ namespaces = Namespace.arel_table
+ children = namespaces.alias('children')
+
+ namespaces.project(Arel.star.count.as('preloaded_subgroup_count'))
+ .from(children)
+ .where(children[:parent_id].eq(namespaces[:id]))
+ end
+
+ def member_count_sql
+ members = Member.arel_table
+ namespaces = Namespace.arel_table
+
+ members.project(Arel.star.count.as('preloaded_member_count'))
+ .where(members[:source_type].eq(Namespace.name))
+ .where(members[:source_id].eq(namespaces[:id]))
+ .where(members[:requested_at].eq(nil))
+ end
+ end
+
+ def children_count
+ @children_count ||= project_count + subgroup_count
+ end
+
+ def project_count
+ @project_count ||= try(:preloaded_project_count) || projects.non_archived.count
+ end
+
+ def subgroup_count
+ @subgroup_count ||= try(:preloaded_subgroup_count) || children.count
+ end
+
+ def member_count
+ @member_count ||= try(:preloaded_member_count) || users.count
+ end
+end
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index 5ab5c80a2f5..b3020484738 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -7,6 +7,8 @@ module Storage
raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry')
end
+ expires_full_path_cache
+
# Move the namespace directory in all storage paths used by member projects
repository_storage_paths.each do |repository_storage_path|
# Ensure old directory exists before moving it
diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb
index b517ddaebd7..9f403d96ed5 100644
--- a/app/models/concerns/time_trackable.rb
+++ b/app/models/concerns/time_trackable.rb
@@ -9,7 +9,7 @@ module TimeTrackable
extend ActiveSupport::Concern
included do
- attr_reader :time_spent, :time_spent_user
+ attr_reader :time_spent, :time_spent_user, :spent_at
alias_method :time_spent?, :time_spent
@@ -24,6 +24,7 @@ module TimeTrackable
def spend_time(options)
@time_spent = options[:duration]
@time_spent_user = options[:user]
+ @spent_at = options[:spent_at]
@original_total_time_spent = nil
return if @time_spent == 0
@@ -55,7 +56,11 @@ module TimeTrackable
end
def add_or_subtract_spent_time
- timelogs.new(time_spent: time_spent, user: @time_spent_user)
+ timelogs.new(
+ time_spent: time_spent,
+ user: @time_spent_user,
+ spent_at: @spent_at
+ )
end
def check_negative_time_spent
diff --git a/app/models/gcp/cluster.rb b/app/models/gcp/cluster.rb
index 18bd6a6dcb4..162a690c0e3 100644
--- a/app/models/gcp/cluster.rb
+++ b/app/models/gcp/cluster.rb
@@ -7,6 +7,9 @@ module Gcp
belongs_to :user
belongs_to :service
+ scope :enabled, -> { where(enabled: true) }
+ scope :disabled, -> { where(enabled: false) }
+
default_value_for :gcp_cluster_zone, 'us-central1-a'
default_value_for :gcp_cluster_size, 3
default_value_for :gcp_machine_type, 'n1-standard-4'
diff --git a/app/models/group.rb b/app/models/group.rb
index e746e4a12c9..07fb62bb249 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -6,6 +6,8 @@ class Group < Namespace
include Avatarable
include Referable
include SelectForProjectAuthorization
+ include LoadedInGroupList
+ include GroupDescendant
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 972a35dde4d..c3fae16d109 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -396,7 +396,7 @@ class MergeRequest < ActiveRecord::Base
end
def merge_ongoing?
- !!merge_jid && !merged?
+ !!merge_jid && !merged? && Gitlab::SidekiqStatus.running?(merge_jid)
end
def closed_without_fork?
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 4672881e220..0601a61a926 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base
.base_and_ancestors
end
+ # returns all ancestors upto but excluding the the given namespace
+ # when no namespace is given, all ancestors upto the top are returned
+ def ancestors_upto(top = nil)
+ Gitlab::GroupHierarchy.new(self.class.where(id: id))
+ .ancestors(upto: top)
+ end
+
def self_and_ancestors
return self.class.where(id: id) unless parent_id
diff --git a/app/models/note.rb b/app/models/note.rb
index ceded9f2aef..8939e590ef1 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -169,7 +169,7 @@ class Note < ActiveRecord::Base
end
def cross_reference?
- system? && SystemNoteService.cross_reference?(note)
+ system? && matches_cross_reference_regex?
end
def diff_note?
diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb
index b85f5dbaf2e..f89e60ad9f4 100644
--- a/app/models/oauth_access_token.rb
+++ b/app/models/oauth_access_token.rb
@@ -1,4 +1,6 @@
class OauthAccessToken < Doorkeeper::AccessToken
belongs_to :resource_owner, class_name: 'User'
belongs_to :application, class_name: 'Doorkeeper::Application'
+
+ alias_method :user, :resource_owner
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 57e91ab3b88..4689b588906 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -17,6 +17,7 @@ class Project < ActiveRecord::Base
include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
include Routable
+ include GroupDescendant
extend Gitlab::ConfigHelper
extend Gitlab::CurrentSettings
@@ -81,6 +82,8 @@ class Project < ActiveRecord::Base
belongs_to :creator, class_name: 'User'
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
belongs_to :namespace
+ alias_method :parent, :namespace
+ alias_attribute :parent_id, :namespace_id
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit
@@ -479,6 +482,13 @@ class Project < ActiveRecord::Base
end
end
+ # returns all ancestor-groups upto but excluding the given namespace
+ # when no namespace is given, all ancestors upto the top are returned
+ def ancestors_upto(top = nil)
+ Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
+ .base_and_ancestors(upto: top)
+ end
+
def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
@@ -1262,7 +1272,7 @@ class Project < ActiveRecord::Base
# self.forked_from_project will be nil before the project is saved, so
# we need to go through the relation
- original_project = forked_project_link.forked_from_project
+ original_project = forked_project_link&.forked_from_project
return true unless original_project
level <= original_project.visibility_level
@@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base
map.public_path_for_source_path(path)
end
- def parent
- namespace
- end
-
def parent_changed?
namespace_id_changed?
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index d725c65081d..4324ea46aac 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -34,7 +34,8 @@ class Repository
CACHED_METHODS = %i(size commit_count rendered_readme contribution_guide
changelog license_blob license_key gitignore koding_yml
gitlab_ci_yml branch_names tag_names branch_count
- tag_count avatar exists? empty? root_ref has_visible_content?).freeze
+ tag_count avatar exists? empty? root_ref has_visible_content?
+ issue_template_names merge_request_template_names).freeze
# Methods that use cache_method but only memoize the value
MEMOIZED_CACHED_METHODS = %i(license empty_repo?).freeze
@@ -50,7 +51,9 @@ class Repository
gitignore: :gitignore,
koding: :koding_yml,
gitlab_ci: :gitlab_ci_yml,
- avatar: :avatar
+ avatar: :avatar,
+ issue_template: :issue_template_names,
+ merge_request_template: :merge_request_template_names
}.freeze
# Wraps around the given method and caches its output in Redis and an instance
@@ -465,9 +468,7 @@ class Repository
end
def blob_at(sha, path)
- unless Gitlab::Git.blank_ref?(sha)
- Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project)
- end
+ Blob.decorate(raw_repository.blob_at(sha, path), project)
rescue Gitlab::Git::Repository::NoRepository
nil
end
@@ -535,6 +536,16 @@ class Repository
end
cache_method :avatar
+ def issue_template_names
+ Gitlab::Template::IssueTemplate.dropdown_names(project)
+ end
+ cache_method :issue_template_names, fallback: []
+
+ def merge_request_template_names
+ Gitlab::Template::MergeRequestTemplate.dropdown_names(project)
+ end
+ cache_method :merge_request_template_names, fallback: []
+
def readme
if readme = tree(:head)&.readme
ReadmeBlob.new(readme, self)
@@ -901,14 +912,6 @@ class Repository
end
end
- def resolve_conflicts(user, branch_name, params)
- with_branch(user, branch_name) do
- committer = user_to_committer(user)
-
- create_commit(params.merge(author: committer, committer: committer))
- end
- end
-
def merged_to_root_ref?(branch_name)
branch_commit = commit(branch_name)
root_ref_commit = commit(root_ref)
@@ -1114,7 +1117,7 @@ class Repository
def last_commit_id_for_path_by_shelling_out(sha, path)
args = %W(rev-list --max-count=1 #{sha} -- #{path})
- run_git(args).first.strip
+ raw_repository.run_git_with_timeout(args, Gitlab::Git::Popen::FAST_GIT_PROCESS_TIMEOUT).first.strip
end
def repository_storage_path
diff --git a/app/models/user.rb b/app/models/user.rb
index 533a776bc65..9459b6d4fa4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -182,13 +182,8 @@ class User < ActiveRecord::Base
enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos]
# User's Project preference
- #
- # Note: When adding an option, it MUST go on the end of the hash with a
- # number higher than the current max. We cannot move options and/or change
- # their numbers.
- #
- # We skip 0 because this was used by an option that has since been removed.
- enum project_view: { activity: 1, files: 2 }
+ # Note: When adding an option, it MUST go on the end of the array.
+ enum project_view: [:readme, :activity, :files]
alias_attribute :private_token, :authentication_token
diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb
index 4e6c15f673b..8cade280b0c 100644
--- a/app/serializers/base_serializer.rb
+++ b/app/serializers/base_serializer.rb
@@ -1,6 +1,9 @@
class BaseSerializer
- def initialize(parameters = {})
- @request = EntityRequest.new(parameters)
+ attr_reader :params
+
+ def initialize(params = {})
+ @params = params
+ @request = EntityRequest.new(params)
end
def represent(resource, opts = {}, entity_class = nil)
diff --git a/app/serializers/concerns/with_pagination.rb b/app/serializers/concerns/with_pagination.rb
new file mode 100644
index 00000000000..d29e22d6740
--- /dev/null
+++ b/app/serializers/concerns/with_pagination.rb
@@ -0,0 +1,22 @@
+module WithPagination
+ attr_accessor :paginator
+
+ def with_pagination(request, response)
+ tap { self.paginator = Gitlab::Serializer::Pagination.new(request, response) }
+ end
+
+ def paginated?
+ paginator.present?
+ end
+
+ # super is `BaseSerializer#represent` here.
+ #
+ # we shouldn't try to paginate single resources
+ def represent(resource, opts = {})
+ if paginated? && resource.respond_to?(:page)
+ super(@paginator.paginate(resource), opts)
+ else
+ super(resource, opts)
+ end
+ end
+end
diff --git a/app/serializers/container_tag_entity.rb b/app/serializers/container_tag_entity.rb
index ec1fc349586..8f1488e6cbb 100644
--- a/app/serializers/container_tag_entity.rb
+++ b/app/serializers/container_tag_entity.rb
@@ -1,10 +1,10 @@
class ContainerTagEntity < Grape::Entity
include RequestAwareEntity
- expose :name, :location, :revision, :total_size, :created_at
+ expose :name, :location, :revision, :short_revision, :total_size, :created_at
expose :destroy_path, if: -> (*) { can_destroy? } do |tag|
- project_registry_repository_tag_path(project, tag.repository, tag.name, format: :json)
+ project_registry_repository_tag_path(project, tag.repository, tag.name)
end
private
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 88842a9aa75..84722f33f59 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -1,4 +1,6 @@
class EnvironmentSerializer < BaseSerializer
+ include WithPagination
+
Item = Struct.new(:name, :size, :latest)
entity EnvironmentEntity
@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
tap { @itemize = true }
end
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
-
def itemized?
@itemize
end
- def paginated?
- @paginator.present?
- end
-
def represent(resource, opts = {})
if itemized?
itemize(resource).map do |item|
@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) }
end
else
- resource = @paginator.paginate(resource) if paginated?
-
super(resource, opts)
end
end
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
new file mode 100644
index 00000000000..37240bfb0b1
--- /dev/null
+++ b/app/serializers/group_child_entity.rb
@@ -0,0 +1,77 @@
+class GroupChildEntity < Grape::Entity
+ include ActionView::Helpers::NumberHelper
+ include RequestAwareEntity
+
+ expose :id, :name, :description, :visibility, :full_name,
+ :created_at, :updated_at, :avatar_url
+
+ expose :type do |instance|
+ type
+ end
+
+ expose :can_edit do |instance|
+ return false unless request.respond_to?(:current_user)
+
+ can?(request.current_user, "admin_#{type}", instance)
+ end
+
+ expose :edit_path do |instance|
+ # We know `type` will be one either `project` or `group`.
+ # The `edit_polymorphic_path` helper would try to call the path helper
+ # with a plural: `edit_groups_path(instance)` or `edit_projects_path(instance)`
+ # while our methods are `edit_group_path` or `edit_group_path`
+ public_send("edit_#{type}_path", instance) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ expose :relative_path do |instance|
+ polymorphic_path(instance)
+ end
+
+ expose :permission do |instance|
+ membership&.human_access
+ end
+
+ # Project only attributes
+ expose :star_count,
+ if: lambda { |_instance, _options| project? }
+
+ # Group only attributes
+ expose :children_count, :parent_id, :project_count, :subgroup_count,
+ unless: lambda { |_instance, _options| project? }
+
+ expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance|
+ leave_group_members_path(instance)
+ end
+
+ expose :can_leave, unless: lambda { |_instance, _options| project? } do |instance|
+ if membership
+ can?(request.current_user, :destroy_group_member, membership)
+ else
+ false
+ end
+ end
+
+ expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+ number_with_delimiter(instance.project_count)
+ end
+
+ expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+ number_with_delimiter(instance.member_count)
+ end
+
+ private
+
+ def membership
+ return unless request.current_user
+
+ @membership ||= request.current_user.members.find_by(source: object)
+ end
+
+ def project?
+ object.is_a?(Project)
+ end
+
+ def type
+ object.class.name.downcase
+ end
+end
diff --git a/app/serializers/group_child_serializer.rb b/app/serializers/group_child_serializer.rb
new file mode 100644
index 00000000000..2baef0a5703
--- /dev/null
+++ b/app/serializers/group_child_serializer.rb
@@ -0,0 +1,51 @@
+class GroupChildSerializer < BaseSerializer
+ include WithPagination
+
+ attr_reader :hierarchy_root, :should_expand_hierarchy
+
+ entity GroupChildEntity
+
+ def expand_hierarchy(hierarchy_root = nil)
+ @hierarchy_root = hierarchy_root
+ @should_expand_hierarchy = true
+
+ self
+ end
+
+ def represent(resource, opts = {}, entity_class = nil)
+ if should_expand_hierarchy
+ paginator.paginate(resource) if paginated?
+ represent_hierarchies(resource, opts)
+ else
+ super(resource, opts)
+ end
+ end
+
+ protected
+
+ def represent_hierarchies(children, opts)
+ if children.is_a?(GroupDescendant)
+ represent_hierarchy(children.hierarchy(hierarchy_root), opts).first
+ else
+ hierarchies = GroupDescendant.build_hierarchy(children, hierarchy_root)
+ # When an array was passed, we always want to represent an array.
+ # Even if the hierarchy only contains one element
+ represent_hierarchy(Array.wrap(hierarchies), opts)
+ end
+ end
+
+ def represent_hierarchy(hierarchy, opts)
+ serializer = self.class.new(params)
+
+ if hierarchy.is_a?(Hash)
+ hierarchy.map do |parent, children|
+ serializer.represent(parent, opts)
+ .merge(children: Array.wrap(serializer.represent_hierarchy(children, opts)))
+ end
+ elsif hierarchy.is_a?(Array)
+ hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) }
+ else
+ serializer.represent(hierarchy, opts)
+ end
+ end
+end
diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb
index 26e8566828b..8cf7eb63bcf 100644
--- a/app/serializers/group_serializer.rb
+++ b/app/serializers/group_serializer.rb
@@ -1,19 +1,5 @@
class GroupSerializer < BaseSerializer
- entity GroupEntity
-
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
+ include WithPagination
- def paginated?
- @paginator.present?
- end
-
- def represent(resource, opts = {})
- if paginated?
- super(@paginator.paginate(resource), opts)
- else
- super(resource, opts)
- end
- end
+ entity GroupEntity
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 661bf17983c..7181f8a6b04 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,16 +1,10 @@
class PipelineSerializer < BaseSerializer
+ include WithPagination
+
InvalidResourceError = Class.new(StandardError)
entity PipelineDetailsEntity
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
-
- def paginated?
- @paginator.present?
- end
-
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 9a636346899..f40cd2b06c8 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -56,11 +56,22 @@ module Auth
def process_scope(scope)
type, name, actions = scope.split(':', 3)
actions = actions.split(',')
- path = ContainerRegistry::Path.new(name)
- return unless type == 'repository'
+ case type
+ when 'registry'
+ process_registry_access(type, name, actions)
+ when 'repository'
+ path = ContainerRegistry::Path.new(name)
+ process_repository_access(type, path, actions)
+ end
+ end
+
+ def process_registry_access(type, name, actions)
+ return unless current_user&.admin?
+ return unless name == 'catalog'
+ return unless actions == ['*']
- process_repository_access(type, path, actions)
+ { type: type, name: name, actions: ['*'] }
end
def process_repository_access(type, path, actions)
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index d67b9f5cc56..c552193e66b 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -28,6 +28,8 @@ module Ci
attributes.push([:user, current_user])
+ build.retried = true
+
Ci::Build.transaction do
# mark all other builds of that name as retried
build.pipeline.builds.latest
diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb
index 727768b1a39..6805b2f7d1c 100644
--- a/app/services/merge_requests/add_todo_when_build_fails_service.rb
+++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb
@@ -3,7 +3,7 @@ module MergeRequests
# Adds a todo to the parent merge_request when a CI build fails
#
def execute(commit_status)
- return if commit_status.allow_failure?
+ return if commit_status.allow_failure? || commit_status.retried?
commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb
index 9835606812c..0f677a996f7 100644
--- a/app/services/merge_requests/conflicts/list_service.rb
+++ b/app/services/merge_requests/conflicts/list_service.rb
@@ -23,13 +23,13 @@ module MergeRequests
# when there are no conflict files.
conflicts.files.each(&:lines)
@conflicts_can_be_resolved_in_ui = conflicts.files.length > 0
- rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing
+ rescue Rugged::OdbError, Gitlab::Git::Conflict::Parser::UnresolvableError, Gitlab::Git::Conflict::Resolver::ConflictSideMissing
@conflicts_can_be_resolved_in_ui = false
end
end
def conflicts
- @conflicts ||= Gitlab::Conflict::FileCollection.read_only(merge_request)
+ @conflicts ||= Gitlab::Conflict::FileCollection.new(merge_request)
end
end
end
diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb
index 6b6e231f4f9..27cafd2d7d9 100644
--- a/app/services/merge_requests/conflicts/resolve_service.rb
+++ b/app/services/merge_requests/conflicts/resolve_service.rb
@@ -1,54 +1,10 @@
module MergeRequests
module Conflicts
class ResolveService < MergeRequests::Conflicts::BaseService
- MissingFiles = Class.new(Gitlab::Conflict::ResolutionError)
-
def execute(current_user, params)
- rugged = merge_request.source_project.repository.rugged
-
- Gitlab::Conflict::FileCollection.for_resolution(merge_request) do |conflicts_for_resolution|
- merge_index = conflicts_for_resolution.merge_index
-
- params[:files].each do |file_params|
- conflict_file = conflicts_for_resolution.file_for_path(file_params[:old_path], file_params[:new_path])
-
- write_resolved_file_to_index(merge_index, rugged, conflict_file, file_params)
- end
-
- unless merge_index.conflicts.empty?
- missing_files = merge_index.conflicts.map { |file| file[:ours][:path] }
-
- raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}"
- end
-
- commit_params = {
- message: params[:commit_message] || conflicts_for_resolution.default_commit_message,
- parents: [conflicts_for_resolution.our_commit, conflicts_for_resolution.their_commit].map(&:oid),
- tree: merge_index.write_tree(rugged)
- }
-
- conflicts_for_resolution
- .project
- .repository
- .resolve_conflicts(current_user, merge_request.source_branch, commit_params)
- end
- end
-
- private
-
- def write_resolved_file_to_index(merge_index, rugged, file, params)
- if params[:sections]
- new_file = file.resolve_lines(params[:sections]).map(&:text).join("\n")
-
- new_file << "\n" if file.our_blob.data.ends_with?("\n")
- elsif params[:content]
- new_file = file.resolve_content(params[:content])
- end
-
- our_path = file.our_path
+ conflicts = Gitlab::Conflict::FileCollection.new(merge_request)
- merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
- merge_index.conflict_remove(our_path)
+ conflicts.resolve(current_user, params[:commit_message], params[:files])
end
end
end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index a110abf8256..8c5821aa870 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -60,13 +60,9 @@ module MergeRequests
def after_merge
MergeRequests::PostMergeService.new(project, current_user).execute(merge_request)
- if params[:should_remove_source_branch].present? || @merge_request.force_remove_source_branch?
- # Verify again that the source branch can be removed, since branch may be protected,
- # or the source branch may have been updated.
- if @merge_request.can_remove_source_branch?(branch_deletion_user)
- DeleteBranchService.new(@merge_request.source_project, branch_deletion_user)
- .execute(merge_request.source_branch)
- end
+ if delete_source_branch?
+ DeleteBranchService.new(@merge_request.source_project, branch_deletion_user)
+ .execute(merge_request.source_branch)
end
end
@@ -78,6 +74,14 @@ module MergeRequests
@merge_request.force_remove_source_branch? ? @merge_request.author : current_user
end
+ # Verify again that the source branch can be removed, since branch may be protected,
+ # or the source branch may have been updated, or the user may not have permission
+ #
+ def delete_source_branch?
+ params.fetch('should_remove_source_branch', @merge_request.force_remove_source_branch?) &&
+ @merge_request.can_remove_source_branch?(branch_deletion_user)
+ end
+
# Logs merge error message and cleans `MergeRequest#merge_jid`.
#
def handle_merge_error(log_message:, save_message_on_model: false)
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 8d5da459882..be3b4b2ba07 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -390,7 +390,7 @@ class NotificationService
end
def relabeled_resource_email(target, labels, current_user, method)
- recipients = labels.flat_map { |l| l.subscribers(target.project) }
+ recipients = labels.flat_map { |l| l.subscribers(target.project) }.uniq
recipients = notifiable_users(
recipients, :subscription,
target: target,
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index abe414d0c05..2b82e5732e4 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -15,8 +15,8 @@ module Projects
refresh_forks_count(@project.forked_from_project)
- @project.forked_project_link.destroy
@project.fork_network_member.destroy
+ @project.forked_project_link.destroy
end
def refresh_forks_count(project)
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index 955d934838b..06ac86cd5a9 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -381,7 +381,7 @@ module QuickActions
end
desc 'Add or substract spent time'
- explanation do |time_spent|
+ explanation do |time_spent, time_spent_date|
if time_spent
if time_spent > 0
verb = 'Adds'
@@ -394,16 +394,20 @@ module QuickActions
"#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
end
end
- params '<1h 30m | -1h 30m>'
+ params '<time(1h30m | -1h30m)> <date(YYYY-MM-DD)>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
- parse_params do |raw_duration|
- Gitlab::TimeTrackingFormatter.parse(raw_duration)
+ parse_params do |raw_time_date|
+ Gitlab::QuickActions::SpendTimeAndDateSeparator.new(raw_time_date).execute
end
- command :spend do |time_spent|
+ command :spend do |time_spent, time_spent_date|
if time_spent
- @updates[:spend_time] = { duration: time_spent, user: current_user }
+ @updates[:spend_time] = {
+ duration: time_spent,
+ user: current_user,
+ spent_at: time_spent_date
+ }
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index a52dce6cb4b..69bd19c1977 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -162,7 +162,6 @@ module SystemNoteService
# "changed time estimate to 3d 5h"
#
# Returns the created Note object
-
def change_time_estimate(noteable, project, author)
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
body = if noteable.time_estimate == 0
@@ -188,16 +187,17 @@ module SystemNoteService
# "added 2h 30m of time spent"
#
# Returns the created Note object
-
def change_time_spent(noteable, project, author)
time_spent = noteable.time_spent
if time_spent == :reset
body = "removed time spent"
else
+ spent_at = noteable.spent_at
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
action = time_spent > 0 ? 'added' : 'subtracted'
body = "#{action} #{parsed_time} of time spent"
+ body << " at #{spent_at}" if spent_at
end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
@@ -451,10 +451,6 @@ module SystemNoteService
end
end
- def cross_reference?(note_text)
- note_text =~ /\A#{cross_reference_note_prefix}/i
- end
-
# Check if a cross-reference is disallowed
#
# This method prevents adding a "mentioned in !1" note on every single commit
@@ -484,7 +480,6 @@ module SystemNoteService
# mentioner - Mentionable object
#
# Returns Boolean
-
def cross_reference_exists?(noteable, mentioner)
# Initial scope should be system notes of this noteable type
notes = Note.system.where(noteable_type: noteable.class)
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index dbaed1d09fb..2b23af9212e 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -530,6 +530,32 @@
= succeed "." do
= link_to "repository storages documentation", help_page_path("administration/repository_storages")
+ %fieldset
+ %legend Git Storage Circuitbreaker settings
+ .form-group
+ = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_count_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_wait_time, _('Seconds to wait after a storage failure'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_wait_time, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_wait_time_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_reset_time_help_text
+ .form-group
+ = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
+ .help-block
+ = circuitbreaker_storage_timeout_help_text
%fieldset
%legend Repository Checks
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 7981daa0705..cebdbab4e74 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,13 +1,13 @@
.top-area
%ul.nav-links
= nav_link(page: dashboard_groups_path) do
- = link_to dashboard_groups_path, title: 'Your groups' do
+ = link_to dashboard_groups_path, title: _("Your groups") do
Your groups
= nav_link(page: explore_groups_path) do
- = link_to explore_groups_path, title: 'Explore public groups' do
+ = link_to explore_groups_path, title: _("Explore public groups") do
Explore public groups
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
- = link_to "New group", new_group_path, class: "btn btn-new"
+ = link_to _("New group"), new_group_path, class: "btn btn-new"
diff --git a/app/views/dashboard/groups/_empty_state.html.haml b/app/views/dashboard/groups/_empty_state.html.haml
deleted file mode 100644
index f5222fe631e..00000000000
--- a/app/views/dashboard/groups/_empty_state.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.groups-empty-state
- = custom_icon("icon_empty_groups")
-
- .text-content
- %h4 A group is a collection of several projects.
- %p If you organize your projects under a group, it works like a folder.
- %p You can manage your group member’s permissions and access to each project in the group.
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 168e6272d8e..601b6a8b1a7 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,9 +1,2 @@
.js-groups-list-holder
- #dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
- .groups-list-loading
- = icon('spinner spin', 'v-show' => 'isLoading')
- %template{ 'v-if' => '!isLoading && isEmpty' }
- %div{ 'v-cloak' => true }
- = render 'empty_state'
- %template{ 'v-else-if' => '!isLoading && !isEmpty' }
- %groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
+ #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 1cea8182733..25bf08c6c12 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -6,7 +6,7 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
-- if @groups.empty?
- = render 'empty_state'
+- if params[:filter].blank? && @groups.empty?
+ = render 'shared/groups/empty_state'
- else
= render 'groups'
diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml
index 253cd336882..079d9083dff 100644
--- a/app/views/discussions/_parallel_diff_discussion.html.haml
+++ b/app/views/discussions/_parallel_diff_discussion.html.haml
@@ -4,7 +4,7 @@
%td.notes_line.old
%td.notes_content.parallel.old
.content{ class: ('hide' unless discussions_left.any?(&:expanded?)) }
- = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old'
+ = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old', locals: { disable_collapse_class: true }
- else
%td.notes_line.old= ("")
%td.notes_content.parallel.old
@@ -14,7 +14,7 @@
%td.notes_line.new
%td.notes_content.parallel.new
.content{ class: ('hide' unless discussions_right.any?(&:expanded?)) }
- = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new'
+ = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new', locals: { disable_collapse_class: true }
- else
%td.notes_line.new= ("")
%td.notes_content.parallel.new
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index 794c6d1d170..91149498248 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,6 +1,2 @@
.js-groups-list-holder
- %ul.content-list
- - @groups.each do |group|
- = render 'shared/groups/group', group: group
-
- = paginate @groups, theme: 'gitlab'
+ #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index 2651ef37e67..86abdf547cc 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,6 +2,9 @@
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
- if current_user
= render 'dashboard/groups_head'
- else
@@ -17,7 +20,7 @@
%p Below you will find all the groups that are public.
%p You can easily contribute to them by requesting to join these groups.
-- if @groups.present?
- = render 'groups'
-- else
+- if params[:filter].blank? && @groups.empty?
.nothing-here-block No public groups
+- else
+ = render 'groups'
diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml
new file mode 100644
index 00000000000..3afb6b2f849
--- /dev/null
+++ b/app/views/groups/_children.html.haml
@@ -0,0 +1,5 @@
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
+.js-groups-list-holder
+ #js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/groups/_show_nav.html.haml b/app/views/groups/_show_nav.html.haml
deleted file mode 100644
index 35b75bc0923..00000000000
--- a/app/views/groups/_show_nav.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-%ul.nav-links
- = nav_link(page: group_path(@group)) do
- = link_to group_path(@group) do
- Projects
- - if Group.supports_nested_groups?
- = nav_link(page: subgroups_group_path(@group)) do
- = link_to subgroups_group_path(@group) do
- Subgroups
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 3ca63f9c3e0..7f9486d08d9 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,5 +1,6 @@
- @no_container = true
- breadcrumb_title "Details"
+- can_create_subgroups = can?(current_user, :create_subgroup, @group)
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
@@ -7,13 +8,38 @@
= render 'groups/home_panel'
.groups-header{ class: container_class }
- .top-area
- = render 'groups/show_nav'
- .nav-controls
- = render 'shared/projects/search_form'
- = render 'shared/projects/dropdown'
+ .group-nav-container
+ .nav-controls.clearfix
+ = render "shared/groups/search_form"
+ = render "shared/groups/dropdown", show_archive_options: true
- if can? current_user, :create_projects, @group
- = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do
- New Project
+ - new_project_label = _("New project")
+ - new_subgroup_label = _("New subgroup")
+ - if can_create_subgroups
+ .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
+ %input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } }
+ %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } }
+ = icon("caret-down", class: "dropdown-btn-icon")
+ %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
+ %li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_project_label
+ %span= s_("GroupsTree|Create a project in this group.")
+ %li.divider.droplap-item-ignore
+ %li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_subgroup_label
+ %span= s_("GroupsTree|Create a subgroup in this group.")
+ - else
+ = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
- = render "projects", projects: @projects
+ - if params[:filter].blank? && !@has_children
+ = render "shared/groups/empty_state"
+ - else
+ = render "children", children: @children, group: @group
diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml
deleted file mode 100644
index 869b3b243c6..00000000000
--- a/app/views/groups/subgroups.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- breadcrumb_title "Details"
-- @no_container = true
-
-= render 'groups/home_panel'
-
-.groups-header{ class: container_class }
- .top-area
- = render 'groups/show_nav'
- .nav-controls
- = form_tag request.path, method: :get do |f|
- = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false
- - if can?(current_user, :create_subgroup, @group)
- = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do
- New Subgroup
-
- - if @nested_groups.present?
- %ul.content-list
- = render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false }
- - else
- .nothing-here-block
- There are no subgroups to show.
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index b18b3dd5766..29b23ae2e52 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -17,10 +17,6 @@
%th Global Shortcuts
%tr
%td.shortcut
- .key n
- %td Main Navigation
- %tr
- %td.shortcut
.key s
%td Focus Search
%tr
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index f1b32274664..1597621fa78 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -37,7 +37,7 @@
- if content_for?(:library_javascripts)
= yield :library_javascripts
- = javascript_include_tag asset_path("locale/#{I18n.locale.to_s || I18n.default_locale.to_s}/app.js") unless I18n.locale == :en
+ = javascript_include_tag locale_path unless I18n.locale == :en
= webpack_bundle_tag "webpack_runtime"
= webpack_bundle_tag "common"
= webpack_bundle_tag "main"
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 759d6ff68ea..f82207559a3 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -272,6 +272,11 @@
= sprite_icon('users')
%span.nav-item-name
Members
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_settings_members_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Members') }
= render 'shared/sidebar_toggle_button'
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index b7a60938132..8eb3f2d5192 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -31,7 +31,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
= @pipeline.ref
@@ -42,7 +42,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "Commit icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a{ href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;" }
= @pipeline.short_sha
@@ -60,7 +60,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml
index 3f16885b8e3..574a8f2fa50 100644
--- a/app/views/notify/pipeline_success_email.html.haml
+++ b/app/views/notify/pipeline_success_email.html.haml
@@ -31,7 +31,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
= @pipeline.ref
@@ -42,7 +42,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "Commit icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a{ href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;" }
= @pipeline.short_sha
@@ -60,7 +60,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
new file mode 100644
index 00000000000..44aa9eb3826
--- /dev/null
+++ b/app/views/projects/_readme.html.haml
@@ -0,0 +1,23 @@
+- if (readme = @repository.readme) && readme.rich_viewer
+ %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
+ .js-file-title.file-title
+ = blob_icon readme.mode, readme.name
+ = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
+ %strong
+ = readme.name
+ = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json)
+
+- else
+ .row-content-block.second-block.center
+ %h3.page-title
+ This project does not have a README yet
+ - if can?(current_user, :push_code, @project)
+ %p
+ A
+ %code README
+ file contains information about other files in a repository and is commonly
+ distributed with computer software, forming part of its documentation.
+ %p
+ We recommend you to
+ = link_to "add a README", add_special_file_path(@project, file_name: 'README.md'), class: 'underlined-link'
+ file to the repository and GitLab will render it here instead of this message.
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 4b344b2edb9..7777f55ddd7 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -1,6 +1,6 @@
- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create'
-.file-holder.file.append-bottom-default
+.file-holder-bottom-radius.file-holder.file.append-bottom-default
.js-file-title.file-title.clearfix{ data: { current_action: action } }
.editor-ref
= icon('code-fork')
diff --git a/app/views/projects/clusters/_advanced_settings.html.haml b/app/views/projects/clusters/_advanced_settings.html.haml
new file mode 100644
index 00000000000..6c162481dd8
--- /dev/null
+++ b/app/views/projects/clusters/_advanced_settings.html.haml
@@ -0,0 +1,14 @@
+- if can?(current_user, :admin_cluster, @cluster)
+ .append-bottom-20
+ %label.append-bottom-10
+ = s_('ClusterIntegration|Google Container Engine')
+ %p
+ - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+
+ .well.form-group
+ %label.text-danger
+ = s_('ClusterIntegration|Remove cluster integration')
+ %p
+ = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
+ = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
diff --git a/app/views/projects/clusters/login.html.haml b/app/views/projects/clusters/login.html.haml
index ae132672b7e..fde030b500b 100644
--- a/app/views/projects/clusters/login.html.haml
+++ b/app/views/projects/clusters/login.html.haml
@@ -10,7 +10,7 @@
.col-sm-8.col-sm-offset-4.signin-with-google
- if @authorize_url
= link_to @authorize_url do
- = image_tag('auth_buttons/signin_with_google.png')
+ = image_tag('auth_buttons/signin_with_google.png', width: '191px')
- else
- link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer')
= s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link }
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
index aee6f904a62..ff76abc3553 100644
--- a/app/views/projects/clusters/show.html.haml
+++ b/app/views/projects/clusters/show.html.haml
@@ -1,24 +1,37 @@
+- @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title "Cluster"
- page_title _("Cluster")
+- expanded = Rails.env.test?
+
- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation?
-.row.prepend-top-default.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
+.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
toggle_status: @cluster.enabled? ? 'true': 'false',
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason } }
- .col-sm-4
- = render 'sidebar'
- .col-sm-8
- %label.append-bottom-10{ for: 'enable-cluster-integration' }
- = s_('ClusterIntegration|Enable cluster integration')
- %p
- - if @cluster.enabled?
- - if can?(current_user, :update_cluster, @cluster)
- = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
+
+ %section.settings
+ %h4= s_('ClusterIntegration|Enable cluster integration')
+ .settings-content.expanded
+
+ .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
+ %p.js-error-reason
+
+ .hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
+
+ .hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
+
+ %p
+ - if @cluster.enabled?
+ - if can?(current_user, :update_cluster, @cluster)
+ = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
+ - else
+ = s_('ClusterIntegration|Cluster integration is enabled for this project.')
- else
- = s_('ClusterIntegration|Cluster integration is enabled for this project.')
- - else
- = s_('ClusterIntegration|Cluster integration is disabled for this project.')
+ = s_('ClusterIntegration|Cluster integration is disabled for this project.')
= form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
= form_errors(@cluster)
@@ -36,35 +49,28 @@
.form-group
= field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success'
- - if can?(current_user, :admin_cluster, @cluster)
- %label.append-bottom-10{ for: 'google-container-engine' }
- = s_('ClusterIntegration|Google Container Engine')
- %p
- - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+ %section.settings#js-cluster-details
+ .settings-header
+ %h4= s_('ClusterIntegration|Cluster details')
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p= s_('ClusterIntegration|See and edit the details for your cluster')
- .hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' }
- = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
- %p.js-error-reason
-
- .hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' }
- = s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
-
- .hidden.js-cluster-success.alert.alert-success.alert-block{ role: 'alert' }
- = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
- .form_group.append-bottom-20
- %label.append-bottom-10{ for: 'cluter-name' }
- = s_('ClusterIntegration|Cluster name')
- .input-group
- %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
- %span.input-group-addon.clipboard-addon
- = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
+ .form_group.append-bottom-20
+ %label.append-bottom-10{ for: 'cluter-name' }
+ = s_('ClusterIntegration|Cluster name')
+ .input-group
+ %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
+ %span.input-group-addon.clipboard-addon
+ = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
- - if can?(current_user, :admin_cluster, @cluster)
- .well.form_group
- %label.text-danger
- = s_('ClusterIntegration|Remove cluster integration')
- %p
- = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
- = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
+ %section.settings#js-cluster-advanced-settings
+ .settings-header
+ %h4= s_('ClusterIntegration|Advanced settings')
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p= s_('ClusterIntegration|Manage Cluster integration on your GitLab project')
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ = render 'advanced_settings'
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 3f3ce10419f..c9956183e12 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -24,10 +24,15 @@
%p
You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected.
+ - if show_auto_devops_callout?(@project)
+ %p
+ - link = link_to(s_('AutoDevOps|Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'))
+ = s_('AutoDevOps|You can activate %{link_to_settings} for this project.').html_safe % { link_to_settings: link }
+ %p
+ = s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+
- if can?(current_user, :push_code, @project)
%div{ class: container_class }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
.prepend-top-20
.empty_wrapper
%h3.page-title-empty
diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index d5c6d329ce4..7da4ffd5e43 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -4,8 +4,10 @@
.sidebar-container
.blocks-container
.block
- %strong
+ %strong.prepend-top-10
= @build.name
+ - if can?(current_user, :update_build, @build) && @build.retryable?
+ = link_to "Retry", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'js-retry-button pull-right btn btn-inverted-secondary btn-retry visible-md-block visible-lg-block', method: :post
%a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
= icon('angle-double-right')
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 6b8dcb3e60b..8da2243adef 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -13,8 +13,6 @@
- if @project.merge_requests.exists?
%div{ class: container_class }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 0934c47a8e2..0a7880ce4cd 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -48,52 +48,54 @@
.tab-pane.import-project-pane{ id: 'import-project-pane', role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
- if import_sources_enabled?
- .project-import
- .form-group.clearfix
- = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
- Import project from
- .col-sm-12.import-buttons
- %div
- - if github_import_enabled?
- = link_to new_import_github_path, class: 'btn import_github' do
- = icon('github', text: 'GitHub')
- %div
- - if bitbucket_import_enabled?
- = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
- = icon('bitbucket', text: 'Bitbucket')
- - unless bitbucket_import_configured?
- = render 'bitbucket_import_modal'
- %div
- - if gitlab_import_enabled?
- = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
- = icon('gitlab', text: 'GitLab.com')
- - unless gitlab_import_configured?
- = render 'gitlab_import_modal'
- %div
- - if google_code_import_enabled?
- = link_to new_import_google_code_path, class: 'btn import_google_code' do
- = icon('google', text: 'Google Code')
- %div
- - if fogbugz_import_enabled?
- = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
- = icon('bug', text: 'Fogbugz')
- %div
- - if gitea_import_enabled?
- = link_to new_import_gitea_url, class: 'btn import_gitea' do
- = custom_icon('go_logo')
- Gitea
- %div
- - if git_import_enabled?
- %button.btn.js-toggle-button.import_git{ type: "button" }
- = icon('git', text: 'Repo by URL')
- - if gitlab_project_import_enabled?
- .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
- = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
- = icon('gitlab', text: 'GitLab export')
- .col-lg-12
- .js-toggle-content.hide
- %hr
- = render "shared/import_form", f: f
+ .project-import.row
+ .col-sm-12
+ .form-group.import-btn-container.clearfix
+ = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
+ Import project from
+ .import-buttons
+ - if gitlab_project_import_enabled?
+ .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
+ = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
+ = icon('gitlab', text: 'GitLab export')
+ %div
+ - if github_import_enabled?
+ = link_to new_import_github_path, class: 'btn import_github' do
+ = icon('github', text: 'GitHub')
+ %div
+ - if bitbucket_import_enabled?
+ = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
+ = icon('bitbucket', text: 'Bitbucket')
+ - unless bitbucket_import_configured?
+ = render 'bitbucket_import_modal'
+ %div
+ - if gitlab_import_enabled?
+ = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
+ = icon('gitlab', text: 'GitLab.com')
+ - unless gitlab_import_configured?
+ = render 'gitlab_import_modal'
+ %div
+ - if google_code_import_enabled?
+ = link_to new_import_google_code_path, class: 'btn import_google_code' do
+ = icon('google', text: 'Google Code')
+ %div
+ - if fogbugz_import_enabled?
+ = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
+ = icon('bug', text: 'Fogbugz')
+ %div
+ - if gitea_import_enabled?
+ = link_to new_import_gitea_url, class: 'btn import_gitea' do
+ = custom_icon('go_logo')
+ Gitea
+ %div
+ - if git_import_enabled?
+ %button.btn.js-toggle-button.import_git{ type: "button" }
+ = icon('git', text: 'Repo by URL')
+ .col-lg-12
+ .js-toggle-content.hide.toggle-import-form
+ %hr
+ = render "shared/import_form", f: f
+ = render 'new_project_fields', f: f, project_name_id: "import-url-name"
.save-project-loader.hide
.center
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index a10a7c23924..f8627a3818b 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -2,8 +2,6 @@
- page_title "Pipelines"
%div{ 'class' => container_class }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
"help-page-path" => help_page_path('ci/quick_start/README'),
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 0cc6674842a..745a6040488 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -12,7 +12,5 @@
= webpack_bundle_tag 'repo'
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- - if show_auto_devops_callout?(@project) && !show_new_repo?
- = render 'shared/auto_devops_callout'
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index 7c633175a06..934d65e8b42 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,15 +1,16 @@
-.user-callout{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
- .bordered-box.landing.content-block
- %button.btn.btn-default.close.js-close-callout{ type: 'button',
- 'aria-label' => 'Dismiss Auto DevOps box' }
- = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
- .svg-container
- = custom_icon('icon_autodevops')
- .user-callout-copy
- %h4= s_('AutoDevOps|Auto DevOps (Beta)')
- %p= s_('AutoDevOps|Auto DevOps can be activated for this project. It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
- %p
- - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
- = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
+.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
+ .banner-graphic
+ = custom_icon('icon_autodevops')
- = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn btn-primary js-close-callout'
+ .prepend-top-10.prepend-left-10.append-bottom-10
+ %h5= s_('AutoDevOps|Auto DevOps (Beta)')
+ %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+ %p
+ - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
+ .prepend-top-10
+ = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn js-close-callout'
+
+ %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button',
+ 'aria-label' => 'Dismiss Auto DevOps box' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index 1f540bdaf93..dfc0f9be321 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -25,7 +25,7 @@
show_any: "true",
project_id: @project&.try(:id),
labels: labels_filter_path(false),
- namespace_path: @project.try(:namespace).try(:full_path),
+ namespace_path: @namespace_path,
project_path: @project.try(:path) },
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
%span.dropdown-toggle-text
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 760370a6984..8e6747ca740 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,18 +1,32 @@
-.dropdown.inline.js-group-filter-dropdown-wrap
+- show_archive_options = local_assigns.fetch(:show_archive_options, false)
+- if @sort.present?
+ - default_sort_by = @sort
+- else
+ - if params[:sort]
+ - default_sort_by = params[:sort]
+ - else
+ - default_sort_by = sort_value_recently_created
+
+.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label
- - if @sort.present?
- = sort_options_hash[@sort]
- - else
- = sort_title_recently_created
+ = sort_options_hash[default_sort_by]
= icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right
- %li
- = link_to filter_groups_path(sort: sort_value_recently_created) do
- = sort_title_recently_created
- = link_to filter_groups_path(sort: sort_value_oldest_created) do
- = sort_title_oldest_created
- = link_to filter_groups_path(sort: sort_value_recently_updated) do
- = sort_title_recently_updated
- = link_to filter_groups_path(sort: sort_value_oldest_updated) do
- = sort_title_oldest_updated
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = _("Sort by")
+ - groups_sort_options_hash.each do |value, title|
+ %li.js-filter-sort-order
+ = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
+ = title
+ - if show_archive_options
+ %li.divider
+ %li.js-filter-archived-projects
+ = link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do
+ Hide archived projects
+ %li.js-filter-archived-projects
+ = link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
+ Show archived projects
+ %li.js-filter-archived-projects
+ = link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+ Show archived projects only
diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml
new file mode 100644
index 00000000000..13bb4baee3f
--- /dev/null
+++ b/app/views/shared/groups/_empty_state.html.haml
@@ -0,0 +1,7 @@
+.groups-empty-state
+ = custom_icon("icon_empty_groups")
+
+ .text-content
+ %h4= s_("GroupsEmptyState|A group is a collection of several projects.")
+ %p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
+ %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index 63f62eb476e..059dd24be6d 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -11,7 +11,7 @@
= link_to edit_group_path(group), class: "btn" do
= icon('cogs')
- = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do
+ = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: s_("GroupsTree|Leave this group") do
= icon('sign-out')
.stats
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index 427595c47a5..aec8ecd1714 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -3,4 +3,4 @@
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group
- else
- .nothing-here-block No groups found
+ .nothing-here-block= s_("GroupsEmptyState|No groups found")
diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml
index ad7a7faedf1..3f91263089a 100644
--- a/app/views/shared/groups/_search_form.html.haml
+++ b/app/views/shared/groups/_search_form.html.haml
@@ -1,2 +1,2 @@
-= form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f|
- = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
+= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f|
+ = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
diff --git a/app/views/shared/icons/_icon_autodevops.svg b/app/views/shared/icons/_icon_autodevops.svg
index 807ff27bb67..7e47c084bde 100644
--- a/app/views/shared/icons/_icon_autodevops.svg
+++ b/app/views/shared/icons/_icon_autodevops.svg
@@ -1,4 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="189" height="179" viewBox="0 0 189 179">
+<svg xmlns="http://www.w3.org/2000/svg" width="189" height="110" viewBox="0 0 189 179">
<g fill="none" fill-rule="evenodd">
<path fill="#FFFFFF" fill-rule="nonzero" d="M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
<path fill="#EEEEEE" fill-rule="nonzero" d="M110.160166,51.6956996 C106.846457,51.6956996 104.160166,54.3819911 104.160166,57.6956996 L104.160166,117.6957 C104.160166,121.009408 106.846457,123.6957 110.160166,123.6957 L160.160166,123.6957 C163.473874,123.6957 166.160166,121.009408 166.160166,117.6957 L166.160166,57.6956996 C166.160166,54.3819911 163.473874,51.6956996 160.160166,51.6956996 L110.160166,51.6956996 Z M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml
index 80432a73e4e..3d917346f6b 100644
--- a/app/views/shared/projects/_dropdown.html.haml
+++ b/app/views/shared/projects/_dropdown.html.haml
@@ -1,5 +1,5 @@
- @sort ||= sort_value_latest_activity
-.dropdown
+.dropdown.js-project-filter-dropdown-wrap
- toggle_text = projects_sort_options_hash[@sort]
= dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' })
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml
index 87fa2007d16..7185f5bcc5b 100644
--- a/app/views/shared/repo/_repo.html.haml
+++ b/app/views/shared/repo/_repo.html.haml
@@ -1,7 +1,10 @@
-#repo{ data: { url: content_url,
+#repo{ data: { root: @path.empty?.to_s,
+ url: content_url,
project_name: project.name,
refs_url: refs_project_path(project, format: :json),
project_url: project_path(project),
project_id: project.id,
+ blob_url: namespace_project_blob_path(project.namespace, project, '{{branch}}'),
+ new_mr_template_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '{{source_branch}}' }),
can_commit: (!!can_push_branch?(project, @ref)).to_s,
on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s } }
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 6c3cd6ecefe..cc59f8660fd 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -4,6 +4,9 @@
- page_description @user.bio
- header_title @user.name, user_path(@user)
- @no_container = true
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'common_d3'
+ = webpack_bundle_tag 'users'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity")
diff --git a/bin/changelog b/bin/changelog
index 61d4de06e90..efe25032ba1 100755
--- a/bin/changelog
+++ b/bin/changelog
@@ -28,6 +28,7 @@ class ChangelogOptionParser
Type.new('deprecated', 'New deprecation'),
Type.new('removed', 'Feature removal'),
Type.new('security', 'Security fix'),
+ Type.new('performance', 'Performance improvement'),
Type.new('other', 'Other')
].freeze
TYPES_OFFSET = 1
diff --git a/changelogs/unreleased/1312-time-spent-at.yml b/changelogs/unreleased/1312-time-spent-at.yml
new file mode 100644
index 00000000000..c029497e9ab
--- /dev/null
+++ b/changelogs/unreleased/1312-time-spent-at.yml
@@ -0,0 +1,5 @@
+---
+title: Added possibility to enter past date in /spend command to log time in the past
+merge_request: 3044
+author: g3dinua, LockiStrike
+type: changed
diff --git a/changelogs/unreleased/26763-grant-registry-auth-scope-to-admins.yml b/changelogs/unreleased/26763-grant-registry-auth-scope-to-admins.yml
new file mode 100644
index 00000000000..8918c42e3fb
--- /dev/null
+++ b/changelogs/unreleased/26763-grant-registry-auth-scope-to-admins.yml
@@ -0,0 +1,5 @@
+---
+title: Issue JWT token with registry:catalog:* scope when requested by GitLab admin
+merge_request: 14751
+author: Vratislav Kalenda
+type: added
diff --git a/changelogs/unreleased/27654-retry-button.yml b/changelogs/unreleased/27654-retry-button.yml
new file mode 100644
index 00000000000..11f3b5eb779
--- /dev/null
+++ b/changelogs/unreleased/27654-retry-button.yml
@@ -0,0 +1,5 @@
+---
+title: Move retry button in job page to sidebar
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/30140-restore-readme-only-preference.yml b/changelogs/unreleased/30140-restore-readme-only-preference.yml
new file mode 100644
index 00000000000..4b4ee4d5be9
--- /dev/null
+++ b/changelogs/unreleased/30140-restore-readme-only-preference.yml
@@ -0,0 +1,5 @@
+---
+title: Add readme only option as project view
+merge_request: 14900
+author:
+type: changed
diff --git a/changelogs/unreleased/34897-delete-branch-after-merge.yml b/changelogs/unreleased/34897-delete-branch-after-merge.yml
new file mode 100644
index 00000000000..96631aa95c8
--- /dev/null
+++ b/changelogs/unreleased/34897-delete-branch-after-merge.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed 'Removed source branch' checkbox in merge widget being ignored.
+merge_request: 14832
+author:
+type: fixed
diff --git a/changelogs/unreleased/35652-prometheus-service-page-shows-error.yml b/changelogs/unreleased/35652-prometheus-service-page-shows-error.yml
new file mode 100644
index 00000000000..7e2a7222162
--- /dev/null
+++ b/changelogs/unreleased/35652-prometheus-service-page-shows-error.yml
@@ -0,0 +1,5 @@
+---
+title: Fix flash errors showing up on a non configured prometheus integration
+merge_request: 35652
+author:
+type: fixed
diff --git a/changelogs/unreleased/36160-select2-dropdown.yml b/changelogs/unreleased/36160-zindex.yml
index a836744fb41..a836744fb41 100644
--- a/changelogs/unreleased/36160-select2-dropdown.yml
+++ b/changelogs/unreleased/36160-zindex.yml
diff --git a/changelogs/unreleased/37032-get-project-branch-invalid-name-message.yml b/changelogs/unreleased/37032-get-project-branch-invalid-name-message.yml
new file mode 100644
index 00000000000..22651967a40
--- /dev/null
+++ b/changelogs/unreleased/37032-get-project-branch-invalid-name-message.yml
@@ -0,0 +1,5 @@
+---
+title: Get Project Branch API shows an helpful error message on invalid refname
+merge_request: 14884
+author: Jacopo Beschi @jacopo-beschi
+type: added
diff --git a/changelogs/unreleased/37571-replace-wikipage-createservice-with-factory.yml b/changelogs/unreleased/37571-replace-wikipage-createservice-with-factory.yml
new file mode 100644
index 00000000000..bc93aa1fca4
--- /dev/null
+++ b/changelogs/unreleased/37571-replace-wikipage-createservice-with-factory.yml
@@ -0,0 +1,5 @@
+---
+title: Replace WikiPage::CreateService calls with wiki_page factory in specs
+merge_request: 14850
+author: Jacopo Beschi @jacopo-beschi
+type: changed
diff --git a/changelogs/unreleased/37660-match-sidebar-colors.yml b/changelogs/unreleased/37660-match-sidebar-colors.yml
new file mode 100644
index 00000000000..d5600f453e7
--- /dev/null
+++ b/changelogs/unreleased/37660-match-sidebar-colors.yml
@@ -0,0 +1,5 @@
+---
+title: Change background color of nav sidebar to match other gl sidebars
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/37691-subscription-fires-multiple-notifications.yml b/changelogs/unreleased/37691-subscription-fires-multiple-notifications.yml
new file mode 100644
index 00000000000..c3c38b35fa7
--- /dev/null
+++ b/changelogs/unreleased/37691-subscription-fires-multiple-notifications.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed duplicate notifications when added multiple labels on an issue
+merge_request: 14798
+author:
+type: fixed
diff --git a/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml b/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml
new file mode 100644
index 00000000000..554249a3f88
--- /dev/null
+++ b/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml
@@ -0,0 +1,6 @@
+---
+title: Removed extra border radius from .file-editor and .file-holder when editing
+ a file
+merge_request: 14803
+author: Rachel Pipkin
+type: fixed
diff --git a/changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml b/changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml
new file mode 100644
index 00000000000..48b92c02505
--- /dev/null
+++ b/changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml
@@ -0,0 +1,5 @@
+---
+title: Don't create build failed todos when the job is automatically retried
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/38871-cleanup-data-page-attribute-after-karma-test.yml b/changelogs/unreleased/38871-cleanup-data-page-attribute-after-karma-test.yml
new file mode 100644
index 00000000000..5e142a2b4cf
--- /dev/null
+++ b/changelogs/unreleased/38871-cleanup-data-page-attribute-after-karma-test.yml
@@ -0,0 +1,5 @@
+---
+title: Cleanup data-page attribute after each Karma test
+merge_request: 14742
+author:
+type: fixed
diff --git a/changelogs/unreleased/38986-due-date.yml b/changelogs/unreleased/38986-due-date.yml
new file mode 100644
index 00000000000..7799b8d297e
--- /dev/null
+++ b/changelogs/unreleased/38986-due-date.yml
@@ -0,0 +1,5 @@
+---
+title: Fix timezone bug in Pikaday and upgrade Pikaday version
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml b/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml
new file mode 100644
index 00000000000..89506f88637
--- /dev/null
+++ b/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml
@@ -0,0 +1,5 @@
+---
+title: Make usage ping scheduling more robust
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/39032-improve-merge-ongoing-check-consistency.yml b/changelogs/unreleased/39032-improve-merge-ongoing-check-consistency.yml
new file mode 100644
index 00000000000..361b6af196a
--- /dev/null
+++ b/changelogs/unreleased/39032-improve-merge-ongoing-check-consistency.yml
@@ -0,0 +1,5 @@
+---
+title: Make "merge ongoing" check more consistent
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/39033-d3-js-is-being-included-in-the-user_profile-and-graphs_show-bundles.yml b/changelogs/unreleased/39033-d3-js-is-being-included-in-the-user_profile-and-graphs_show-bundles.yml
new file mode 100644
index 00000000000..d142afa3433
--- /dev/null
+++ b/changelogs/unreleased/39033-d3-js-is-being-included-in-the-user_profile-and-graphs_show-bundles.yml
@@ -0,0 +1,6 @@
+---
+title: Removed d3.js from the graph and users bundles and used the common_d3 bundle
+ instead
+merge_request: 14826
+author:
+type: other
diff --git a/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml b/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml
new file mode 100644
index 00000000000..4b90d68d80c
--- /dev/null
+++ b/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml
@@ -0,0 +1,5 @@
+---
+title: 14830 Move GitLab export option to top of import list when creating a new project
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/add-lazy-option-to-user-avatar-image-component.yml b/changelogs/unreleased/add-lazy-option-to-user-avatar-image-component.yml
new file mode 100644
index 00000000000..eef78cd58f9
--- /dev/null
+++ b/changelogs/unreleased/add-lazy-option-to-user-avatar-image-component.yml
@@ -0,0 +1,5 @@
+---
+title: Add lazy option to UserAvatarImage
+merge_request: 14895
+author:
+type: changed
diff --git a/changelogs/unreleased/an-popen-deadline.yml b/changelogs/unreleased/an-popen-deadline.yml
new file mode 100644
index 00000000000..4b74c63ed5c
--- /dev/null
+++ b/changelogs/unreleased/an-popen-deadline.yml
@@ -0,0 +1,5 @@
+---
+title: Use a timeout on certain git operations
+merge_request: 14872
+author:
+type: security
diff --git a/changelogs/unreleased/bvl-circuitbreaker-improvements.yml b/changelogs/unreleased/bvl-circuitbreaker-improvements.yml
new file mode 100644
index 00000000000..15cbd5592e9
--- /dev/null
+++ b/changelogs/unreleased/bvl-circuitbreaker-improvements.yml
@@ -0,0 +1,5 @@
+---
+title: Store circuitbreaker settings in the database instead of config
+merge_request: 14842
+author:
+type: changed
diff --git a/changelogs/unreleased/bvl-do-not-use-redis-keys.yml b/changelogs/unreleased/bvl-do-not-use-redis-keys.yml
new file mode 100644
index 00000000000..f703aad2065
--- /dev/null
+++ b/changelogs/unreleased/bvl-do-not-use-redis-keys.yml
@@ -0,0 +1,5 @@
+---
+title: Forbid the usage of `Redis#keys`
+merge_request: 14889
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml b/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml
new file mode 100644
index 00000000000..95f56facc4b
--- /dev/null
+++ b/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml
@@ -0,0 +1,5 @@
+---
+title: Fix error when updating a forked project with deleted `ForkedProjectLink`
+merge_request: 14916
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-fix-locale-path.yml b/changelogs/unreleased/bvl-fix-locale-path.yml
new file mode 100644
index 00000000000..97e0e000e3c
--- /dev/null
+++ b/changelogs/unreleased/bvl-fix-locale-path.yml
@@ -0,0 +1,5 @@
+---
+title: Correctly render asset path for locales with a region
+merge_request: 14924
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-group-trees.yml b/changelogs/unreleased/bvl-group-trees.yml
new file mode 100644
index 00000000000..9f76eb81627
--- /dev/null
+++ b/changelogs/unreleased/bvl-group-trees.yml
@@ -0,0 +1,5 @@
+---
+title: Show collapsible project lists
+merge_request: 14055
+author:
+type: changed
diff --git a/changelogs/unreleased/cache-issuable-template-names.yml b/changelogs/unreleased/cache-issuable-template-names.yml
new file mode 100644
index 00000000000..858fdff2db2
--- /dev/null
+++ b/changelogs/unreleased/cache-issuable-template-names.yml
@@ -0,0 +1,5 @@
+---
+title: Cache issue and MR template names in Redis
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/es-module-broadcast_message.yml b/changelogs/unreleased/es-module-broadcast_message.yml
new file mode 100644
index 00000000000..031bcc449ae
--- /dev/null
+++ b/changelogs/unreleased/es-module-broadcast_message.yml
@@ -0,0 +1,5 @@
+---
+title: Fix unnecessary ajax requests in admin broadcast message form
+merge_request: 14853
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-resolved-side-by-side.yml b/changelogs/unreleased/fix-resolved-side-by-side.yml
new file mode 100644
index 00000000000..424130c3eb0
--- /dev/null
+++ b/changelogs/unreleased/fix-resolved-side-by-side.yml
@@ -0,0 +1,5 @@
+---
+title: Fix resolved discussions not expanding on side by side view
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix_diff_parsing.yml b/changelogs/unreleased/fix_diff_parsing.yml
new file mode 100644
index 00000000000..7a26b4f9ff5
--- /dev/null
+++ b/changelogs/unreleased/fix_diff_parsing.yml
@@ -0,0 +1,5 @@
+---
+title: Fix diff parser so it tolerates to diff special markers in the content
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix_global_board_routes_39073.yml b/changelogs/unreleased/fix_global_board_routes_39073.yml
new file mode 100644
index 00000000000..cc9ae8592db
--- /dev/null
+++ b/changelogs/unreleased/fix_global_board_routes_39073.yml
@@ -0,0 +1,5 @@
+---
+title: Allow boards as top level route
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fl-autodevops-fix.yml b/changelogs/unreleased/fl-autodevops-fix.yml
new file mode 100644
index 00000000000..21b739231a8
--- /dev/null
+++ b/changelogs/unreleased/fl-autodevops-fix.yml
@@ -0,0 +1,5 @@
+---
+title: Improve autodevops banner UX and render it only in project page
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/issue-36484.yml b/changelogs/unreleased/issue-36484.yml
new file mode 100644
index 00000000000..a19126e650f
--- /dev/null
+++ b/changelogs/unreleased/issue-36484.yml
@@ -0,0 +1,5 @@
+---
+title: Remove unnecessary alt-texts from pipeline emails
+merge_request: 14602
+author: gernberg
+type: fixed
diff --git a/changelogs/unreleased/kt-bug-fix-revision-and-size-for-container-registry.yml b/changelogs/unreleased/kt-bug-fix-revision-and-size-for-container-registry.yml
new file mode 100644
index 00000000000..acbb24d16fc
--- /dev/null
+++ b/changelogs/unreleased/kt-bug-fix-revision-and-size-for-container-registry.yml
@@ -0,0 +1,5 @@
+---
+title: Fix revision and total size missing for Container Registry
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/new-mr-repo-editor.yml b/changelogs/unreleased/new-mr-repo-editor.yml
new file mode 100644
index 00000000000..a6c15ee30a9
--- /dev/null
+++ b/changelogs/unreleased/new-mr-repo-editor.yml
@@ -0,0 +1,5 @@
+---
+title: 'Repo Editor: Add option to start a new MR directly from comit section'
+merge_request: 14665
+author:
+type: added
diff --git a/changelogs/unreleased/not-found-in-commits.yml b/changelogs/unreleased/not-found-in-commits.yml
new file mode 100644
index 00000000000..d5f9ff15a36
--- /dev/null
+++ b/changelogs/unreleased/not-found-in-commits.yml
@@ -0,0 +1,5 @@
+---
+title: Renders 404 in commits controller if no commits are found for a given path
+merge_request: 14610
+author: Guilherme Vieira
+type: fixed
diff --git a/changelogs/unreleased/prevent-creating-multiple-application-settings.yml b/changelogs/unreleased/prevent-creating-multiple-application-settings.yml
new file mode 100644
index 00000000000..fd49028b9e9
--- /dev/null
+++ b/changelogs/unreleased/prevent-creating-multiple-application-settings.yml
@@ -0,0 +1,5 @@
+---
+title: Prevent creating multiple ApplicationSetting instances
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/replace_explore_projects-feature.yml b/changelogs/unreleased/replace_explore_projects-feature.yml
new file mode 100644
index 00000000000..85ef045fb4b
--- /dev/null
+++ b/changelogs/unreleased/replace_explore_projects-feature.yml
@@ -0,0 +1,5 @@
+---
+title: Replace the 'features/explore/projects.feature' spinach test with an rspec analog
+merge_request: 14755
+author: Vitaliy @blackst0ne Klachkov
+type: other
diff --git a/changelogs/unreleased/sha-handling.yml b/changelogs/unreleased/sha-handling.yml
new file mode 100644
index 00000000000..d776edafef5
--- /dev/null
+++ b/changelogs/unreleased/sha-handling.yml
@@ -0,0 +1,5 @@
+---
+title: Fix 404 errors in API caused when the branch name had a dot
+merge_request: 14462
+author: gvieira37
+type: fixed
diff --git a/changelogs/unreleased/tc-saml-fix-false-empty.yml b/changelogs/unreleased/tc-saml-fix-false-empty.yml
new file mode 100644
index 00000000000..987f596475b
--- /dev/null
+++ b/changelogs/unreleased/tc-saml-fix-false-empty.yml
@@ -0,0 +1,5 @@
+---
+title: Fix SAML error 500 when no groups are defined for user
+merge_request: 14913
+author:
+type: fixed
diff --git a/changelogs/unreleased/winh-indeterminate-dropdown.yml b/changelogs/unreleased/winh-indeterminate-dropdown.yml
new file mode 100644
index 00000000000..61205d1643e
--- /dev/null
+++ b/changelogs/unreleased/winh-indeterminate-dropdown.yml
@@ -0,0 +1,5 @@
+---
+title: Fix alignment for indeterminate marker in dropdowns
+merge_request: 14809
+author:
+type: fixed
diff --git a/changelogs/unreleased/zj-add-performance-changelog-cat.yml b/changelogs/unreleased/zj-add-performance-changelog-cat.yml
new file mode 100644
index 00000000000..3d58044a254
--- /dev/null
+++ b/changelogs/unreleased/zj-add-performance-changelog-cat.yml
@@ -0,0 +1,5 @@
+---
+title: Add Performance improvement as category on the changelog
+merge_request:
+author:
+type: performance
diff --git a/config/application.rb b/config/application.rb
index 31e91835b9e..5100ec5d2b7 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -29,6 +29,7 @@ module Gitlab
#{config.root}/app/models/project_services
#{config.root}/app/workers/concerns
#{config.root}/app/services/concerns
+ #{config.root}/app/serializers/concerns
#{config.root}/app/finders/concerns])
config.generators.templates.push("#{config.root}/generator_templates")
diff --git a/config/database.yml.mysql b/config/database.yml.mysql
index eb71d3f5fe1..98c2abe9f5e 100644
--- a/config/database.yml.mysql
+++ b/config/database.yml.mysql
@@ -10,7 +10,7 @@ production:
pool: 10
username: git
password: "secure password"
- # host: localhost
+ host: localhost
# socket: /tmp/mysql.sock
#
@@ -25,7 +25,22 @@ development:
pool: 5
username: root
password: "secure password"
- # host: localhost
+ host: localhost
+ # socket: /tmp/mysql.sock
+
+#
+# Staging specific
+#
+staging:
+ adapter: mysql2
+ encoding: utf8
+ collation: utf8_general_ci
+ reconnect: false
+ database: gitlabhq_staging
+ pool: 10
+ username: git
+ password: "secure password"
+ host: localhost
# socket: /tmp/mysql.sock
# Warning: The database defined as "test" will be erased and
@@ -40,6 +55,6 @@ test: &test
pool: 5
username: root
password:
- # host: localhost
+ host: localhost
# socket: /tmp/mysql.sock
prepared_statements: false
diff --git a/config/database.yml.postgresql b/config/database.yml.postgresql
index 4b30982fe82..baded682e46 100644
--- a/config/database.yml.postgresql
+++ b/config/database.yml.postgresql
@@ -6,10 +6,9 @@ production:
encoding: unicode
database: gitlabhq_production
pool: 10
- # username: git
- # password:
- # host: localhost
- # port: 5432
+ username: git
+ password: "secure password"
+ host: localhost
#
# Development specific
@@ -20,8 +19,8 @@ development:
database: gitlabhq_development
pool: 5
username: postgres
- password:
- # host: localhost
+ password: "secure password"
+ host: localhost
#
# Staging specific
@@ -30,10 +29,10 @@ staging:
adapter: postgresql
encoding: unicode
database: gitlabhq_staging
- pool: 5
- username: postgres
- password:
- # host: localhost
+ pool: 10
+ username: git
+ password: "secure password"
+ host: localhost
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
@@ -45,5 +44,5 @@ test: &test
pool: 5
username: postgres
password:
- # host: localhost
+ host: localhost
prepared_statements: false
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index db31b01a7d2..3af7f7bd5c0 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -464,3 +464,10 @@
:why: Our own library - https://gitlab.com/gitlab-org/gitlab-svgs
:versions: []
:when: 2017-09-19 14:36:32.795496000 Z
+- - :license
+ - pikaday
+ - MIT
+ - :who:
+ :why:
+ :versions: []
+ :when: 2017-10-17 17:46:12.367554000 Z
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 1069c7be5f0..4bfa5be0136 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -522,11 +522,6 @@ production: &base
path: /home/git/repositories/
gitaly_address: unix:/home/git/gitlab/tmp/sockets/private/gitaly.socket # TCP connections are supported too (e.g. tcp://host:port)
# gitaly_token: 'special token' # Optional: override global gitaly.token for this storage.
- failure_count_threshold: 10 # number of failures before stopping attempts
- failure_wait_time: 30 # Seconds after an access failure before allowing access again
- failure_reset_time: 1800 # Time in seconds to expire failures
- storage_timeout: 30 # Time in seconds to wait before aborting a storage access attempt
-
## Backup settings
backup:
@@ -659,9 +654,6 @@ test:
default:
path: tmp/tests/repositories/
gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
- failure_count_threshold: 999999
- failure_wait_time: 0
- storage_timeout: 30
broken:
path: tmp/tests/non-existent-repositories
gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index a4b7c1a3919..12694f8016f 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -113,12 +113,14 @@ class Settings < Settingslogic
URI.parse(url_without_path).host
end
- # Random cron time every Sunday to load balance usage pings
- def cron_random_weekly_time
+ # Runs every minute in a random ten-minute period on Sundays, to balance the
+ # load on the server receiving these pings. The usage ping is safe to run
+ # multiple times because of a 24 hour exclusive lock.
+ def cron_for_usage_ping
hour = rand(24)
- minute = rand(60)
+ minute = rand(6)
- "#{minute} #{hour} * * 0"
+ "#{minute}0-#{minute}9 #{hour} * * 0"
end
end
end
@@ -398,7 +400,7 @@ Settings.cron_jobs['stuck_import_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_import_jobs_worker']['cron'] ||= '15 * * * *'
Settings.cron_jobs['stuck_import_jobs_worker']['job_class'] = 'StuckImportJobsWorker'
Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({})
-Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time)
+Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_for_usage_ping)
Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker'
Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({})
@@ -453,17 +455,6 @@ Settings.repositories.storages.each do |key, storage|
# Expand relative paths
storage['path'] = Settings.absolute(storage['path'])
- # Set failure defaults
- storage['failure_count_threshold'] ||= 10
- storage['failure_wait_time'] ||= 30
- storage['failure_reset_time'] ||= 1800
- storage['storage_timeout'] ||= 5
- # Set turn strings into numbers
- storage['failure_count_threshold'] = storage['failure_count_threshold'].to_i
- storage['failure_wait_time'] = storage['failure_wait_time'].to_i
- storage['failure_reset_time'] = storage['failure_reset_time'].to_i
- # We might want to have a timeout shorter than 1 second.
- storage['storage_timeout'] = storage['storage_timeout'].to_f
Settings.repositories.storages[key] = storage
end
diff --git a/config/routes.rb b/config/routes.rb
index 405bfcc2d8e..fc13dc4865f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -44,6 +44,19 @@ Rails.application.routes.draw do
get 'readiness' => 'health#readiness'
resources :metrics, only: [:index]
mount Peek::Railtie => '/peek'
+
+ # Boards resources shared between group and projects
+ resources :boards, only: [] do
+ resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
+ collection do
+ post :generate
+ end
+
+ resources :issues, only: [:index, :create, :update]
+ end
+
+ resources :issues, module: :boards, only: [:index, :update]
+ end
end
# Koding route
@@ -74,19 +87,6 @@ Rails.application.routes.draw do
# Notification settings
resources :notification_settings, only: [:create, :update]
- # Boards resources shared between group and projects
- resources :boards do
- resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
- collection do
- post :generate
- end
-
- resources :issues, only: [:index, :create, :update]
- end
-
- resources :issues, module: :boards, only: [:index, :update]
- end
-
draw :google_api
draw :import
draw :uploads
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 8cc30bfcc50..702df5b7b5a 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -32,6 +32,8 @@ scope(path: 'groups/*group_id',
end
resources :variables, only: [:index, :show, :update, :create, :destroy]
+
+ resources :children, only: [:index]
end
end
@@ -43,7 +45,6 @@ scope(path: 'groups/*id',
get :merge_requests, as: :merge_requests_group
get :projects, as: :projects_group
get :activity, as: :activity_group
- get :subgroups, as: :subgroups_group
get '/', action: :show, as: :group_canonical
end
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 7f0e056c884..d05fe11f233 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -281,8 +281,13 @@ constraints(ProjectUrlConstrainer.new) do
namespace :registry do
resources :repository, only: [] do
- resources :tags, only: [:index, :destroy],
- constraints: { id: Gitlab::Regex.container_registry_tag_regex }
+ # We default to JSON format in the controller to avoid ambiguity.
+ # `latest.json` could either be a request for a tag named `latest`
+ # in JSON format, or a request for tag named `latest.json`.
+ scope format: false do
+ resources :tags, only: [:index, :destroy],
+ constraints: { id: Gitlab::Regex.container_registry_tag_regex }
+ end
end
end
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 8cded750a66..f7a7182a627 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -84,6 +84,7 @@ var config = {
vue_merge_request_widget: './vue_merge_request_widget/index.js',
test: './test.js',
two_factor_auth: './two_factor_auth.js',
+ users: './users/index.js',
performance_bar: './performance_bar.js',
webpack_runtime: './webpack.js',
},
@@ -215,7 +216,9 @@ var config = {
name: 'common_d3',
chunks: [
'graphs',
+ 'graphs_show',
'monitoring',
+ 'users',
],
}),
@@ -233,7 +236,7 @@ var config = {
from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`),
to: 'monaco-editor/vs',
transform: function(content, path) {
- if (/\.js$/.test(path) && !/worker/i.test(path)) {
+ if (/\.js$/.test(path) && !/worker/i.test(path) && !/typescript/i.test(path)) {
return (
'(function(){\n' +
'var define = this.define, require = this.require;\n' +
diff --git a/db/migrate/20170909150936_add_spent_at_to_timelogs.rb b/db/migrate/20170909150936_add_spent_at_to_timelogs.rb
new file mode 100644
index 00000000000..ffff719c289
--- /dev/null
+++ b/db/migrate/20170909150936_add_spent_at_to_timelogs.rb
@@ -0,0 +1,11 @@
+class AddSpentAtToTimelogs < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ add_column :timelogs, :spent_at, :datetime_with_timezone
+ end
+
+ def down
+ remove_column :timelogs, :spent_at
+ end
+end
diff --git a/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb b/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb
new file mode 100644
index 00000000000..bcf7dbd8e64
--- /dev/null
+++ b/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb
@@ -0,0 +1,27 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddCircuitBreakerPropertiesToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings,
+ :circuitbreaker_failure_count_threshold,
+ :integer,
+ default: 160
+ add_column :application_settings,
+ :circuitbreaker_failure_wait_time,
+ :integer,
+ default: 30
+ add_column :application_settings,
+ :circuitbreaker_failure_reset_time,
+ :integer,
+ default: 1800
+ add_column :application_settings,
+ :circuitbreaker_storage_timeout,
+ :integer,
+ default: 30
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index aac37b6b455..c2c04873d4d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20171006091000) do
+ActiveRecord::Schema.define(version: 20171012101043) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -134,6 +134,10 @@ ActiveRecord::Schema.define(version: 20171006091000) do
t.boolean "hashed_storage_enabled", default: false, null: false
t.boolean "project_export_enabled", default: true, null: false
t.boolean "auto_devops_enabled", default: false, null: false
+ t.integer "circuitbreaker_failure_count_threshold", default: 160
+ t.integer "circuitbreaker_failure_wait_time", default: 30
+ t.integer "circuitbreaker_failure_reset_time", default: 1800
+ t.integer "circuitbreaker_storage_timeout", default: 30
end
create_table "audit_events", force: :cascade do |t|
@@ -1550,6 +1554,7 @@ ActiveRecord::Schema.define(version: 20171006091000) do
t.datetime "updated_at", null: false
t.integer "issue_id"
t.integer "merge_request_id"
+ t.datetime_with_timezone "spent_at"
end
add_index "timelogs", ["issue_id"], name: "index_timelogs_on_issue_id", using: :btree
diff --git a/doc/administration/img/circuitbreaker_config.png b/doc/administration/img/circuitbreaker_config.png
new file mode 100644
index 00000000000..9250d38297c
--- /dev/null
+++ b/doc/administration/img/circuitbreaker_config.png
Binary files differ
diff --git a/doc/administration/raketasks/github_import.md b/doc/administration/raketasks/github_import.md
index 04c70c3644e..6b8ad1b039b 100644
--- a/doc/administration/raketasks/github_import.md
+++ b/doc/administration/raketasks/github_import.md
@@ -7,6 +7,7 @@
> projects. You can get it from: https://github.com/settings/tokens
> - You also need to pass an username as the second argument to the rake task
> which will become the owner of the project.
+> - You can also resume an import with the same command.
To import a project from the list of your GitHub projects available:
diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md
index 624a908b3a3..efcabd69822 100644
--- a/doc/administration/repository_storage_paths.md
+++ b/doc/administration/repository_storage_paths.md
@@ -105,61 +105,26 @@ When GitLab detects access to the repositories storage fails repeatedly, it can
gracefully prevent attempts to access the storage. This might be useful when
the repositories are stored somewhere on the network.
-The configuration could look as follows:
+This can be configured from the admin interface:
-**For Omnibus installations**
-
-1. Edit `/etc/gitlab/gitlab.rb`:
-
- ```ruby
- git_data_dirs({
- "default" => {
- "path" => "/mnt/nfs-01/git-data",
- "failure_count_threshold" => 10,
- "failure_wait_time" => 30,
- "failure_reset_time" => 1800,
- "storage_timeout" => 5
- }
- })
- ```
-
-1. Save the file and [reconfigure GitLab][reconfigure-gitlab] for the changes to take effect.
-
----
-
-**For installations from source**
-
-1. Edit `config/gitlab.yml`:
-
- ```yaml
- repositories:
- storages: # You must have at least a `default` storage path.
- default:
- path: /home/git/repositories/
- failure_count_threshold: 10 # number of failures before stopping attempts
- failure_wait_time: 30 # Seconds after last access failure before trying again
- failure_reset_time: 1800 # Time in seconds to expire failures
- storage_timeout: 5 # Time in seconds to wait before aborting a storage access attempt
- ```
-
-1. Save the file and [restart GitLab][restart-gitlab] for the changes to take effect.
+![circuitbreaker configuration](img/circuitbreaker_config.png)
-**`failure_count_threshold`:** The number of failures of after which GitLab will
+**Maximum git storage failures:** The number of failures of after which GitLab will
completely prevent access to the storage. The number of failures can be reset in
the admin interface: `https://gitlab.example.com/admin/health_check` or using the
[api](../api/repository_storage_health.md) to allow access to the storage again.
-**`failure_wait_time`:** When access to a storage fails. GitLab will prevent
-access to the storage for the time specified here. This allows the filesystem to
-recover without.
+**Seconds to wait after a storage failure:** When access to a storage fails. GitLab
+will prevent access to the storage for the time specified here. This allows the
+filesystem to recover.
-**`failure_reset_time`:** The time in seconds GitLab will keep failure
-information. When no failures occur during this time, information about the
+**Seconds before reseting failure information:** The time in seconds GitLab will
+keep failure information. When no failures occur during this time, information about the
mount is reset.
-**`storage_timeout`:** The time in seconds GitLab will try to access storage.
-After this time a timeout error will be raised.
+**Seconds to wait for a storage access attempt:** The time in seconds GitLab will
+try to access storage. After this time a timeout error will be raised.
When storage failures occur, this will be visible in the admin interface like this:
diff --git a/doc/api/README.md b/doc/api/README.md
index 3fd4c97e536..3145c9b676f 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -63,7 +63,21 @@ following locations:
## Road to GraphQL
-We have changed our plans to move to GraphQL. After reviewing the GraphQL license, anything related to the Facebook BSD plus patent license will not be allowed at GitLab.
+Going forward, we will start on moving to
+[GraphQL](http://graphql.org/learn/best-practices/) and deprecate the use of
+controller-specific endpoints. GraphQL has a number of benefits:
+
+1. We avoid having to maintain two different APIs.
+2. Callers of the API can request only what they need.
+3. It is versioned by default.
+
+It will co-exist with the current v4 REST API. If we have a v5 API, this should
+be a compatibility layer on top of GraphQL.
+
+Although there were some patenting and licensing concerns with GraphQL, these
+have been resolved to our satisfaction by the relicensing of the reference
+implementations under MIT, and the use of the OWF license for the GraphQL
+specification.
## Basic usage
diff --git a/doc/api/repositories.md b/doc/api/repositories.md
index bccef924375..594babc74be 100644
--- a/doc/api/repositories.md
+++ b/doc/api/repositories.md
@@ -85,7 +85,7 @@ GET /projects/:id/repository/blobs/:sha
Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
-- `sha` (required) - The commit or branch name
+- `sha` (required) - The blob SHA
## Raw blob content
diff --git a/doc/api/settings.md b/doc/api/settings.md
index b78f1252108..664f3ef7b77 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -64,38 +64,93 @@ PUT /application/settings
| Attribute | Type | Required | Description |
| --------- | ---- | :------: | ----------- |
-| `default_projects_limit` | integer | no | Project limit per user. Default is `100000` |
-| `signup_enabled` | boolean | no | Enable registration. Default is `true`. |
-| `password_authentication_enabled` | boolean | no | Enable authentication via a GitLab account password. Default is `true`. |
-| `gravatar_enabled` | boolean | no | Enable Gravatar |
-| `sign_in_text` | string | no | Text on login page |
-| `home_page_url` | string | no | Redirect to this URL when not logged in |
-| `default_branch_protection` | integer | no | Determine if developers can push to master. Can take `0` _(not protected, both developers and masters can push new commits, force push or delete the branch)_, `1` _(partially protected, developers can push new commits, but cannot force push or delete the branch, masters can do anything)_ or `2` _(fully protected, developers cannot push new commits, force push or delete the branch, masters can do anything)_ as a parameter. Default is `2`. |
-| `restricted_visibility_levels` | array of strings | no | Selected levels cannot be used by non-admin users for projects or snippets. Can take `private`, `internal` and `public` as a parameter. Default is null which means there is no restriction. |
-| `max_attachment_size` | integer | no | Limit attachment size in MB |
-| `session_expire_delay` | integer | no | Session duration in minutes. GitLab restart is required to apply changes |
-| `default_project_visibility` | string | no | What visibility level new projects receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`.|
-| `default_snippet_visibility` | string | no | What visibility level new snippets receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`.|
-| `default_group_visibility` | string | no | What visibility level new groups receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`.|
-| `domain_whitelist` | array of strings | no | Force people to use only corporate emails for sign-up. Default is null, meaning there is no restriction. |
-| `domain_blacklist_enabled` | boolean | no | Enable/disable the `domain_blacklist` |
-| `domain_blacklist` | array of strings | yes (if `domain_blacklist_enabled` is `true`) | People trying to sign-up with emails from this domain will not be allowed to do so. |
-| `user_oauth_applications` | boolean | no | Allow users to register any application to use GitLab as an OAuth provider |
-| `after_sign_out_path` | string | no | Where to redirect users after logout |
-| `container_registry_token_expire_delay` | integer | no | Container Registry token duration in minutes |
-| `repository_storages` | array of strings | no | A list of names of enabled storage paths, taken from `gitlab.yml`. New projects will be created in one of these stores, chosen at random. |
-| `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. |
-| `koding_enabled` | boolean | no | Enable Koding integration. Default is `false`. |
-| `koding_url` | string | yes (if `koding_enabled` is `true`) | The Koding instance URL for integration. |
-| `disabled_oauth_sign_in_sources` | Array of strings | no | Disabled OAuth sign-in sources |
-| `plantuml_enabled` | boolean | no | Enable PlantUML integration. Default is `false`. |
-| `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. |
-| `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time. |
-| `polling_interval_multiplier` | decimal | no | Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling. |
-| `rsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `0` (no restriction). `-1` disables RSA keys.
-| `dsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `0` (no restriction). `-1` disables DSA keys.
-| `ecdsa_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `0` (no restriction). `-1` disables ECDSA keys.
-| `ed25519_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ED25519 key. Default is `0` (no restriction). `-1` disables ED25519 keys.
+| `admin_notification_email` | string | no | Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. |
+| `after_sign_out_path` | string | no | Where to redirect users after logout |
+| `after_sign_up_text` | string | no | Text shown to the user after signing up |
+| `akismet_api_key` | string | no | API key for akismet spam protection |
+| `akismet_enabled` | boolean | no | Enable or disable akismet spam protection |
+| `circuitbreaker_failure_count_threshold` | integer | no | The number of failures of after which GitLab will completely prevent access to the storage. |
+| `circuitbreaker_failure_reset_time` | integer | no | Time in seconds GitLab will keep storage failure information. When no failures occur during this time, the failure information is reset. |
+| `circuitbreaker_failure_wait_time` | integer | no | Time in seconds GitLab will block access to a failing storage to allow it to recover. |
+| `circuitbreaker_storage_timeout` | integer | no | Seconds to wait for a storage access attempt |
+| `clientside_sentry_dsn` | string | no | Required if `clientside_sentry_dsn` is enabled |
+| `clientside_sentry_enabled` | boolean | no | Enable Sentry error reporting for the client side |
+| `container_registry_token_expire_delay` | integer | no | Container Registry token duration in minutes |
+| `default_artifacts_expire_in` | string | no | Set the default expiration time for each job's artifacts |
+| `default_branch_protection` | integer | no | Determine if developers can push to master. Can take `0` _(not protected, both developers and masters can push new commits, force push or delete the branch)_, `1` _(partially protected, developers can push new commits, but cannot force push or delete the branch, masters can do anything)_ or `2` _(fully protected, developers cannot push new commits, force push or delete the branch, masters can do anything)_ as a parameter. Default is `2`. |
+| `default_group_visibility` | string | no | What visibility level new groups receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. |
+| `default_project_visibility` | string | no | What visibility level new projects receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. |
+| `default_projects_limit` | integer | no | Project limit per user. Default is `100000` |
+| `default_snippet_visibility` | string | no | What visibility level new snippets receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. |
+| `disabled_oauth_sign_in_sources` | Array of strings | no | Disabled OAuth sign-in sources |
+| `domain_blacklist_enabled` | boolean | no | Enable/disable the `domain_blacklist` |
+| `domain_blacklist` | array of strings | yes (if `domain_blacklist_enabled` is `true`) | People trying to sign-up with emails from this domain will not be allowed to do so. |
+| `domain_whitelist` | array of strings | no | Force people to use only corporate emails for sign-up. Default is null, meaning there is no restriction. |
+| `dsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `0` (no restriction). `-1` disables DSA keys. |
+| `ecdsa_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `0` (no restriction). `-1` disables ECDSA keys. |
+| `ed25519_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ED25519 key. Default is `0` (no restriction). `-1` disables ED25519 keys. |
+| `email_author_in_body` | boolean | no | Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead. |
+| `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. |
+| `gravatar_enabled` | boolean | no | Enable Gravatar |
+| `help_page_hide_commercial_content` | boolean | no | Hide marketing-related entries from help |
+| `help_page_support_url` | string | no | Alternate support URL for help page |
+| `home_page_url` | string | no | Redirect to this URL when not logged in |
+| `housekeeping_bitmaps_enabled` | boolean | no | Enable Git pack file bitmap creation |
+| `housekeeping_enabled` | boolean | no | Enable or disable git housekeeping |
+| `housekeeping_full_repack_period` | integer | no | Number of Git pushes after which an incremental 'git repack' is run. |
+| `housekeeping_gc_period` | integer | no | Number of Git pushes after which 'git gc' is run. |
+| `housekeeping_incremental_repack_period` | integer | no | Number of Git pushes after which an incremental 'git repack' is run. |
+| `html_emails_enabled` | boolean | no | Enable HTML emails |
+| `import_sources` | Array of strings | no | Sources to allow project import from, possible values: "github bitbucket gitlab google_code fogbugz git gitlab_project |
+| `koding_enabled` | boolean | no | Enable Koding integration. Default is `false`. |
+| `koding_url` | string | yes (if `koding_enabled` is `true`) | The Koding instance URL for integration. |
+| `max_artifacts_size` | integer | no | Maximum artifacts size in MB |
+| `max_attachment_size` | integer | no | Limit attachment size in MB |
+| `max_pages_size` | integer | no | Maximum size of pages repositories in MB |
+| `metrics_enabled` | boolean | no | Enable influxDB metrics |
+| `metrics_host` | string | yes (if `metrics_enabled` is `true`) | InfluxDB host |
+| `metrics_method_call_threshold` | integer | yes (if `metrics_enabled` is `true`) | A method call is only tracked when it takes longer than the given amount of milliseconds |
+| `metrics_packet_size` | integer | yes (if `metrics_enabled` is `true`) | The amount of datapoints to send in a single UDP packet. |
+| `metrics_pool_size` | integer | yes (if `metrics_enabled` is `true`) | The amount of InfluxDB connections to keep open |
+| `metrics_port` | integer | no | The UDP port to use for connecting to InfluxDB |
+| `metrics_sample_interval` | integer | yes (if `metrics_enabled` is `true`) | The sampling interval in seconds. |
+| `metrics_timeout` | integer | yes (if `metrics_enabled` is `true`) | The amount of seconds after which InfluxDB will time out. |
+| `password_authentication_enabled` | boolean | no | Enable authentication via a GitLab account password. Default is `true`. |
+| `performance_bar_allowed_group_id` | string | no | The group that is allowed to enable the performance bar |
+| `performance_bar_enabled` | boolean | no | Allow enabling the performance bar |
+| `plantuml_enabled` | boolean | no | Enable PlantUML integration. Default is `false`. |
+| `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. |
+| `polling_interval_multiplier` | decimal | no | Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling. |
+| `project_export_enabled` | boolean | no | Enable project export |
+| `prometheus_metrics_enabled` | boolean | no | Enable prometheus metrics |
+| `recaptcha_enabled` | boolean | no | Enable recaptcha |
+| `recaptcha_private_key` | string | yes (if `recaptcha_enabled` is true) | Private key for recaptcha |
+| `recaptcha_site_key` | string | yes (if `recaptcha_enabled` is true) | Site key for recaptcha |
+| `repository_checks_enabled` | boolean | no | GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues. |
+| `repository_storages` | array of strings | no | A list of names of enabled storage paths, taken from `gitlab.yml`. New projects will be created in one of these stores, chosen at random. |
+| `require_two_factor_authentication` | boolean | no | Require all users to setup Two-factor authentication |
+| `restricted_visibility_levels` | array of strings | no | Selected levels cannot be used by non-admin users for projects or snippets. Can take `private`, `internal` and `public` as a parameter. Default is null which means there is no restriction. |
+| `rsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `0` (no restriction). `-1` disables RSA keys. |
+| `send_user_confirmation_email` | boolean | no | Send confirmation email on sign-up |
+| `sentry_dsn` | string | yes (if `sentry_enabled` is true) | Sentry Data Source Name |
+| `sentry_enabled` | boolean | no | Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: https://getsentry.com |
+| `session_expire_delay` | integer | no | Session duration in minutes. GitLab restart is required to apply changes |
+| `shared_runners_enabled` | true | no | Enable shared runners for new projects |
+| `shared_runners_text` | string | no | Shared runners text |
+| `sidekiq_throttling_enabled` | boolean | no | Enable Sidekiq Job Throttling |
+| `sidekiq_throttling_factor` | decimal | yes (if `sidekiq_throttling_enabled` is true) | The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive. |
+| `sidekiq_throttling_queues` | array of strings | yes (if `sidekiq_throttling_enabled` is true) | Choose which queues you wish to throttle |
+| `sign_in_text` | string | no | Text on login page |
+| `signup_enabled` | boolean | no | Enable registration. Default is `true`. |
+| `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time. |
+| `two_factor_grace_period` | integer | no | Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication |
+| `unique_ips_limit_enabled` | boolean | no | Limit sign in from multiple ips |
+| `unique_ips_limit_per_user` | integer | yes (if `unique_ips_limit_enabled` is true) | Maximum number of ips per user |
+| `unique_ips_limit_time_window` | integer | yes (if `unique_ips_limit_enabled` is true) | How many seconds an IP will be counted towards the limit |
+| `usage_ping_enabled` | boolean | no | Every week GitLab will report license usage back to GitLab, Inc. |
+| `user_default_external` | boolean | no | Newly registered users will by default be external |
+| `user_oauth_applications` | boolean | no | Allow users to register any application to use GitLab as an OAuth provider |
+| `version_check_enabled` | boolean | no | Let GitLab inform you when an update is available. |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/application/settings?signup_enabled=false&default_project_visibility=internal
diff --git a/doc/ci/README.md b/doc/ci/README.md
index 5cfd82de381..ec0ddfbea75 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -42,7 +42,7 @@ digging into specific reference guides.
- **The permissions model** - Learn about the access levels a user can have for
performing certain CI actions
- [User permissions](../user/permissions.md#gitlab-ci)
- - [Jobs permissions](../user/permissions.md#jobs-permissions)
+ - [Job permissions](../user/permissions.md#job-permissions)
## Auto DevOps
diff --git a/doc/development/README.md b/doc/development/README.md
index e2d0c6c2056..36096842344 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -21,7 +21,6 @@
## Backend guides
-- [Testing standards and style guidelines](testing_guide/index.md)
- [API styleguide](api_styleguide.md) Use this styleguide if you are
contributing to the API.
- [Sidekiq guidelines](sidekiq_style_guide.md) for working with Sidekiq workers
@@ -67,6 +66,11 @@
- [Ordering table columns](ordering_table_columns.md)
- [Verifying database capabilities](verifying_database_capabilities.md)
+## Testing guides
+
+- [Testing standards and style guidelines](testing_guide/index.md)
+- [Frontend testing standards and style guidelines](testing_guide/frontend_testing.md)
+
## Documentation guides
- [Documentation styleguide](doc_styleguide.md): Use this styleguide if you are
diff --git a/doc/development/gitaly.md b/doc/development/gitaly.md
index e41d258bec6..ca2048c7019 100644
--- a/doc/development/gitaly.md
+++ b/doc/development/gitaly.md
@@ -52,8 +52,8 @@ rm -rf tmp/tests/gitaly
## `TooManyInvocationsError` errors
-During development and testing, you may experience `Gitlab::GitalyClient::TooManyInvocationsError` failures.
-The `GitalyClient` will attempt to block against potential n+1 issues by raising this error
+During development and testing, you may experience `Gitlab::GitalyClient::TooManyInvocationsError` failures.
+The `GitalyClient` will attempt to block against potential n+1 issues by raising this error
when Gitaly is called more than 30 times in a single Rails request or Sidekiq execution.
As a temporary measure, export `GITALY_DISABLE_REQUEST_LIMITS=1` to suppress the error. This will disable the n+1 detection
@@ -64,7 +64,7 @@ Please raise an issue in the GitLab CE or EE repositories to report the issue. I
`TooManyInvocationsError`. Also include any known failing tests if possible.
Isolate the source of the n+1 problem. This will normally be a loop that results in Gitaly being called for each
-element in an array. If you are unable to isolate the problem, please contact a member
+element in an array. If you are unable to isolate the problem, please contact a member
of the [Gitaly Team](https://gitlab.com/groups/gl-gitaly/group_members) for assistance.
Once the source has been found, wrap it in an `allow_n_plus_1_calls` block, as follows:
@@ -79,6 +79,24 @@ end
Once the code is wrapped in this block, this code-path will be excluded from n+1 detection.
+## Request counts
+
+Commits and other git data, is now fetched through Gitaly. These fetches can,
+much like with a database, be batched. This improves performance for the client
+and for Gitaly itself and therefore for the users too. To keep performance stable
+and guard performance regressions, Gitaly calls can be counted and the call count
+can be tested against. This requires the `:request_store` flag to be set.
+
+```ruby
+describe 'Gitaly Request count tests' do
+ context 'when the request store is activated', :request_store do
+ it 'correctly counts the gitaly requests made' do
+ expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(10)
+ end
+ end
+end
+```
+
---
[Return to Development documentation](README.md)
diff --git a/doc/development/i18n/translation.md b/doc/development/i18n/translation.md
index 79707aaf671..b34ec754742 100644
--- a/doc/development/i18n/translation.md
+++ b/doc/development/i18n/translation.md
@@ -58,6 +58,18 @@ For example, in French we translate `you` as the informal `tu`.
You can refer to other translated strings and notes in the glossary to assist determining a
suitable level of formality.
+### Inclusive language
+
+[Diversity] is one of GitLab's values.
+We ask you to avoid translations which exclude people based on their gender or ethnicity.
+In languages which distinguish between a male and female form,
+use both or choose a neutral formulation.
+
+For example in German, the word "user" can be translated into "Benutzer" (male) or "Benutzerin" (female).
+Therefore "create a new user" would translate into "Benutzer(in) anlegen".
+
+[Diversity]: https://about.gitlab.com/handbook/values/#diversity
+
### Updating the glossary
To propose additions to the glossary please
diff --git a/doc/development/licensing.md b/doc/development/licensing.md
index a75cdf22f40..902b1c74a42 100644
--- a/doc/development/licensing.md
+++ b/doc/development/licensing.md
@@ -56,6 +56,7 @@ Libraries with the following licenses are acceptable for use:
- [ISC License][ISC] (also known as the OpenBSD License): A permissive (non-copyleft) license as defined by the Open Source Initiative.
- [Creative Commons Zero (CC0)][CC0]: A public domain dedication, recommended as a way to disclaim copyright on your work to the maximum extent possible.
- [Unlicense][UNLICENSE]: Another public domain dedication.
+- [OWFa 1.0][OWFa1]: An open-source license and patent grant designed for specifications.
## Unacceptable Licenses
@@ -105,6 +106,7 @@ Gems which are included only in the "development" or "test" groups by Bundler ar
[OSL-GNU]: https://www.gnu.org/licenses/license-list.en.html#OSL
[Org-Repo]: https://gitlab.com/gitlab-com/organization
[UNLICENSE]: https://unlicense.org
+[OWFa1]: http://www.openwebfoundation.org/legal/the-owf-1-0-agreements/owfa-1-0
[Facebook]: https://code.facebook.com/pages/850928938376556
[x-list]: https://www.apache.org/legal/resolved.html#category-x
[Acceptable-Licenses]: #acceptable-licenses
diff --git a/doc/development/profiling.md b/doc/development/profiling.md
index 933033a09e0..af79353b721 100644
--- a/doc/development/profiling.md
+++ b/doc/development/profiling.md
@@ -27,3 +27,13 @@ Bullet will log query problems to both the Rails log as well as the Chrome
console.
As a follow up to finding `N+1` queries with Bullet, consider writing a [QueryRecoder test](query_recorder.md) to prevent a regression.
+
+## GitLab Profiler
+
+
+[Gitlab-Profiler](https://gitlab.com/gitlab-com/gitlab-profiler) was built to
+help developers understand why specific URLs of their application may be slow
+and to provide hard data that can help reduce load times.
+
+For GitLab.com, you can find the latest results here:
+<http://redash.gitlab.com/dashboard/gitlab-profiler-statistics>
diff --git a/doc/development/testing_guide/index.md b/doc/development/testing_guide/index.md
index 38b1fe1a193..8045bbad7ba 100644
--- a/doc/development/testing_guide/index.md
+++ b/doc/development/testing_guide/index.md
@@ -84,6 +84,7 @@ test should be re-implemented using RSpec instead.
[^1]: /ci/yaml/README.html#dependencies
+[rails]: http://rubyonrails.org/
[RSpec]: https://github.com/rspec/rspec-rails#feature-specs
[Capybara]: https://github.com/teamcapybara/capybara
[Karma]: http://karma-runner.github.io/
diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md
index 67ef189fee9..e18711f3392 100644
--- a/doc/gitlab-basics/create-project.md
+++ b/doc/gitlab-basics/create-project.md
@@ -17,7 +17,7 @@
[Project Templates](https://gitlab.com/gitlab-org/project-templates):
this will kickstart your repository code and CI automatically.
Otherwise, if you have a project in a different repository, you can [import it] by
- clicking an **Import project from** button provided this is enabled in
+ clicking on the **Import project** tab, provided this is enabled in
your GitLab instance. Ask your administrator if not.
1. Provide the following information:
diff --git a/doc/gitlab-basics/img/create_new_project_info.png b/doc/gitlab-basics/img/create_new_project_info.png
index ef8753e224b..ce4f7d1204b 100644
--- a/doc/gitlab-basics/img/create_new_project_info.png
+++ b/doc/gitlab-basics/img/create_new_project_info.png
Binary files differ
diff --git a/doc/install/installation.md b/doc/install/installation.md
index af6c797dc00..2a004152d5e 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -80,7 +80,7 @@ Make sure you have the right version of Git installed
# Install Git
sudo apt-get install -y git-core
- # Make sure Git is version 2.13.0 or higher
+ # Make sure Git is version 2.13.6 or higher
git --version
Is the system packaged Git too old? Remove it and compile from source.
@@ -121,7 +121,7 @@ The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab
in production, frequently leads to hard to diagnose problems. For example,
GitLab Shell is called from OpenSSH, and having a version manager can prevent
pushing and pulling over SSH. Version managers are not supported and we strongly
-advise everyone to follow the instructions below to use a system Ruby.
+advise everyone to follow the instructions below to use a system Ruby.
Linux distributions generally have older versions of Ruby available, so these
instructions are designed to install Ruby from the official source code.
@@ -299,9 +299,9 @@ sudo usermod -aG redis git
### Clone the Source
# Clone GitLab repository
- sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-0-stable gitlab
+ sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-1-stable gitlab
-**Note:** You can change `10-0-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
+**Note:** You can change `10-1-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
### Configure It
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index 17fe80fa93d..3d7becd18fc 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -121,8 +121,8 @@ Existing users using GitLab with MySQL/MariaDB are advised to
### PostgreSQL Requirements
-As of GitLab 9.3, PostgreSQL 9.2 or newer is required, and earlier versions are
-not supported. We highly recommend users to use at least PostgreSQL 9.6 as this
+As of GitLab 10.0, PostgreSQL 9.6 or newer is required, and earlier versions are
+not supported. We highly recommend users to use PostgreSQL 9.6 as this
is the PostgreSQL version used for development and testing.
Users using PostgreSQL must ensure the `pg_trgm` extension is loaded into every
diff --git a/doc/integration/google.md b/doc/integration/google.md
index d5b523e6dc0..0611cbb59dc 100644
--- a/doc/integration/google.md
+++ b/doc/integration/google.md
@@ -1,83 +1,92 @@
# Google OAuth2 OmniAuth Provider
-To enable the Google OAuth2 OmniAuth provider you must register your application with Google. Google will generate a client ID and secret key for you to use.
-
-1. Sign in to the [Google Developers Console](https://console.developers.google.com/) with the Google account you want to use to register GitLab.
-
-1. Select "Create Project".
-
-1. Provide the project information
- - Project name: 'GitLab' works just fine here.
- - Project ID: Must be unique to all Google Developer registered applications. Google provides a randomly generated Project ID by default. You can use the randomly generated ID or choose a new one.
-1. Refresh the page. You should now see your new project in the list. Click on the project.
-
-1. Select the "Google APIs" tab in the Overview.
-
-1. Select and enable the following Google APIs - listed under "Popular APIs"
- - Enable `Contacts API`
- - Enable `Google+ API`
+To enable the Google OAuth2 OmniAuth provider you must register your application
+with Google. Google will generate a client ID and secret key for you to use.
+
+## Enabling Google OAuth
+
+In Google's side:
+
+1. Navigate to the [cloud resource manager](https://console.cloud.google.com/cloud-resource-manager) page
+1. Select **Create Project**
+1. Provide the project information:
+ - **Project name** - "GitLab" works just fine here.
+ - **Project ID** - Must be unique to all Google Developer registered applications.
+ Google provides a randomly generated Project ID by default. You can use
+ the randomly generated ID or choose a new one.
+1. Refresh the page and you should see your new project in the list
+1. Go to the [Google API Console](https://console.developers.google.com/apis/dashboard)
+1. Select the previously created project form the upper left corner
+1. Select **Credentials** from the sidebar
+1. Select **OAuth consent screen** and fill the form with the required information
+1. In the **Credentials** tab, select **Create credentials > OAuth client ID**
+1. Fill in the required information
+ - **Application type** - Choose "Web Application"
+ - **Name** - Use the default one or provide your own
+ - **Authorized JavaScript origins** -This isn't really used by GitLab but go
+ ahead and put `https://gitlab.example.com`
+ - **Authorized redirect URIs** - Enter your domain name followed by the
+ callback URIs one at a time:
-1. Select "Credentials" in the submenu.
+ ```
+ https://gitlab.example.com/users/auth/google_oauth2/callback
+ https://gitlab.exampl.com/-/google_api/auth/callback
+ ```
-1. Select "Create New Client ID".
+1. You should now be able to see a Client ID and Client secret. Note them down
+ or keep this page open as you will need them later.
+1. From the **Dashboard** select **ENABLE APIS AND SERVICES > Google Cloud APIs > Container Engine API > Enable**
-1. Fill in the required information
- - Application type: "Web Application"
- - Authorized JavaScript origins: This isn't really used by GitLab but go ahead and put 'https://gitlab.example.com' here.
- - Authorized redirect URI: 'https://gitlab.example.com/users/auth/google_oauth2/callback'
-1. Under the heading "Client ID for web application" you should see a Client ID and Client secret (see screenshot). Keep this page open as you continue configuration. ![Google app](img/google_app.png)
+On your GitLab server:
-1. On your GitLab server, open the configuration file.
+1. Open the configuration file.
- For omnibus package:
+ For Omnibus GitLab:
```sh
- sudo editor /etc/gitlab/gitlab.rb
+ sudo editor /etc/gitlab/gitlab.rb
```
For installations from source:
```sh
- cd /home/git/gitlab
-
- sudo -u git -H editor config/gitlab.yml
+ cd /home/git/gitlab
+ sudo -u git -H editor config/gitlab.yml
```
-1. See [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) for initial settings.
-
-1. Add the provider configuration:
+1. See [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) for initial settings.
+1. Add the provider configuration:
- For omnibus package:
+ For Omnibus GitLab:
```ruby
- gitlab_rails['omniauth_providers'] = [
- {
- "name" => "google_oauth2",
- "app_id" => "YOUR_APP_ID",
- "app_secret" => "YOUR_APP_SECRET",
- "args" => { "access_type" => "offline", "approval_prompt" => '' }
- }
- ]
+ gitlab_rails['omniauth_providers'] = [
+ {
+ "name" => "google_oauth2",
+ "app_id" => "YOUR_APP_ID",
+ "app_secret" => "YOUR_APP_SECRET",
+ "args" => { "access_type" => "offline", "approval_prompt" => '' }
+ }
+ ]
```
For installations from source:
```
- - { name: 'google_oauth2', app_id: 'YOUR_APP_ID',
- app_secret: 'YOUR_APP_SECRET',
- args: { access_type: 'offline', approval_prompt: '' } }
+ - { name: 'google_oauth2', app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ args: { access_type: 'offline', approval_prompt: '' } }
```
-1. Change 'YOUR_APP_ID' to the client ID from the Google Developer page from step 10.
-
-1. Change 'YOUR_APP_SECRET' to the client secret from the Google Developer page from step 10.
-
-1. Make sure that you configure GitLab to use an FQDN as Google will not accept raw IP addresses.
+1. Change `YOUR_APP_ID` to the client ID from the Google Developer page
+1. Similarly, change `YOUR_APP_SECRET` to the client secret
+1. Make sure that you configure GitLab to use an FQDN as Google will not accept
+ raw IP addresses.
For Omnibus packages:
```ruby
- external_url 'https://gitlab.example.com'
+ external_url 'https://gitlab.example.com'
```
For installations from source:
@@ -88,21 +97,32 @@ To enable the Google OAuth2 OmniAuth provider you must register your application
```
1. Save the configuration file.
-
1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you
installed GitLab via Omnibus or from source respectively.
-On the sign in page there should now be a Google icon below the regular sign in form. Click the icon to begin the authentication process. Google will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to GitLab and will be signed in.
+On the sign in page there should now be a Google icon below the regular sign in
+form. Click the icon to begin the authentication process. Google will ask the
+user to sign in and authorize the GitLab application. If everything goes well
+the user will be returned to GitLab and will be signed in.
## Further Configuration
-This further configuration is not required for Google authentication to function but it is strongly recommended. Taking these steps will increase usability for users by providing a little more recognition and branding.
-
-At this point, when users first try to authenticate to your GitLab installation with Google they will see a generic application name on the prompt screen. The prompt informs the user that "Project Default Service Account" would like to access their account. "Project Default Service Account" isn't very recognizable and may confuse or cause users to be concerned. This is easily changeable.
-
-1. Select 'Consent screen' in the left menu. (See steps 1, 4 and 5 above for instructions on how to get here if you closed your window).
-1. Scroll down until you find "Product Name". Change the product name to something more descriptive.
-1. Add any additional information as you wish - homepage, logo, privacy policy, etc. None of this is required, but it may help your users.
+This further configuration is not required for Google authentication to function
+but it is strongly recommended. Taking these steps will increase usability for
+users by providing a little more recognition and branding.
+
+At this point, when users first try to authenticate to your GitLab installation
+with Google they will see a generic application name on the prompt screen. The
+prompt informs the user that "Project Default Service Account" would like to
+access their account. "Project Default Service Account" isn't very recognizable
+and may confuse or cause users to be concerned. This is easily changeable:
+
+1. Select 'Consent screen' in the left menu. (See steps 1, 4 and 5 above for
+ instructions on how to get here if you closed your window).
+1. Scroll down until you find "Product Name". Change the product name to
+ something more descriptive.
+1. Add any additional information as you wish - homepage, logo, privacy policy,
+ etc. None of this is required, but it may help your users.
[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure
[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source
diff --git a/doc/update/10.0-to-10.1.md b/doc/update/10.0-to-10.1.md
index 4a9384f3ad6..dc14c779026 100644
--- a/doc/update/10.0-to-10.1.md
+++ b/doc/update/10.0-to-10.1.md
@@ -150,7 +150,7 @@ sudo -u git -H make
#### New Gitaly configuration options required
-In order to function Gitaly needs some additional configuration information. Below we assume you installed Gitaly in `/home/git/gitaly` and GitLab Shell in `/home/git/gitlab-shell'.
+In order to function Gitaly needs some additional configuration information. Below we assume you installed Gitaly in `/home/git/gitaly` and GitLab Shell in `/home/git/gitlab-shell`.
```shell
echo '
@@ -335,11 +335,11 @@ sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
If all items are green, then congratulations, the upgrade is complete!
-## Things went south? Revert to previous version (9.5)
+## Things went south? Revert to previous version (10.0)
### 1. Revert the code to the previous version
-Follow the [upgrade guide from 9.4 to 9.5](9.4-to-9.5.md), except for the
+Follow the [upgrade guide from 9.5 to 10.0](9.5-to-10.0.md), except for the
database migration (the backup is already migrated to the previous version).
### 2. Restore from the backup
diff --git a/doc/user/discussions/index.md b/doc/user/discussions/index.md
index 5bd326a426f..2206b2860f4 100644
--- a/doc/user/discussions/index.md
+++ b/doc/user/discussions/index.md
@@ -155,7 +155,7 @@ comments in greater detail.
## Image discussions
-> [Introduced][ce-14531] in GitLab 10.1.
+> [Introduced][ce-14061] in GitLab 10.1.
Sometimes a discussion is revolved around an image. With image discussions,
you can easily target a specific coordinate of an image and start a discussion
@@ -227,6 +227,7 @@ edit existing comments. Non-team members are restricted from adding or editing c
[ce-7180]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7180
[ce-8266]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8266
[ce-14053]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14053
+[ce-14061]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14061
[ce-14531]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14531
[resolve-discussion-button]: img/resolve_discussion_button.png
[resolve-comment-button]: img/resolve_comment_button.png
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index c4e41c8e9bf..c03700a3501 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -76,6 +76,7 @@ The following table depicts the various user permission levels in a project.
| Force push to protected branches [^4] | | | | | |
| Remove protected branches [^4] | | | | | |
| Remove pages | | | | | ✓ |
+| Manage clusters | | | | ✓ | ✓ |
## Project features permissions
diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md
new file mode 100644
index 00000000000..7d9e771f570
--- /dev/null
+++ b/doc/user/project/clusters/index.md
@@ -0,0 +1,90 @@
+# Connecting GitLab with GKE
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/35954) in 10.1.
+
+CAUTION: **Warning:**
+The Cluster integration is currently in **Beta**.
+
+Connect your project to Google Container Engine (GKE) in a few steps.
+
+With a cluster associated to your project, you can use Review Apps, deploy your
+applications, run your pipelines, and much more in an easy way.
+
+NOTE: **Note:**
+The Cluster integration will eventually supersede the
+[Kubernetes integration](../integrations/kubernetes.md). For the moment,
+you can create only one cluster.
+
+## Prerequisites
+
+In order to be able to manage your GKE cluster through GitLab, the following
+prerequisites must be met:
+
+- The [Google authentication integration](../../../integration/google.md) must
+ be enabled in GitLab at the instance level. If that's not the case, ask your
+ administrator to enable it.
+- Your associated Google account must have the right privileges to manage
+ clusters on GKE. That would mean that a
+ [billing account](https://cloud.google.com/billing/docs/how-to/manage-billing-account)
+ must be set up.
+- You must have Master [permissions] in order to be able to access the **Cluster**
+ page.
+
+If all of the above requirements are met, you can proceed to add a new cluster.
+
+## Adding a cluster
+
+NOTE: **Note:**
+You need Master [permissions] and above to add a cluster.
+
+To add a new cluster:
+
+1. Navigate to your project's **CI/CD > Cluster** page.
+1. Connect your Google account if you haven't done already by clicking the
+ "Sign-in with Google" button.
+1. Fill in the requested values:
+ - **Cluster name** (required) - The name you wish to give the cluster.
+ - **GCP project ID** (required) - The ID of the project you created in your GCP
+ console that will host the Kubernetes cluster. This must **not** be confused
+ with the project name. Learn more about [Google Cloud Platform projects](https://cloud.google.com/resource-manager/docs/creating-managing-projects).
+ - **Zone** - The zone under which the cluster will be created. Read more about
+ [the available zones](https://cloud.google.com/compute/docs/regions-zones/).
+ - **Number of nodes** - The number of nodes you wish the cluster to have.
+ - **Machine type** - The machine type of the Virtual Machine instance that
+ the cluster will be based on. Read more about [the available machine types](https://cloud.google.com/compute/docs/machine-types).
+ - **Project namespace** - The unique namespace for this project. By default you
+ don't have to fill it in; by leaving it blank, GitLab will create one for you.
+1. Click the **Create cluster** button.
+
+After a few moments your cluster should be created. If something goes wrong,
+you will be notified.
+
+Now, you can proceed to [enable the Cluster integration](#enabling-or-disabling-the-cluster-integration).
+
+## Enabling or disabling the Cluster integration
+
+After you have successfully added your cluster information, you can enable the
+Cluster integration:
+
+1. Click the "Enabled/Disabled" switch
+1. Hit **Save** for the changes to take effect
+
+You can now start using your Kubernetes cluster for your deployments.
+
+To disable the Cluster integration, follow the same procedure.
+
+## Removing the Cluster integration
+
+NOTE: **Note:**
+You need Master [permissions] and above to remove a cluster integration.
+
+NOTE: **Note:**
+When you remove a cluster, you only remove its relation to GitLab, not the
+cluster itself. To remove the cluster, you can do so by visiting the GKE
+dashboard or using `kubectl`.
+
+To remove the Cluster integration from your project, simply click on the
+**Remove integration** button. You will then be able to follow the procedure
+and [add a cluster](#adding-a-cluster) again.
+
+[permissions]: ../../permissions.md
diff --git a/doc/user/project/index.md b/doc/user/project/index.md
index 03bbc46bd8c..97d0d529886 100644
--- a/doc/user/project/index.md
+++ b/doc/user/project/index.md
@@ -63,6 +63,8 @@ common actions on issues or merge requests
browse, and download job artifacts
- [Pipeline settings](pipelines/settings.md): Set up Git strategy (choose the default way your repository is fetched from GitLab in a job),
timeout (defines the maximum amount of time in minutes that a job is able run), custom path for `.gitlab-ci.yml`, test coverage parsing, pipeline's visibility, and much more
+ - [GKE cluster integration](clusters/index.md): Connecting your GitLab project
+ with Google Container Engine
- [GitLab Pages](pages/index.md): Build, test, and deploy your static
website with GitLab Pages
diff --git a/doc/user/project/issues/automatic_issue_closing.md b/doc/user/project/issues/automatic_issue_closing.md
index d6f3a7d5555..402a2a3c727 100644
--- a/doc/user/project/issues/automatic_issue_closing.md
+++ b/doc/user/project/issues/automatic_issue_closing.md
@@ -1,8 +1,10 @@
# Automatic issue closing
->**Note:**
-This is the user docs. In order to change the default issue closing pattern,
-follow the steps in the [administration docs].
+>**Notes:**
+> - This is the user docs. In order to change the default issue closing pattern,
+> follow the steps in the [administration docs].
+> - For performance reasons, automatic issue closing is disabled for the very
+> first push from an existing repository.
When a commit or merge request resolves one or more issues, it is possible to
automatically have these issues closed when the commit or merge request lands
@@ -19,7 +21,7 @@ When not specified, the default issue closing pattern as shown below will be
used:
```bash
-((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing))(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)
+((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing)|[Ii]mplement(?:s|ed|ing)?)(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)
```
Note that `%{issue_ref}` is a complex regular expression defined inside GitLab's
@@ -34,6 +36,7 @@ This translates to the following keywords:
- Close, Closes, Closed, Closing, close, closes, closed, closing
- Fix, Fixes, Fixed, Fixing, fix, fixes, fixed, fixing
- Resolve, Resolves, Resolved, Resolving, resolve, resolves, resolved, resolving
+- Implement, Implements, Implemented, Implementing, implement, implements, implemented, implementing
---
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index 6a5d2d40927..e81e935e37d 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -32,7 +32,7 @@ do.
| `/wip` | Toggle the Work In Progress status |
| <code>/estimate &lt;1w 3d 2h 14m&gt;</code> | Set time estimate |
| `/remove_estimate` | Remove estimated time |
-| <code>/spend &lt;1h 30m &#124; -1h 5m&gt;</code> | Add or subtract spent time |
+| <code>/spend &lt;time(1h 30m &#124; -1h 5m)&gt; &lt;date(YYYY-MM-DD)&gt;</code> | Add or subtract spent time; optionally, specify the date that time was spent on |
| `/remove_time_spent` | Remove time spent |
| `/target_branch <Branch Name>` | Set target branch for current merge request |
| `/award :emoji:` | Toggle award for :emoji: |
diff --git a/features/explore/groups.feature b/features/explore/groups.feature
index 9eacbe0b25e..830810615e0 100644
--- a/features/explore/groups.feature
+++ b/features/explore/groups.feature
@@ -3,6 +3,7 @@ Feature: Explore Groups
Background:
Given group "TestGroup" has private project "Enterprise"
+ @javascript
Scenario: I should see group with private and internal projects as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
@@ -10,6 +11,7 @@ Feature: Explore Groups
Then I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group issues for internal project as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
@@ -17,6 +19,7 @@ Feature: Explore Groups
Then I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group merge requests for internal project as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
@@ -24,6 +27,7 @@ Feature: Explore Groups
Then I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group with private, internal and public projects as visitor
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -32,6 +36,7 @@ Feature: Explore Groups
And I should not see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group issues for public project as visitor
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -40,6 +45,7 @@ Feature: Explore Groups
And I should not see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group merge requests for public project as visitor
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -48,6 +54,7 @@ Feature: Explore Groups
And I should not see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group with private, internal and public projects as user
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -57,6 +64,7 @@ Feature: Explore Groups
And I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group issues for internal and public projects as user
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -66,6 +74,7 @@ Feature: Explore Groups
And I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group merge requests for internal and public projects as user
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -75,17 +84,20 @@ Feature: Explore Groups
And I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group with public project in public groups area
Given group "TestGroup" has public project "Community"
When I visit the public groups area
Then I should see group "TestGroup"
+ @javascript
Scenario: I should see group with public project in public groups area as user
Given group "TestGroup" has public project "Community"
When I sign in as a user
And I visit the public groups area
Then I should see group "TestGroup"
+ @javascript
Scenario: I should see group with internal project in public groups area as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
diff --git a/features/explore/projects.feature b/features/explore/projects.feature
deleted file mode 100644
index 4e0f4486ab7..00000000000
--- a/features/explore/projects.feature
+++ /dev/null
@@ -1,144 +0,0 @@
-@public
-Feature: Explore Projects
- Background:
- Given public project "Community"
- And internal project "Internal"
- And private project "Enterprise"
-
- Scenario: I visit public area
- Given archived project "Archive"
- When I visit the public projects area
- Then I should see project "Community"
- And I should not see project "Internal"
- And I should not see project "Enterprise"
- And I should not see project "Archive"
-
- Scenario: I visit public project page
- When I visit project "Community" page
- Then I should see project "Community" home page
-
- Scenario: I visit internal project page
- When I visit project "Internal" page
- Then I should be redirected to sign in page
-
- Scenario: I visit private project page
- When I visit project "Enterprise" page
- Then I should be redirected to sign in page
-
- Scenario: I visit an empty public project page
- Given public empty project "Empty Public Project"
- When I visit empty project page
- Then I should see empty public project details
- And I should see empty public project details with http clone info
-
- Scenario: I visit an empty public project page as user with no ssh-keys
- Given I sign in as a user
- And I have no ssh keys
- And public empty project "Empty Public Project"
- When I visit empty project page
- Then I should see empty public project details
- And I should see empty public project details with http clone info
-
- Scenario: I visit an empty public project page as user with an ssh-key
- Given I sign in as a user
- And I have an ssh key
- And public empty project "Empty Public Project"
- When I visit empty project page
- Then I should see empty public project details
- And I should see empty public project details with ssh clone info
-
- Scenario: I visit public area as user
- Given archived project "Archive"
- And I sign in as a user
- When I visit the public projects area
- Then I should see project "Community"
- And I should see project "Internal"
- And I should not see project "Enterprise"
- And I should not see project "Archive"
-
- Scenario: I visit internal project page as user
- Given I sign in as a user
- When I visit project "Internal" page
- Then I should see project "Internal" home page
-
- Scenario: I visit public project page
- When I visit project "Community" page
- Then I should see project "Community" home page
- And I should see an http link to the repository
-
- Scenario: I visit public project page as user with no ssh-keys
- Given I sign in as a user
- And I have no ssh keys
- When I visit project "Community" page
- Then I should see project "Community" home page
- And I should see an http link to the repository
-
- Scenario: I visit public project page as user with an ssh-key
- Given I sign in as a user
- And I have an ssh key
- When I visit project "Community" page
- Then I should see project "Community" home page
- And I should see an ssh link to the repository
-
- Scenario: I visit an empty public project page
- Given public empty project "Empty Public Project"
- When I visit empty project page
- Then I should see empty public project details
-
- Scenario: I visit public project issues page as a non authorized user
- Given I visit project "Community" page
- Then I should not see command line instructions
- And I visit "Community" issues page
- Then I should see list of issues for "Community" project
-
- Scenario: I visit public project issues page as authorized user
- Given I sign in as a user
- Given I visit project "Community" page
- And I visit "Community" issues page
- Then I should see list of issues for "Community" project
-
- Scenario: I visit internal project issues page as authorized user
- Given I sign in as a user
- Given I visit project "Internal" page
- And I visit "Internal" issues page
- Then I should see list of issues for "Internal" project
-
- Scenario: I visit public project merge requests page as an authorized user
- Given I sign in as a user
- Given I visit project "Community" page
- And I visit "Community" merge requests page
- And project "Community" has "Bug fix" open merge request
- Then I should see list of merge requests for "Community" project
-
- Scenario: I visit public project merge requests page as a non authorized user
- Given I visit project "Community" page
- And I visit "Community" merge requests page
- And project "Community" has "Bug fix" open merge request
- Then I should see list of merge requests for "Community" project
-
- Scenario: I visit internal project merge requests page as an authorized user
- Given I sign in as a user
- Given I visit project "Internal" page
- And I visit "Internal" merge requests page
- And project "Internal" has "Feature implemented" open merge request
- Then I should see list of merge requests for "Internal" project
-
- Scenario: Trending page
- Given archived project "Archive"
- And project "Archive" has comments
- And I sign in as a user
- And project "Community" has comments
- And trending projects are refreshed
- When I visit the explore trending projects
- Then I should see project "Community"
- And I should not see project "Internal"
- And I should not see project "Enterprise"
- And I should not see project "Archive"
-
- Scenario: Most starred page
- Given archived project "Archive"
- And I sign in as a user
- When I visit the explore starred projects
- Then I should see project "Community"
- And I should see project "Internal"
- And I should not see project "Archive"
diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb
deleted file mode 100644
index 962e39dde9a..00000000000
--- a/features/steps/explore/projects.rb
+++ /dev/null
@@ -1,145 +0,0 @@
-class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
- include SharedUser
-
- step 'I should see project "Empty Public Project"' do
- expect(page).to have_content "Empty Public Project"
- end
-
- step 'I should see public project details' do
- expect(page).to have_content '32 branches'
- expect(page).to have_content '16 tags'
- end
-
- step 'I should see project readme' do
- expect(page).to have_content 'README.md'
- end
-
- step 'I should see empty public project details' do
- expect(page).not_to have_content 'Git global setup'
- end
-
- step 'I should see empty public project details with http clone info' do
- project = Project.find_by(name: 'Empty Public Project')
- page.all(:css, '.git-empty .clone').each do |element|
- expect(element.text).to include(project.http_url_to_repo)
- end
- end
-
- step 'I should see empty public project details with ssh clone info' do
- project = Project.find_by(name: 'Empty Public Project')
- page.all(:css, '.git-empty .clone').each do |element|
- expect(element.text).to include(project.url_to_repo)
- end
- end
-
- step 'I should see project "Community" home page' do
- page.within '.breadcrumbs .breadcrumb-item-text' do
- expect(page).to have_content 'Community'
- end
- end
-
- step 'I should see project "Internal" home page' do
- page.within '.breadcrumbs .breadcrumb-item-text' do
- expect(page).to have_content 'Internal'
- end
- end
-
- step 'I should see an http link to the repository' do
- project = Project.find_by(name: 'Community')
- expect(page).to have_field('project_clone', with: project.http_url_to_repo)
- end
-
- step 'I should see an ssh link to the repository' do
- project = Project.find_by(name: 'Community')
- expect(page).to have_field('project_clone', with: project.url_to_repo)
- end
-
- step 'I visit "Community" issues page' do
- create(:issue,
- title: "Bug",
- project: public_project
- )
- create(:issue,
- title: "New feature",
- project: public_project
- )
- visit project_issues_path(public_project)
- end
-
- step 'I should see list of issues for "Community" project' do
- expect(page).to have_content "Bug"
- expect(page).to have_content public_project.name
- expect(page).to have_content "New feature"
- end
-
- step 'I visit "Internal" issues page' do
- create(:issue,
- title: "Internal Bug",
- project: internal_project
- )
- create(:issue,
- title: "New internal feature",
- project: internal_project
- )
- visit project_issues_path(internal_project)
- end
-
- step 'I should see list of issues for "Internal" project' do
- expect(page).to have_content "Internal Bug"
- expect(page).to have_content internal_project.name
- expect(page).to have_content "New internal feature"
- end
-
- step 'I visit "Community" merge requests page' do
- visit project_merge_requests_path(public_project)
- end
-
- step 'project "Community" has "Bug fix" open merge request' do
- create(:merge_request,
- title: "Bug fix for public project",
- source_project: public_project,
- target_project: public_project
- )
- end
-
- step 'I should see list of merge requests for "Community" project' do
- expect(page).to have_content public_project.name
- expect(page).to have_content public_merge_request.source_project.name
- end
-
- step 'I visit "Internal" merge requests page' do
- visit project_merge_requests_path(internal_project)
- end
-
- step 'project "Internal" has "Feature implemented" open merge request' do
- create(:merge_request,
- title: "Feature implemented",
- source_project: internal_project,
- target_project: internal_project
- )
- end
-
- step 'I should see list of merge requests for "Internal" project' do
- expect(page).to have_content internal_project.name
- expect(page).to have_content internal_merge_request.source_project.name
- end
-
- def internal_project
- @internal_project ||= Project.find_by!(name: 'Internal')
- end
-
- def public_project
- @public_project ||= Project.find_by!(name: 'Community')
- end
-
- def internal_merge_request
- @internal_merge_request ||= MergeRequest.find_by!(title: 'Feature implemented')
- end
-
- def public_merge_request
- @public_merge_request ||= MergeRequest.find_by!(title: 'Bug fix for public project')
- end
-end
diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb
index be69a96c3ee..bff0d58aaf4 100644
--- a/features/steps/shared/paths.rb
+++ b/features/steps/shared/paths.rb
@@ -222,7 +222,7 @@ module SharedPaths
end
step "I visit my project's commits page for a specific path" do
- visit project_commits_path(@project, root_ref + "/app/models/project.rb", { limit: 5 })
+ visit project_commits_path(@project, root_ref + "/files/ruby/regex.rb", { limit: 5 })
end
step 'I visit my project\'s commits stats page' do
@@ -454,19 +454,6 @@ module SharedPaths
# ----------------------------------------
# Public Projects
# ----------------------------------------
-
- step 'I visit the public projects area' do
- visit explore_projects_path
- end
-
- step 'I visit the explore trending projects' do
- visit trending_explore_projects_path
- end
-
- step 'I visit the explore starred projects' do
- visit starred_explore_projects_path
- end
-
step 'I visit the public groups area' do
visit explore_groups_path
end
diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb
index 96cc0745e97..5e4edaf99a6 100644
--- a/features/steps/shared/project.rb
+++ b/features/steps/shared/project.rb
@@ -112,10 +112,6 @@ module SharedProject
# Visibility of archived project
# ----------------------------------------
- step 'archived project "Archive"' do
- create(:project, :archived, :public, :repository, name: 'Archive')
- end
-
step 'I should not see project "Archive"' do
project = Project.find_by(name: "Archive")
expect(page).not_to have_content project.name_with_namespace
@@ -126,11 +122,6 @@ module SharedProject
expect(page).to have_content project.name_with_namespace
end
- step 'project "Archive" has comments' do
- project = Project.find_by(name: "Archive")
- 2.times { create(:note_on_issue, project: project) }
- end
-
# ----------------------------------------
# Visibility level
# ----------------------------------------
@@ -209,15 +200,6 @@ module SharedProject
create :project_empty_repo, :public, name: "Empty Public Project"
end
- step 'project "Community" has comments' do
- project = Project.find_by(name: "Community")
- 2.times { create(:note_on_issue, project: project) }
- end
-
- step 'trending projects are refreshed' do
- TrendingProject.refresh!
- end
-
step 'project "Shop" has labels: "bug", "feature", "enhancement"' do
project = Project.find_by(name: "Shop")
create(:label, project: project, title: 'bug')
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 79e55a2f4f7..99fcc59ba04 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -4,6 +4,10 @@ module API
LOG_FILENAME = Rails.root.join("log", "api_json.log")
+ NO_SLASH_URL_PART_REGEX = %r{[^/]+}
+ PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
+ COMMIT_ENDPOINT_REQUIREMENTS = PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: NO_SLASH_URL_PART_REGEX).freeze
+
use GrapeLogging::Middleware::RequestLogger,
logger: Logger.new(LOG_FILENAME),
formatter: Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp.new,
@@ -96,9 +100,6 @@ module API
helpers ::API::Helpers
helpers ::API::Helpers::CommonHelpers
- NO_SLASH_URL_PART_REGEX = %r{[^/]+}
- PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
-
# Keep in alphabetical order
mount ::API::AccessRequests
mount ::API::AwardEmoji
diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb
index e79f988f549..87b9db66efd 100644
--- a/lib/api/api_guard.rb
+++ b/lib/api/api_guard.rb
@@ -42,6 +42,38 @@ module API
# Helper Methods for Grape Endpoint
module HelperMethods
+ def find_current_user
+ user =
+ find_user_from_private_token ||
+ find_user_from_oauth_token ||
+ find_user_from_warden
+
+ return nil unless user
+
+ raise UnauthorizedError unless Gitlab::UserAccess.new(user).allowed? && user.can?(:access_api)
+
+ user
+ end
+
+ def private_token
+ params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]
+ end
+
+ private
+
+ def find_user_from_private_token
+ token_string = private_token.to_s
+ return nil unless token_string.present?
+
+ user =
+ find_user_by_authentication_token(token_string) ||
+ find_user_by_personal_access_token(token_string)
+
+ raise UnauthorizedError unless user
+
+ user
+ end
+
# Invokes the doorkeeper guard.
#
# If token is presented and valid, then it sets @current_user.
@@ -60,70 +92,89 @@ module API
# scopes: (optional) scopes required for this guard.
# Defaults to empty array.
#
- def doorkeeper_guard(scopes: [])
- access_token = find_access_token
- return nil unless access_token
-
- case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
- when AccessTokenValidationService::INSUFFICIENT_SCOPE
- raise InsufficientScopeError.new(scopes)
-
- when AccessTokenValidationService::EXPIRED
- raise ExpiredError
+ def find_user_from_oauth_token
+ access_token = find_oauth_access_token
+ return unless access_token
- when AccessTokenValidationService::REVOKED
- raise RevokedError
+ find_user_by_access_token(access_token)
+ end
- when AccessTokenValidationService::VALID
- User.find(access_token.resource_owner_id)
- end
+ def find_user_by_authentication_token(token_string)
+ User.find_by_authentication_token(token_string)
end
- def find_user_by_private_token(scopes: [])
- token_string = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s
+ def find_user_by_personal_access_token(token_string)
+ access_token = PersonalAccessToken.find_by_token(token_string)
+ return unless access_token
- return nil unless token_string.present?
+ find_user_by_access_token(access_token)
+ end
- user =
- find_user_by_authentication_token(token_string) ||
- find_user_by_personal_access_token(token_string, scopes)
+ # Check the Rails session for valid authentication details
+ def find_user_from_warden
+ warden.try(:authenticate) if verified_request?
+ end
- raise UnauthorizedError unless user
+ def warden
+ env['warden']
+ end
- user
+ # Check if the request is GET/HEAD, or if CSRF token is valid.
+ def verified_request?
+ Gitlab::RequestForgeryProtection.verified?(env)
end
- private
+ def find_oauth_access_token
+ return @oauth_access_token if defined?(@oauth_access_token)
- def find_user_by_authentication_token(token_string)
- User.find_by_authentication_token(token_string)
- end
+ token = Doorkeeper::OAuth::Token.from_request(doorkeeper_request, *Doorkeeper.configuration.access_token_methods)
+ return @oauth_access_token = nil unless token
- def find_user_by_personal_access_token(token_string, scopes)
- access_token = PersonalAccessToken.active.find_by_token(token_string)
- return unless access_token
+ @oauth_access_token = OauthAccessToken.by_token(token)
+ raise UnauthorizedError unless @oauth_access_token
- if AccessTokenValidationService.new(access_token, request: request).include_any_scope?(scopes)
- User.find(access_token.user_id)
- end
+ @oauth_access_token.revoke_previous_refresh_token!
+ @oauth_access_token
end
- def find_access_token
- return @access_token if defined?(@access_token)
+ def find_user_by_access_token(access_token)
+ scopes = scopes_registered_for_endpoint
- token = Doorkeeper::OAuth::Token.from_request(doorkeeper_request, *Doorkeeper.configuration.access_token_methods)
- return @access_token = nil unless token
+ case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
+ when AccessTokenValidationService::INSUFFICIENT_SCOPE
+ raise InsufficientScopeError.new(scopes)
+
+ when AccessTokenValidationService::EXPIRED
+ raise ExpiredError
- @access_token = Doorkeeper::AccessToken.by_token(token)
- raise UnauthorizedError unless @access_token
+ when AccessTokenValidationService::REVOKED
+ raise RevokedError
- @access_token.revoke_previous_refresh_token!
- @access_token
+ when AccessTokenValidationService::VALID
+ access_token.user
+ end
end
def doorkeeper_request
@doorkeeper_request ||= ActionDispatch::Request.new(env)
end
+
+ # An array of scopes that were registered (using `allow_access_with_scope`)
+ # for the current endpoint class. It also returns scopes registered on
+ # `API::API`, since these are meant to apply to all API routes.
+ def scopes_registered_for_endpoint
+ @scopes_registered_for_endpoint ||=
+ begin
+ endpoint_classes = [options[:for].presence, ::API::API].compact
+ endpoint_classes.reduce([]) do |memo, endpoint|
+ if endpoint.respond_to?(:allowed_scopes)
+ memo.concat(endpoint.allowed_scopes)
+ else
+ memo
+ end
+ end
+ end
+ end
end
module ClassMethods
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index 61a2d688282..19152c9f395 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -8,6 +8,16 @@ module API
before { authorize! :download_code, user_project }
+ helpers do
+ def find_branch!(branch_name)
+ begin
+ user_project.repository.find_branch(branch_name) || not_found!('Branch')
+ rescue Gitlab::Git::CommandError
+ render_api_error!('The branch refname is invalid', 400)
+ end
+ end
+ end
+
params do
requires :id, type: String, desc: 'The ID of a project'
end
@@ -38,8 +48,7 @@ module API
user_project.repository.branch_exists?(params[:branch]) ? status(204) : status(404)
end
get do
- branch = user_project.repository.find_branch(params[:branch])
- not_found!('Branch') unless branch
+ branch = find_branch!(params[:branch])
present branch, with: Entities::Branch, project: user_project
end
@@ -60,8 +69,7 @@ module API
put ':id/repository/branches/:branch/protect', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
authorize_admin_project
- branch = user_project.repository.find_branch(params[:branch])
- not_found!('Branch') unless branch
+ branch = find_branch!(params[:branch])
protected_branch = user_project.protected_branches.find_by(name: branch.name)
@@ -96,8 +104,7 @@ module API
put ':id/repository/branches/:branch/unprotect', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
authorize_admin_project
- branch = user_project.repository.find_branch(params[:branch])
- not_found!("Branch") unless branch
+ branch = find_branch!(params[:branch])
protected_branch = user_project.protected_branches.find_by(name: branch.name)
protected_branch&.destroy
@@ -133,8 +140,7 @@ module API
delete ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
authorize_push_project
- branch = user_project.repository.find_branch(params[:branch])
- not_found!('Branch') unless branch
+ branch = find_branch!(params[:branch])
commit = user_project.repository.commit(branch.dereferenced_target)
diff --git a/lib/api/commits.rb b/lib/api/commits.rb
index 4af37a2ad1d..2685dc27252 100644
--- a/lib/api/commits.rb
+++ b/lib/api/commits.rb
@@ -4,8 +4,6 @@ module API
class Commits < Grape::API
include PaginationParams
- COMMIT_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: API::NO_SLASH_URL_PART_REGEX)
-
before { authorize! :download_code, user_project }
params do
@@ -85,7 +83,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ get ':id/repository/commits/:sha', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -99,7 +97,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha/diff', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ get ':id/repository/commits/:sha/diff', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -115,7 +113,7 @@ module API
use :pagination
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ get ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -132,7 +130,7 @@ module API
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag to be cherry picked'
requires :branch, type: String, desc: 'The name of the branch'
end
- post ':id/repository/commits/:sha/cherry_pick', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ post ':id/repository/commits/:sha/cherry_pick', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
commit = user_project.commit(params[:sha])
@@ -169,7 +167,7 @@ module API
requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
end
end
- post ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ post ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -186,7 +184,7 @@ module API
lines.each do |line|
next unless line.new_pos == params[:line] && line.type == params[:line_type]
- break opts[:line_code] = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos)
+ break opts[:line_code] = Gitlab::Git.diff_line_code(diff.new_path, line.new_pos, line.old_pos)
end
break if opts[:line_code]
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index a87297a604c..2b316b58ed9 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -3,8 +3,6 @@ module API
include Gitlab::Utils
include Helpers::Pagination
- UnauthorizedError = Class.new(StandardError)
-
SUDO_HEADER = "HTTP_SUDO".freeze
SUDO_PARAM = :sudo
@@ -379,47 +377,16 @@ module API
private
- def private_token
- params[APIGuard::PRIVATE_TOKEN_PARAM] || env[APIGuard::PRIVATE_TOKEN_HEADER]
- end
-
- def warden
- env['warden']
- end
-
- # Check if the request is GET/HEAD, or if CSRF token is valid.
- def verified_request?
- Gitlab::RequestForgeryProtection.verified?(env)
- end
-
- # Check the Rails session for valid authentication details
- def find_user_from_warden
- warden.try(:authenticate) if verified_request?
- end
-
def initial_current_user
return @initial_current_user if defined?(@initial_current_user)
begin
@initial_current_user = Gitlab::Auth::UniqueIpsLimiter.limit_user! { find_current_user }
- rescue APIGuard::UnauthorizedError, UnauthorizedError
+ rescue APIGuard::UnauthorizedError
unauthorized!
end
end
- def find_current_user
- user =
- find_user_by_private_token(scopes: scopes_registered_for_endpoint) ||
- doorkeeper_guard(scopes: scopes_registered_for_endpoint) ||
- find_user_from_warden
-
- return nil unless user
-
- raise UnauthorizedError unless Gitlab::UserAccess.new(user).allowed? && user.can?(:access_api)
-
- user
- end
-
def sudo!
return unless sudo_identifier
return unless initial_current_user
@@ -479,22 +446,5 @@ module API
exception.status == 500
end
-
- # An array of scopes that were registered (using `allow_access_with_scope`)
- # for the current endpoint class. It also returns scopes registered on
- # `API::API`, since these are meant to apply to all API routes.
- def scopes_registered_for_endpoint
- @scopes_registered_for_endpoint ||=
- begin
- endpoint_classes = [options[:for].presence, ::API::API].compact
- endpoint_classes.reduce([]) do |memo, endpoint|
- if endpoint.respond_to?(:allowed_scopes)
- memo.concat(endpoint.allowed_scopes)
- else
- memo
- end
- end
- end
- end
end
end
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index ceee3226732..7887b886c03 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -57,7 +57,7 @@ module API
desc 'Get raw blob contents from the repository'
params do
- requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+ requires :sha, type: String, desc: 'The commit hash'
end
get ':id/repository/blobs/:sha/raw' do
assign_blob_vars!
@@ -67,7 +67,7 @@ module API
desc 'Get a blob from the repository'
params do
- requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+ requires :sha, type: String, desc: 'The commit hash'
end
get ':id/repository/blobs/:sha' do
assign_blob_vars!
diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb
index c189d486f50..f493fd7c7ec 100644
--- a/lib/api/v3/builds.rb
+++ b/lib/api/v3/builds.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
params :optional_scope do
optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show',
diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb
index 345cb7e7c11..ed206a6def0 100644
--- a/lib/api/v3/commits.rb
+++ b/lib/api/v3/commits.rb
@@ -11,7 +11,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a project repository commits' do
success ::API::Entities::Commit
end
@@ -72,7 +72,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ":id/repository/commits/:sha" do
+ get ":id/repository/commits/:sha", requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! "Commit" unless commit
@@ -86,7 +86,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ":id/repository/commits/:sha/diff" do
+ get ":id/repository/commits/:sha/diff", requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! "Commit" unless commit
@@ -102,7 +102,7 @@ module API
use :pagination
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha/comments' do
+ get ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -119,7 +119,7 @@ module API
requires :sha, type: String, desc: 'A commit sha to be cherry picked'
requires :branch, type: String, desc: 'The name of the branch'
end
- post ':id/repository/commits/:sha/cherry_pick' do
+ post ':id/repository/commits/:sha/cherry_pick', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
commit = user_project.commit(params[:sha])
@@ -156,7 +156,7 @@ module API
requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
end
end
- post ':id/repository/commits/:sha/comments' do
+ post ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -173,7 +173,7 @@ module API
lines.each do |line|
next unless line.new_pos == params[:line] && line.type == params[:line_type]
- break opts[:line_code] = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos)
+ break opts[:line_code] = Gitlab::Git.diff_line_code(diff.new_path, line.new_pos, line.old_pos)
end
break if opts[:line_code]
diff --git a/lib/api/v3/repositories.rb b/lib/api/v3/repositories.rb
index 41a7c6b83ae..f9a47101e27 100644
--- a/lib/api/v3/repositories.rb
+++ b/lib/api/v3/repositories.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
def handle_project_member_errors(errors)
if errors[:project_access].any?
@@ -43,7 +43,7 @@ module API
requires :sha, type: String, desc: 'The commit, branch name, or tag name'
requires :filepath, type: String, desc: 'The path to the file to display'
end
- get [":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob"] do
+ get [":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob"], requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
repo = user_project.repository
commit = repo.commit(params[:sha])
not_found! "Commit" unless commit
@@ -56,7 +56,7 @@ module API
params do
requires :sha, type: String, desc: 'The commit, branch name, or tag name'
end
- get ':id/repository/raw_blobs/:sha' do
+ get ':id/repository/raw_blobs/:sha', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
repo = user_project.repository
begin
blob = Gitlab::Git::Blob.raw(repo, params[:sha])
diff --git a/lib/backup/manager.rb b/lib/backup/manager.rb
index 3cf3939994a..092e82bd4d1 100644
--- a/lib/backup/manager.rb
+++ b/lib/backup/manager.rb
@@ -101,50 +101,50 @@ module Backup
end
def unpack
- Dir.chdir(backup_path)
-
- # check for existing backups in the backup dir
- if backup_file_list.empty?
- $progress.puts "No backups found in #{backup_path}"
- $progress.puts "Please make sure that file name ends with #{FILE_NAME_SUFFIX}"
- exit 1
- elsif backup_file_list.many? && ENV["BACKUP"].nil?
- $progress.puts 'Found more than one backup, please specify which one you want to restore:'
- $progress.puts 'rake gitlab:backup:restore BACKUP=timestamp_of_backup'
- exit 1
- end
+ Dir.chdir(backup_path) do
+ # check for existing backups in the backup dir
+ if backup_file_list.empty?
+ $progress.puts "No backups found in #{backup_path}"
+ $progress.puts "Please make sure that file name ends with #{FILE_NAME_SUFFIX}"
+ exit 1
+ elsif backup_file_list.many? && ENV["BACKUP"].nil?
+ $progress.puts 'Found more than one backup, please specify which one you want to restore:'
+ $progress.puts 'rake gitlab:backup:restore BACKUP=timestamp_of_backup'
+ exit 1
+ end
- tar_file = if ENV['BACKUP'].present?
- "#{ENV['BACKUP']}#{FILE_NAME_SUFFIX}"
- else
- backup_file_list.first
- end
+ tar_file = if ENV['BACKUP'].present?
+ "#{ENV['BACKUP']}#{FILE_NAME_SUFFIX}"
+ else
+ backup_file_list.first
+ end
- unless File.exist?(tar_file)
- $progress.puts "The backup file #{tar_file} does not exist!"
- exit 1
- end
+ unless File.exist?(tar_file)
+ $progress.puts "The backup file #{tar_file} does not exist!"
+ exit 1
+ end
- $progress.print 'Unpacking backup ... '
+ $progress.print 'Unpacking backup ... '
- unless Kernel.system(*%W(tar -xf #{tar_file}))
- $progress.puts 'unpacking backup failed'.color(:red)
- exit 1
- else
- $progress.puts 'done'.color(:green)
- end
+ unless Kernel.system(*%W(tar -xf #{tar_file}))
+ $progress.puts 'unpacking backup failed'.color(:red)
+ exit 1
+ else
+ $progress.puts 'done'.color(:green)
+ end
- ENV["VERSION"] = "#{settings[:db_version]}" if settings[:db_version].to_i > 0
-
- # restoring mismatching backups can lead to unexpected problems
- if settings[:gitlab_version] != Gitlab::VERSION
- $progress.puts 'GitLab version mismatch:'.color(:red)
- $progress.puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".color(:red)
- $progress.puts ' Please switch to the following version and try again:'.color(:red)
- $progress.puts " version: #{settings[:gitlab_version]}".color(:red)
- $progress.puts
- $progress.puts "Hint: git checkout v#{settings[:gitlab_version]}"
- exit 1
+ ENV["VERSION"] = "#{settings[:db_version]}" if settings[:db_version].to_i > 0
+
+ # restoring mismatching backups can lead to unexpected problems
+ if settings[:gitlab_version] != Gitlab::VERSION
+ $progress.puts 'GitLab version mismatch:'.color(:red)
+ $progress.puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".color(:red)
+ $progress.puts ' Please switch to the following version and try again:'.color(:red)
+ $progress.puts " version: #{settings[:gitlab_version]}".color(:red)
+ $progress.puts
+ $progress.puts "Hint: git checkout v#{settings[:gitlab_version]}"
+ exit 1
+ end
end
end
diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb
index d8c8deea628..6786b9d07b6 100644
--- a/lib/banzai/filter/sanitization_filter.rb
+++ b/lib/banzai/filter/sanitization_filter.rb
@@ -75,9 +75,19 @@ module Banzai
begin
node['href'] = node['href'].strip
uri = Addressable::URI.parse(node['href'])
- uri.scheme = uri.scheme.downcase if uri.scheme
- node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(uri.scheme)
+ return unless uri.scheme
+
+ # Remove all invalid scheme characters before checking against the
+ # list of unsafe protocols.
+ #
+ # See https://tools.ietf.org/html/rfc3986#section-3.1
+ scheme = uri.scheme
+ .strip
+ .downcase
+ .gsub(/[^A-Za-z0-9\+\.\-]+/, '')
+
+ node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(scheme)
rescue Addressable::URI::InvalidURIError
node.remove_attribute('href')
end
diff --git a/lib/github/representation/comment.rb b/lib/github/representation/comment.rb
index 1b5be91461b..83bf0b5310d 100644
--- a/lib/github/representation/comment.rb
+++ b/lib/github/representation/comment.rb
@@ -23,7 +23,7 @@ module Github
private
def generate_line_code(line)
- Gitlab::Diff::LineCode.generate(file_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(file_path, line.new_pos, line.old_pos)
end
def on_diff?
diff --git a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
index 8e5c95f2287..380802258f5 100644
--- a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
+++ b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
@@ -81,6 +81,7 @@ module Gitlab
def single_diff_rows(merge_request_diff)
sha_attribute = Gitlab::Database::ShaAttribute.new
commits = YAML.load(merge_request_diff.st_commits) rescue []
+ commits ||= []
commit_rows = commits.map.with_index do |commit, index|
commit_hash = commit.to_hash.with_indifferent_access.except(:parent_ids)
diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb
index d1979bb7ed3..033ecd15749 100644
--- a/lib/gitlab/bitbucket_import/importer.rb
+++ b/lib/gitlab/bitbucket_import/importer.rb
@@ -241,7 +241,7 @@ module Gitlab
end
def generate_line_code(pr_comment)
- Gitlab::Diff::LineCode.generate(pr_comment.file_path, pr_comment.new_pos, pr_comment.old_pos)
+ Gitlab::Git.diff_line_code(pr_comment.file_path, pr_comment.new_pos, pr_comment.old_pos)
end
def pull_request_comment_attributes(comment)
diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb
index 98dfe900044..2a0cb640a14 100644
--- a/lib/gitlab/conflict/file.rb
+++ b/lib/gitlab/conflict/file.rb
@@ -4,82 +4,29 @@ module Gitlab
include Gitlab::Routing
include IconsHelper
- MissingResolution = Class.new(ResolutionError)
-
CONTEXT_LINES = 3
- attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository
-
- def initialize(merge_file_result, conflict, merge_request:)
- @merge_file_result = merge_file_result
- @their_path = conflict[:theirs][:path]
- @our_path = conflict[:ours][:path]
- @our_mode = conflict[:ours][:mode]
- @merge_request = merge_request
- @repository = merge_request.project.repository
- @match_line_headers = {}
- end
-
- def content
- merge_file_result[:data]
- end
+ attr_reader :merge_request
- def our_blob
- @our_blob ||= repository.blob_at(merge_request.diff_refs.head_sha, our_path)
- end
+ # 'raw' holds the Gitlab::Git::Conflict::File that this instance wraps
+ attr_reader :raw
- def type
- lines unless @type
+ delegate :type, :content, :their_path, :our_path, :our_mode, :our_blob, :repository, to: :raw
- @type.inquiry
+ def initialize(raw, merge_request:)
+ @raw = raw
+ @merge_request = merge_request
+ @match_line_headers = {}
end
- # Array of Gitlab::Diff::Line objects
def lines
return @lines if defined?(@lines)
- begin
- @type = 'text'
- @lines = Gitlab::Conflict::Parser.new.parse(content,
- our_path: our_path,
- their_path: their_path,
- parent_file: self)
- rescue Gitlab::Conflict::Parser::ParserError
- @type = 'text-editor'
- @lines = nil
- end
+ @lines = raw.lines.nil? ? nil : map_raw_lines(raw.lines)
end
def resolve_lines(resolution)
- section_id = nil
-
- lines.map do |line|
- unless line.type
- section_id = nil
- next line
- end
-
- section_id ||= line_code(line)
-
- case resolution[section_id]
- when 'head'
- next unless line.type == 'new'
- when 'origin'
- next unless line.type == 'old'
- else
- raise MissingResolution, "Missing resolution for section ID: #{section_id}"
- end
-
- line
- end.compact
- end
-
- def resolve_content(resolution)
- if resolution == content
- raise MissingResolution, "Resolved content has no changes for file #{our_path}"
- end
-
- resolution
+ map_raw_lines(raw.resolve_lines(resolution))
end
def highlight_lines!
@@ -163,7 +110,7 @@ module Gitlab
end
def line_code(line)
- Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(our_path, line.new_pos, line.old_pos)
end
def create_match_line(line)
@@ -227,15 +174,14 @@ module Gitlab
new_path: our_path)
end
- # Don't try to print merge_request or repository.
- def inspect
- instance_variables = [:merge_file_result, :their_path, :our_path, :our_mode, :type].map do |instance_variable|
- value = instance_variable_get("@#{instance_variable}")
+ private
- "#{instance_variable}=\"#{value}\""
+ def map_raw_lines(raw_lines)
+ raw_lines.map do |raw_line|
+ Gitlab::Diff::Line.new(raw_line[:full_line], raw_line[:type],
+ raw_line[:line_obj_index], raw_line[:line_old],
+ raw_line[:line_new], parent_file: self)
end
-
- "#<#{self.class} #{instance_variables.join(' ')}>"
end
end
end
diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb
index 90f83e0f810..fb28e80ff73 100644
--- a/lib/gitlab/conflict/file_collection.rb
+++ b/lib/gitlab/conflict/file_collection.rb
@@ -1,48 +1,29 @@
module Gitlab
module Conflict
class FileCollection
- ConflictSideMissing = Class.new(StandardError)
-
- attr_reader :merge_request, :our_commit, :their_commit, :project
-
- delegate :repository, to: :project
-
- class << self
- # We can only write when getting the merge index from the source
- # project, because we will write to that project. We don't use this all
- # the time because this fetches a ref into the source project, which
- # isn't needed for reading.
- def for_resolution(merge_request)
- project = merge_request.source_project
-
- new(merge_request, project).tap do |file_collection|
- project
- .repository
- .with_repo_branch_commit(merge_request.target_project.repository.raw_repository, merge_request.target_branch) do
-
- yield file_collection
- end
- end
- end
-
- # We don't need to do `with_repo_branch_commit` here, because the target
- # project always fetches source refs when creating merge request diffs.
- def read_only(merge_request)
- new(merge_request, merge_request.target_project)
- end
+ attr_reader :merge_request, :resolver
+
+ def initialize(merge_request)
+ source_repo = merge_request.source_project.repository.raw
+ our_commit = merge_request.source_branch_head.raw
+ their_commit = merge_request.target_branch_head.raw
+ target_repo = merge_request.target_project.repository.raw
+ @resolver = Gitlab::Git::Conflict::Resolver.new(source_repo, our_commit, target_repo, their_commit)
+ @merge_request = merge_request
end
- def merge_index
- @merge_index ||= repository.rugged.merge_commits(our_commit, their_commit)
+ def resolve(user, commit_message, files)
+ args = {
+ source_branch: merge_request.source_branch,
+ target_branch: merge_request.target_branch,
+ commit_message: commit_message || default_commit_message
+ }
+ resolver.resolve_conflicts(user, files, args)
end
def files
- @files ||= merge_index.conflicts.map do |conflict|
- raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
-
- Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]),
- conflict,
- merge_request: merge_request)
+ @files ||= resolver.conflicts.map do |conflict_file|
+ Gitlab::Conflict::File.new(conflict_file, merge_request: merge_request)
end
end
@@ -61,8 +42,8 @@ module Gitlab
end
def default_commit_message
- conflict_filenames = merge_index.conflicts.map do |conflict|
- "# #{conflict[:ours][:path]}"
+ conflict_filenames = files.map do |conflict|
+ "# #{conflict.our_path}"
end
<<EOM.chomp
@@ -72,15 +53,6 @@ Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branc
#{conflict_filenames.join("\n")}
EOM
end
-
- private
-
- def initialize(merge_request, project)
- @merge_request = merge_request
- @our_commit = merge_request.source_branch_head.raw.rugged_commit
- @their_commit = merge_request.target_branch_head.raw.rugged_commit
- @project = project
- end
end
end
end
diff --git a/lib/gitlab/conflict/parser.rb b/lib/gitlab/conflict/parser.rb
deleted file mode 100644
index e3678c914db..00000000000
--- a/lib/gitlab/conflict/parser.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-module Gitlab
- module Conflict
- class Parser
- UnresolvableError = Class.new(StandardError)
- UnmergeableFile = Class.new(UnresolvableError)
- UnsupportedEncoding = Class.new(UnresolvableError)
-
- # Recoverable errors - the conflict can be resolved in an editor, but not with
- # sections.
- ParserError = Class.new(StandardError)
- UnexpectedDelimiter = Class.new(ParserError)
- MissingEndDelimiter = Class.new(ParserError)
-
- def parse(text, our_path:, their_path:, parent_file: nil)
- validate_text!(text)
-
- line_obj_index = 0
- line_old = 1
- line_new = 1
- type = nil
- lines = []
- conflict_start = "<<<<<<< #{our_path}"
- conflict_middle = '======='
- conflict_end = ">>>>>>> #{their_path}"
-
- text.each_line.map do |line|
- full_line = line.delete("\n")
-
- if full_line == conflict_start
- validate_delimiter!(type.nil?)
-
- type = 'new'
- elsif full_line == conflict_middle
- validate_delimiter!(type == 'new')
-
- type = 'old'
- elsif full_line == conflict_end
- validate_delimiter!(type == 'old')
-
- type = nil
- elsif line[0] == '\\'
- type = 'nonewline'
- lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
- else
- lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
- line_old += 1 if type != 'new'
- line_new += 1 if type != 'old'
-
- line_obj_index += 1
- end
- end
-
- raise MissingEndDelimiter unless type.nil?
-
- lines
- end
-
- private
-
- def validate_text!(text)
- raise UnmergeableFile if text.blank? # Typically a binary file
- raise UnmergeableFile if text.length > 200.kilobytes
-
- text.force_encoding('UTF-8')
-
- raise UnsupportedEncoding unless text.valid_encoding?
- end
-
- def validate_delimiter!(condition)
- raise UnexpectedDelimiter unless condition
- end
- end
- end
-end
diff --git a/lib/gitlab/conflict/resolution_error.rb b/lib/gitlab/conflict/resolution_error.rb
deleted file mode 100644
index 0b61256b35a..00000000000
--- a/lib/gitlab/conflict/resolution_error.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-module Gitlab
- module Conflict
- ResolutionError = Class.new(StandardError)
- end
-end
diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb
index 599c3c5deab..ea5891a028a 100644
--- a/lib/gitlab/diff/file.rb
+++ b/lib/gitlab/diff/file.rb
@@ -49,7 +49,7 @@ module Gitlab
def line_code(line)
return if line.meta?
- Gitlab::Diff::LineCode.generate(file_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(file_path, line.new_pos, line.old_pos)
end
def line_for_line_code(code)
diff --git a/lib/gitlab/diff/line_code.rb b/lib/gitlab/diff/line_code.rb
deleted file mode 100644
index f3578ab3d35..00000000000
--- a/lib/gitlab/diff/line_code.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-module Gitlab
- module Diff
- class LineCode
- def self.generate(file_path, new_line_position, old_line_position)
- "#{Digest::SHA1.hexdigest(file_path)}_#{old_line_position}_#{new_line_position}"
- end
- end
- end
-end
diff --git a/lib/gitlab/diff/parser.rb b/lib/gitlab/diff/parser.rb
index 742f989c50b..7dc9cc7c281 100644
--- a/lib/gitlab/diff/parser.rb
+++ b/lib/gitlab/diff/parser.rb
@@ -17,7 +17,9 @@ module Gitlab
# without having to instantiate all the others that come after it.
Enumerator.new do |yielder|
@lines.each do |line|
- next if filename?(line)
+ # We're expecting a filename parameter only in a meta-part of the diff content
+ # when type is defined then we're already in a content-part
+ next if filename?(line) && type.nil?
full_line = line.delete("\n")
diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb
index 7b3483a7f96..99dfee3dd9b 100644
--- a/lib/gitlab/encoding_helper.rb
+++ b/lib/gitlab/encoding_helper.rb
@@ -14,9 +14,9 @@ module Gitlab
ENCODING_CONFIDENCE_THRESHOLD = 50
def encode!(message)
- return nil unless message.respond_to? :force_encoding
+ return nil unless message.respond_to?(:force_encoding)
+ return message if message.encoding == Encoding::UTF_8 && message.valid_encoding?
- # if message is utf-8 encoding, just return it
message.force_encoding("UTF-8")
return message if message.valid_encoding?
@@ -50,6 +50,9 @@ module Gitlab
end
def encode_utf8(message)
+ return nil unless message.is_a?(String)
+ return message if message.encoding == Encoding::UTF_8 && message.valid_encoding?
+
detect = CharlockHolmes::EncodingDetector.detect(message)
if detect && detect[:encoding]
begin
diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb
index a8cb7fc3fe7..0e9ef4f897c 100644
--- a/lib/gitlab/file_detector.rb
+++ b/lib/gitlab/file_detector.rb
@@ -6,31 +6,33 @@ module Gitlab
module FileDetector
PATTERNS = {
# Project files
- readme: /\Areadme/i,
- changelog: /\A(changelog|history|changes|news)/i,
- license: /\A(licen[sc]e|copying)(\..+|\z)/i,
- contributing: /\Acontributing/i,
+ readme: /\Areadme[^\/]*\z/i,
+ changelog: /\A(changelog|history|changes|news)[^\/]*\z/i,
+ license: /\A(licen[sc]e|copying)(\.[^\/]+)?\z/i,
+ contributing: /\Acontributing[^\/]*\z/i,
version: 'version',
avatar: /\Alogo\.(png|jpg|gif)\z/,
+ issue_template: /\A\.gitlab\/issue_templates\/[^\/]+\.md\z/,
+ merge_request_template: /\A\.gitlab\/merge_request_templates\/[^\/]+\.md\z/,
# Configuration files
gitignore: '.gitignore',
koding: '.koding.yml',
gitlab_ci: '.gitlab-ci.yml',
- route_map: 'route-map.yml',
+ route_map: '.gitlab/route-map.yml',
# Dependency files
- cartfile: /\ACartfile/,
+ cartfile: /\ACartfile[^\/]*\z/,
composer_json: 'composer.json',
gemfile: /\A(Gemfile|gems\.rb)\z/,
gemfile_lock: 'Gemfile.lock',
- gemspec: /\.gemspec\z/,
+ gemspec: /\A[^\/]*\.gemspec\z/,
godeps_json: 'Godeps.json',
package_json: 'package.json',
podfile: 'Podfile',
- podspec_json: /\.podspec\.json\z/,
- podspec: /\.podspec\z/,
- requirements_txt: /requirements\.txt\z/,
+ podspec_json: /\A[^\/]*\.podspec\.json\z/,
+ podspec: /\A[^\/]*\.podspec\z/,
+ requirements_txt: /\A[^\/]*requirements\.txt\z/,
yarn_lock: 'yarn.lock'
}.freeze
@@ -63,13 +65,11 @@ module Gitlab
# type_of('README.md') # => :readme
# type_of('VERSION') # => :version
def self.type_of(path)
- name = File.basename(path)
-
PATTERNS.each do |type, search|
did_match = if search.is_a?(Regexp)
- name =~ search
+ path =~ search
else
- name.casecmp(search) == 0
+ path.casecmp(search) == 0
end
return type if did_match
diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb
index c78fe63f9b5..1f31cdbc96d 100644
--- a/lib/gitlab/git.rb
+++ b/lib/gitlab/git.rb
@@ -66,6 +66,10 @@ module Gitlab
end
end
end
+
+ def diff_line_code(file_path, new_line_position, old_line_position)
+ "#{Digest::SHA1.hexdigest(file_path)}_#{old_line_position}_#{new_line_position}"
+ end
end
end
end
diff --git a/lib/gitlab/git/conflict/file.rb b/lib/gitlab/git/conflict/file.rb
new file mode 100644
index 00000000000..fc1595f1faf
--- /dev/null
+++ b/lib/gitlab/git/conflict/file.rb
@@ -0,0 +1,86 @@
+module Gitlab
+ module Git
+ module Conflict
+ class File
+ attr_reader :content, :their_path, :our_path, :our_mode, :repository
+
+ def initialize(repository, commit_oid, conflict, content)
+ @repository = repository
+ @commit_oid = commit_oid
+ @their_path = conflict[:theirs][:path]
+ @our_path = conflict[:ours][:path]
+ @our_mode = conflict[:ours][:mode]
+ @content = content
+ end
+
+ def lines
+ return @lines if defined?(@lines)
+
+ begin
+ @type = 'text'
+ @lines = Gitlab::Git::Conflict::Parser.parse(content,
+ our_path: our_path,
+ their_path: their_path)
+ rescue Gitlab::Git::Conflict::Parser::ParserError
+ @type = 'text-editor'
+ @lines = nil
+ end
+ end
+
+ def type
+ lines unless @type
+
+ @type.inquiry
+ end
+
+ def our_blob
+ # REFACTOR NOTE: the source of `commit_oid` used to be
+ # `merge_request.diff_refs.head_sha`. Instead of passing this value
+ # around the new lib structure, I decided to use `@commit_oid` which is
+ # equivalent to `merge_request.source_branch_head.raw.rugged_commit.oid`.
+ # That is what `merge_request.diff_refs.head_sha` is equivalent to when
+ # `merge_request` is not persisted (see `MergeRequest#diff_head_commit`).
+ # I think using the same oid is more consistent anyways, but if Conflicts
+ # start breaking, the change described above is a good place to look at.
+ @our_blob ||= repository.blob_at(@commit_oid, our_path)
+ end
+
+ def line_code(line)
+ Gitlab::Git.diff_line_code(our_path, line[:line_new], line[:line_old])
+ end
+
+ def resolve_lines(resolution)
+ section_id = nil
+
+ lines.map do |line|
+ unless line[:type]
+ section_id = nil
+ next line
+ end
+
+ section_id ||= line_code(line)
+
+ case resolution[section_id]
+ when 'head'
+ next unless line[:type] == 'new'
+ when 'origin'
+ next unless line[:type] == 'old'
+ else
+ raise Gitlab::Git::Conflict::Resolver::ResolutionError, "Missing resolution for section ID: #{section_id}"
+ end
+
+ line
+ end.compact
+ end
+
+ def resolve_content(resolution)
+ if resolution == content
+ raise Gitlab::Git::Conflict::Resolver::ResolutionError, "Resolved content has no changes for file #{our_path}"
+ end
+
+ resolution
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/conflict/parser.rb b/lib/gitlab/git/conflict/parser.rb
new file mode 100644
index 00000000000..3effa9d2d31
--- /dev/null
+++ b/lib/gitlab/git/conflict/parser.rb
@@ -0,0 +1,91 @@
+module Gitlab
+ module Git
+ module Conflict
+ class Parser
+ UnresolvableError = Class.new(StandardError)
+ UnmergeableFile = Class.new(UnresolvableError)
+ UnsupportedEncoding = Class.new(UnresolvableError)
+
+ # Recoverable errors - the conflict can be resolved in an editor, but not with
+ # sections.
+ ParserError = Class.new(StandardError)
+ UnexpectedDelimiter = Class.new(ParserError)
+ MissingEndDelimiter = Class.new(ParserError)
+
+ class << self
+ def parse(text, our_path:, their_path:, parent_file: nil)
+ validate_text!(text)
+
+ line_obj_index = 0
+ line_old = 1
+ line_new = 1
+ type = nil
+ lines = []
+ conflict_start = "<<<<<<< #{our_path}"
+ conflict_middle = '======='
+ conflict_end = ">>>>>>> #{their_path}"
+
+ text.each_line.map do |line|
+ full_line = line.delete("\n")
+
+ if full_line == conflict_start
+ validate_delimiter!(type.nil?)
+
+ type = 'new'
+ elsif full_line == conflict_middle
+ validate_delimiter!(type == 'new')
+
+ type = 'old'
+ elsif full_line == conflict_end
+ validate_delimiter!(type == 'old')
+
+ type = nil
+ elsif line[0] == '\\'
+ type = 'nonewline'
+ lines << {
+ full_line: full_line,
+ type: type,
+ line_obj_index: line_obj_index,
+ line_old: line_old,
+ line_new: line_new
+ }
+ else
+ lines << {
+ full_line: full_line,
+ type: type,
+ line_obj_index: line_obj_index,
+ line_old: line_old,
+ line_new: line_new
+ }
+
+ line_old += 1 if type != 'new'
+ line_new += 1 if type != 'old'
+
+ line_obj_index += 1
+ end
+ end
+
+ raise MissingEndDelimiter unless type.nil?
+
+ lines
+ end
+
+ private
+
+ def validate_text!(text)
+ raise UnmergeableFile if text.blank? # Typically a binary file
+ raise UnmergeableFile if text.length > 200.kilobytes
+
+ text.force_encoding('UTF-8')
+
+ raise UnsupportedEncoding unless text.valid_encoding?
+ end
+
+ def validate_delimiter!(condition)
+ raise UnexpectedDelimiter unless condition
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/conflict/resolver.rb b/lib/gitlab/git/conflict/resolver.rb
new file mode 100644
index 00000000000..df509c5f4ce
--- /dev/null
+++ b/lib/gitlab/git/conflict/resolver.rb
@@ -0,0 +1,91 @@
+module Gitlab
+ module Git
+ module Conflict
+ class Resolver
+ ConflictSideMissing = Class.new(StandardError)
+ ResolutionError = Class.new(StandardError)
+
+ def initialize(repository, our_commit, target_repository, their_commit)
+ @repository = repository
+ @our_commit = our_commit.rugged_commit
+ @target_repository = target_repository
+ @their_commit = their_commit.rugged_commit
+ end
+
+ def conflicts
+ @conflicts ||= begin
+ target_index = @target_repository.rugged.merge_commits(@our_commit, @their_commit)
+
+ # We don't need to do `with_repo_branch_commit` here, because the target
+ # project always fetches source refs when creating merge request diffs.
+ target_index.conflicts.map do |conflict|
+ raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
+
+ Gitlab::Git::Conflict::File.new(
+ @target_repository,
+ @our_commit.oid,
+ conflict,
+ target_index.merge_file(conflict[:ours][:path])[:data]
+ )
+ end
+ end
+ end
+
+ def resolve_conflicts(user, files, source_branch:, target_branch:, commit_message:)
+ @repository.with_repo_branch_commit(@target_repository, target_branch) do
+ files.each do |file_params|
+ conflict_file = conflict_for_path(file_params[:old_path], file_params[:new_path])
+
+ write_resolved_file_to_index(conflict_file, file_params)
+ end
+
+ unless index.conflicts.empty?
+ missing_files = index.conflicts.map { |file| file[:ours][:path] }
+
+ raise ResolutionError, "Missing resolutions for the following files: #{missing_files.join(', ')}"
+ end
+
+ commit_params = {
+ message: commit_message,
+ parents: [@our_commit, @their_commit].map(&:oid)
+ }
+
+ @repository.commit_index(user, source_branch, index, commit_params)
+ end
+ end
+
+ def conflict_for_path(old_path, new_path)
+ conflicts.find do |conflict|
+ conflict.their_path == old_path && conflict.our_path == new_path
+ end
+ end
+
+ private
+
+ # We can only write when getting the merge index from the source
+ # project, because we will write to that project. We don't use this all
+ # the time because this fetches a ref into the source project, which
+ # isn't needed for reading.
+ def index
+ @index ||= @repository.rugged.merge_commits(@our_commit, @their_commit)
+ end
+
+ def write_resolved_file_to_index(file, params)
+ if params[:sections]
+ resolved_lines = file.resolve_lines(params[:sections])
+ new_file = resolved_lines.map { |line| line[:full_line] }.join("\n")
+
+ new_file << "\n" if file.our_blob.data.ends_with?("\n")
+ elsif params[:content]
+ new_file = file.resolve_content(params[:content])
+ end
+
+ our_path = file.our_path
+
+ index.add(path: our_path, oid: @repository.rugged.write(new_file, :blob), mode: file.our_mode)
+ index.conflict_remove(our_path)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/env.rb b/lib/gitlab/git/env.rb
index f80193ac553..9d0b47a1a6d 100644
--- a/lib/gitlab/git/env.rb
+++ b/lib/gitlab/git/env.rb
@@ -11,9 +11,11 @@ module Gitlab
#
# This class is thread-safe via RequestStore.
class Env
- WHITELISTED_GIT_VARIABLES = %w[
+ WHITELISTED_VARIABLES = %w[
GIT_OBJECT_DIRECTORY
+ GIT_OBJECT_DIRECTORY_RELATIVE
GIT_ALTERNATE_OBJECT_DIRECTORIES
+ GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE
].freeze
def self.set(env)
@@ -28,12 +30,23 @@ module Gitlab
RequestStore.fetch(:gitlab_git_env) { {} }
end
+ def self.to_env_hash
+ env = {}
+
+ all.compact.each do |key, value|
+ value = value.join(File::PATH_SEPARATOR) if value.is_a?(Array)
+ env[key.to_s] = value
+ end
+
+ env
+ end
+
def self.[](key)
all[key]
end
def self.whitelist_git_env(env)
- env.select { |key, _| WHITELISTED_GIT_VARIABLES.include?(key.to_s) }.with_indifferent_access
+ env.select { |key, _| WHITELISTED_VARIABLES.include?(key.to_s) }.with_indifferent_access
end
end
end
diff --git a/lib/gitlab/git/operation_service.rb b/lib/gitlab/git/operation_service.rb
index d835dcca8ba..ab94ba8a73a 100644
--- a/lib/gitlab/git/operation_service.rb
+++ b/lib/gitlab/git/operation_service.rb
@@ -3,9 +3,17 @@ module Gitlab
class OperationService
include Gitlab::Git::Popen
- WithBranchResult = Struct.new(:newrev, :repo_created, :branch_created) do
+ BranchUpdate = Struct.new(:newrev, :repo_created, :branch_created) do
alias_method :repo_created?, :repo_created
alias_method :branch_created?, :branch_created
+
+ def self.from_gitaly(branch_update)
+ new(
+ branch_update.commit_id,
+ branch_update.repo_created,
+ branch_update.branch_created
+ )
+ end
end
attr_reader :user, :repository
@@ -112,7 +120,7 @@ module Gitlab
ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name
update_ref_in_hooks(ref, newrev, oldrev)
- WithBranchResult.new(newrev, was_empty, was_empty || Gitlab::Git.blank_ref?(oldrev))
+ BranchUpdate.new(newrev, was_empty, was_empty || Gitlab::Git.blank_ref?(oldrev))
end
def find_oldrev_from_branch(newrev, branch)
diff --git a/lib/gitlab/git/popen.rb b/lib/gitlab/git/popen.rb
index 3d2fc471d28..b45da6020ee 100644
--- a/lib/gitlab/git/popen.rb
+++ b/lib/gitlab/git/popen.rb
@@ -5,6 +5,8 @@ require 'open3'
module Gitlab
module Git
module Popen
+ FAST_GIT_PROCESS_TIMEOUT = 15.seconds
+
def popen(cmd, path, vars = {})
unless cmd.is_a?(Array)
raise "System commands must be given as an array of strings"
@@ -27,6 +29,67 @@ module Gitlab
[@cmd_output, @cmd_status]
end
+
+ def popen_with_timeout(cmd, timeout, path, vars = {})
+ unless cmd.is_a?(Array)
+ raise "System commands must be given as an array of strings"
+ end
+
+ path ||= Dir.pwd
+ vars['PWD'] = path
+
+ unless File.directory?(path)
+ FileUtils.mkdir_p(path)
+ end
+
+ rout, wout = IO.pipe
+ rerr, werr = IO.pipe
+
+ pid = Process.spawn(vars, *cmd, out: wout, err: werr, chdir: path, pgroup: true)
+
+ begin
+ status = process_wait_with_timeout(pid, timeout)
+
+ # close write ends so we could read them
+ wout.close
+ werr.close
+
+ cmd_output = rout.readlines.join
+ cmd_output << rerr.readlines.join # Copying the behaviour of `popen` which merges stderr into output
+
+ [cmd_output, status.exitstatus]
+ rescue Timeout::Error => e
+ kill_process_group_for_pid(pid)
+
+ raise e
+ ensure
+ wout.close unless wout.closed?
+ werr.close unless werr.closed?
+
+ rout.close
+ rerr.close
+ end
+ end
+
+ def process_wait_with_timeout(pid, timeout)
+ deadline = timeout.seconds.from_now
+ wait_time = 0.01
+
+ while deadline > Time.now
+ sleep(wait_time)
+ _, status = Process.wait2(pid, Process::WNOHANG)
+
+ return status unless status.nil?
+ end
+
+ raise Timeout::Error, "Timeout waiting for process ##{pid}"
+ end
+
+ def kill_process_group_for_pid(pid)
+ Process.kill("KILL", -pid)
+ Process.wait(pid)
+ rescue Errno::ESRCH
+ end
end
end
end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index fa13920a3f3..59a54b48ed9 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -12,6 +12,10 @@ module Gitlab
GIT_OBJECT_DIRECTORY
GIT_ALTERNATE_OBJECT_DIRECTORIES
].freeze
+ ALLOWED_OBJECT_RELATIVE_DIRECTORIES_VARIABLES = %w[
+ GIT_OBJECT_DIRECTORY_RELATIVE
+ GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE
+ ].freeze
SEARCH_CONTEXT_LINES = 3
NoRepository = Class.new(StandardError)
@@ -700,7 +704,17 @@ module Gitlab
tags.find { |tag| tag.name == name }
end
- def merge(user, source_sha, target_branch, message)
+ def merge(user, source_sha, target_branch, message, &block)
+ gitaly_migrate(:operation_user_merge_branch) do |is_enabled|
+ if is_enabled
+ gitaly_operation_client.user_merge_branch(user, source_sha, target_branch, message, &block)
+ else
+ rugged_merge(user, source_sha, target_branch, message, &block)
+ end
+ end
+ end
+
+ def rugged_merge(user, source_sha, target_branch, message)
committer = Gitlab::Git.committer_hash(email: user.email, name: user.name)
OperationService.new(user, self).with_branch(target_branch) do |start_commit|
@@ -1058,6 +1072,13 @@ module Gitlab
end
# Refactoring aid; allows us to copy code from app/models/repository.rb
+ def run_git_with_timeout(args, timeout, env: {})
+ circuit_breaker.perform do
+ popen_with_timeout([Gitlab.config.git.bin_path, *args], timeout, path, env)
+ end
+ end
+
+ # Refactoring aid; allows us to copy code from app/models/repository.rb
def commit(ref = 'HEAD')
Gitlab::Git::Commit.find(self, ref)
end
@@ -1082,6 +1103,30 @@ module Gitlab
@has_visible_content = has_local_branches?
end
+ def fetch(remote = 'origin')
+ args = %W(#{Gitlab.config.git.bin_path} fetch #{remote})
+
+ popen(args, @path).last.zero?
+ end
+
+ def blob_at(sha, path)
+ Gitlab::Git::Blob.find(self, sha, path) unless Gitlab::Git.blank_ref?(sha)
+ end
+
+ def commit_index(user, branch_name, index, options)
+ committer = user_to_committer(user)
+
+ OperationService.new(user, self).with_branch(branch_name) do
+ commit_params = options.merge(
+ tree: index.write_tree(rugged),
+ author: committer,
+ committer: committer
+ )
+
+ create_commit(commit_params)
+ end
+ end
+
def gitaly_repository
Gitlab::GitalyClient::Util.repository(@storage, @relative_path, @gl_repository)
end
@@ -1220,7 +1265,16 @@ module Gitlab
end
def alternate_object_directories
- Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_DIRECTORIES_VARIABLES).compact
+ relative_paths = Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_RELATIVE_DIRECTORIES_VARIABLES).flatten.compact
+
+ if relative_paths.any?
+ relative_paths.map { |d| File.join(path, d) }
+ else
+ Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_DIRECTORIES_VARIABLES)
+ .flatten
+ .compact
+ .flat_map { |d| d.split(File::PATH_SEPARATOR) }
+ end
end
# Get the content of a blob for a given commit. If the blob is a commit
diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb
index 92a6a672534..60b2a4ec411 100644
--- a/lib/gitlab/git/rev_list.rb
+++ b/lib/gitlab/git/rev_list.rb
@@ -28,7 +28,7 @@ module Gitlab
private
def execute(args)
- output, status = popen(args, nil, Gitlab::Git::Env.all.stringify_keys)
+ output, status = popen(args, nil, Gitlab::Git::Env.to_env_hash)
unless status.zero?
raise "Got a non-zero exit code while calling out `#{args.join(' ')}`: #{output}"
diff --git a/lib/gitlab/git/storage/circuit_breaker.rb b/lib/gitlab/git/storage/circuit_breaker.rb
index 1eaa2d83fb6..0456ad9a1f3 100644
--- a/lib/gitlab/git/storage/circuit_breaker.rb
+++ b/lib/gitlab/git/storage/circuit_breaker.rb
@@ -2,15 +2,13 @@ module Gitlab
module Git
module Storage
class CircuitBreaker
+ include CircuitBreakerSettings
+
FailureInfo = Struct.new(:last_failure, :failure_count)
attr_reader :storage,
:hostname,
- :storage_path,
- :failure_count_threshold,
- :failure_wait_time,
- :failure_reset_time,
- :storage_timeout
+ :storage_path
delegate :last_failure, :failure_count, to: :failure_info
@@ -18,7 +16,7 @@ module Gitlab
pattern = "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}*"
Gitlab::Git::Storage.redis.with do |redis|
- all_storage_keys = redis.keys(pattern)
+ all_storage_keys = redis.scan_each(match: pattern).to_a
redis.del(*all_storage_keys) unless all_storage_keys.empty?
end
@@ -53,10 +51,6 @@ module Gitlab
config = Gitlab.config.repositories.storages[@storage]
@storage_path = config['path']
- @failure_count_threshold = config['failure_count_threshold']
- @failure_wait_time = config['failure_wait_time']
- @failure_reset_time = config['failure_reset_time']
- @storage_timeout = config['storage_timeout']
end
def perform
diff --git a/lib/gitlab/git/storage/circuit_breaker_settings.rb b/lib/gitlab/git/storage/circuit_breaker_settings.rb
new file mode 100644
index 00000000000..d2313fe7c1b
--- /dev/null
+++ b/lib/gitlab/git/storage/circuit_breaker_settings.rb
@@ -0,0 +1,29 @@
+module Gitlab
+ module Git
+ module Storage
+ module CircuitBreakerSettings
+ def failure_count_threshold
+ application_settings.circuitbreaker_failure_count_threshold
+ end
+
+ def failure_wait_time
+ application_settings.circuitbreaker_failure_wait_time
+ end
+
+ def failure_reset_time
+ application_settings.circuitbreaker_failure_reset_time
+ end
+
+ def storage_timeout
+ application_settings.circuitbreaker_storage_timeout
+ end
+
+ private
+
+ def application_settings
+ Gitlab::CurrentSettings.current_application_settings
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/storage/health.rb b/lib/gitlab/git/storage/health.rb
index 1564e94b7f7..7049772fe3b 100644
--- a/lib/gitlab/git/storage/health.rb
+++ b/lib/gitlab/git/storage/health.rb
@@ -23,26 +23,36 @@ module Gitlab
end
end
- def self.all_keys_for_storages(storage_names, redis)
+ private_class_method def self.all_keys_for_storages(storage_names, redis)
keys_per_storage = {}
redis.pipelined do
storage_names.each do |storage_name|
pattern = pattern_for_storage(storage_name)
+ matched_keys = redis.scan_each(match: pattern)
- keys_per_storage[storage_name] = redis.keys(pattern)
+ keys_per_storage[storage_name] = matched_keys
end
end
- keys_per_storage
+ # We need to make sure each lazy-loaded `Enumerator` for matched keys
+ # is loaded into an array.
+ #
+ # Otherwise it would be loaded in the second `Redis#pipelined` block
+ # within `.load_for_keys`. In this pipelined call, the active
+ # Redis-client changes again, so the values would not be available
+ # until the end of that pipelined-block.
+ keys_per_storage.each do |storage_name, key_future|
+ keys_per_storage[storage_name] = key_future.to_a
+ end
end
- def self.load_for_keys(keys_per_storage, redis)
+ private_class_method def self.load_for_keys(keys_per_storage, redis)
info_for_keys = {}
redis.pipelined do
keys_per_storage.each do |storage_name, keys_future|
- info_for_storage = keys_future.value.map do |key|
+ info_for_storage = keys_future.map do |key|
{ name: key, failure_count: redis.hget(key, :failure_count) }
end
diff --git a/lib/gitlab/git/storage/null_circuit_breaker.rb b/lib/gitlab/git/storage/null_circuit_breaker.rb
index 297c043d054..60c6791a7e4 100644
--- a/lib/gitlab/git/storage/null_circuit_breaker.rb
+++ b/lib/gitlab/git/storage/null_circuit_breaker.rb
@@ -2,15 +2,14 @@ module Gitlab
module Git
module Storage
class NullCircuitBreaker
+ include CircuitBreakerSettings
+
# These will have actual values
attr_reader :storage,
:hostname
# These will always have nil values
- attr_reader :storage_path,
- :failure_wait_time,
- :failure_reset_time,
- :storage_timeout
+ attr_reader :storage_path
def initialize(storage, hostname, error: nil)
@storage = storage
@@ -26,16 +25,12 @@ module Gitlab
!!@error
end
- def failure_count_threshold
- 1
- end
-
def last_failure
circuit_broken? ? Time.now : nil
end
def failure_count
- circuit_broken? ? 1 : 0
+ circuit_broken? ? failure_count_threshold : 0
end
def failure_info
diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb
index d651c931a38..e7b2f52a552 100644
--- a/lib/gitlab/git/wiki.rb
+++ b/lib/gitlab/git/wiki.rb
@@ -23,14 +23,14 @@ module Gitlab
end
def write_page(name, format, content, commit_details)
- assert_type!(format, Symbol)
- assert_type!(commit_details, CommitDetails)
-
- gollum_wiki.write_page(name, format, content, commit_details.to_h)
-
- nil
- rescue Gollum::DuplicatePageError => e
- raise Gitlab::Git::Wiki::DuplicatePageError, e.message
+ @repository.gitaly_migrate(:wiki_write_page) do |is_enabled|
+ if is_enabled
+ gitaly_write_page(name, format, content, commit_details)
+ gollum_wiki.clear_cache
+ else
+ gollum_write_page(name, format, content, commit_details)
+ end
+ end
end
def delete_page(page_path, commit_details)
@@ -110,6 +110,25 @@ module Gitlab
raise ArgumentError, "expected a #{klass}, got #{object.inspect}"
end
end
+
+ def gitaly_wiki_client
+ @gitaly_wiki_client ||= Gitlab::GitalyClient::WikiService.new(@repository)
+ end
+
+ def gollum_write_page(name, format, content, commit_details)
+ assert_type!(format, Symbol)
+ assert_type!(commit_details, CommitDetails)
+
+ gollum_wiki.write_page(name, format, content, commit_details.to_h)
+
+ nil
+ rescue Gollum::DuplicatePageError => e
+ raise Gitlab::Git::Wiki::DuplicatePageError, e.message
+ end
+
+ def gitaly_write_page(name, format, content, commit_details)
+ gitaly_wiki_client.write_page(name, format, content, commit_details)
+ end
end
end
end
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index cf36106e23d..6c1ae19ff11 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -70,15 +70,27 @@ module Gitlab
# All Gitaly RPC call sites should use GitalyClient.call. This method
# makes sure that per-request authentication headers are set.
+ #
+ # This method optionally takes a block which receives the keyword
+ # arguments hash 'kwargs' that will be passed to gRPC. This allows the
+ # caller to modify or augment the keyword arguments. The block must
+ # return a hash.
+ #
+ # For example:
+ #
+ # GitalyClient.call(storage, service, rpc, request) do |kwargs|
+ # kwargs.merge(deadline: Time.now + 10)
+ # end
+ #
def self.call(storage, service, rpc, request)
enforce_gitaly_request_limits(:call)
- metadata = request_metadata(storage)
- metadata = yield(metadata) if block_given?
- stub(service, storage).__send__(rpc, request, metadata) # rubocop:disable GitlabSecurity/PublicSend
+ kwargs = request_kwargs(storage)
+ kwargs = yield(kwargs) if block_given?
+ stub(service, storage).__send__(rpc, request, kwargs) # rubocop:disable GitlabSecurity/PublicSend
end
- def self.request_metadata(storage)
+ def self.request_kwargs(storage)
encoded_token = Base64.strict_encode64(token(storage).to_s)
metadata = {
'authorization' => "Bearer #{encoded_token}",
diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb
index 81ddaf13e10..91f34011f6e 100644
--- a/lib/gitlab/gitaly_client/operation_service.rb
+++ b/lib/gitlab/gitaly_client/operation_service.rb
@@ -74,6 +74,37 @@ module Gitlab
raise Gitlab::Git::HooksService::PreReceiveError, pre_receive_error
end
end
+
+ def user_merge_branch(user, source_sha, target_branch, message)
+ request_enum = QueueEnumerator.new
+ response_enum = GitalyClient.call(
+ @repository.storage,
+ :operation_service,
+ :user_merge_branch,
+ request_enum.each
+ )
+
+ request_enum.push(
+ Gitaly::UserMergeBranchRequest.new(
+ repository: @gitaly_repo,
+ user: Util.gitaly_user(user),
+ commit_id: source_sha,
+ branch: GitalyClient.encode(target_branch),
+ message: GitalyClient.encode(message)
+ )
+ )
+
+ yield response_enum.next.commit_id
+
+ request_enum.push(Gitaly::UserMergeBranchRequest.new(apply: true))
+
+ branch_update = response_enum.next.branch_update
+ raise Gitlab::Git::CommitError.new('failed to apply merge to branch') unless branch_update.commit_id.present?
+
+ Gitlab::Git::OperationService::BranchUpdate.from_gitaly(branch_update)
+ ensure
+ request_enum.close
+ end
end
end
end
diff --git a/lib/gitlab/gitaly_client/queue_enumerator.rb b/lib/gitlab/gitaly_client/queue_enumerator.rb
new file mode 100644
index 00000000000..b8018029552
--- /dev/null
+++ b/lib/gitlab/gitaly_client/queue_enumerator.rb
@@ -0,0 +1,28 @@
+module Gitlab
+ module GitalyClient
+ class QueueEnumerator
+ def initialize
+ @queue = Queue.new
+ end
+
+ def push(elem)
+ @queue << elem
+ end
+
+ def close
+ push(:close)
+ end
+
+ def each
+ return enum_for(:each) unless block_given?
+
+ loop do
+ elem = @queue.pop
+ break if elem == :close
+
+ yield elem
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb
index da43c616b94..a1222a7e718 100644
--- a/lib/gitlab/gitaly_client/util.rb
+++ b/lib/gitlab/gitaly_client/util.rb
@@ -3,12 +3,18 @@ module Gitlab
module Util
class << self
def repository(repository_storage, relative_path, gl_repository)
+ git_object_directory = Gitlab::Git::Env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence ||
+ Gitlab::Git::Env['GIT_OBJECT_DIRECTORY'].presence
+ git_alternate_object_directories =
+ Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE']).presence ||
+ Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES']).flat_map { |d| d.split(File::PATH_SEPARATOR) }
+
Gitaly::Repository.new(
storage_name: repository_storage,
relative_path: relative_path,
gl_repository: gl_repository,
- git_object_directory: Gitlab::Git::Env['GIT_OBJECT_DIRECTORY'].to_s,
- git_alternate_object_directories: Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES'])
+ git_object_directory: git_object_directory.to_s,
+ git_alternate_object_directories: git_alternate_object_directories
)
end
diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb
new file mode 100644
index 00000000000..03afcce81f0
--- /dev/null
+++ b/lib/gitlab/gitaly_client/wiki_service.rb
@@ -0,0 +1,45 @@
+require 'stringio'
+
+module Gitlab
+ module GitalyClient
+ class WikiService
+ MAX_MSG_SIZE = 128.kilobytes.freeze
+
+ def initialize(repository)
+ @gitaly_repo = repository.gitaly_repository
+ @repository = repository
+ end
+
+ def write_page(name, format, content, commit_details)
+ request = Gitaly::WikiWritePageRequest.new(
+ repository: @gitaly_repo,
+ name: GitalyClient.encode(name),
+ format: format.to_s,
+ commit_details: Gitaly::WikiCommitDetails.new(
+ name: GitalyClient.encode(commit_details.name),
+ email: GitalyClient.encode(commit_details.email),
+ message: GitalyClient.encode(commit_details.message)
+ )
+ )
+
+ strio = StringIO.new(content)
+
+ enum = Enumerator.new do |y|
+ until strio.eof?
+ chunk = strio.read(MAX_MSG_SIZE)
+ request.content = GitalyClient.encode(chunk)
+
+ y.yield request
+
+ request = Gitaly::WikiWritePageRequest.new
+ end
+ end
+
+ response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_write_page, enum)
+ if error = response.duplicate_error.presence
+ raise Gitlab::Git::Wiki::DuplicatePageError, error
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/github_import/comment_formatter.rb b/lib/gitlab/github_import/comment_formatter.rb
index e21922070c1..8911b81ec9a 100644
--- a/lib/gitlab/github_import/comment_formatter.rb
+++ b/lib/gitlab/github_import/comment_formatter.rb
@@ -38,7 +38,7 @@ module Gitlab
end
def generate_line_code(line)
- Gitlab::Diff::LineCode.generate(file_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(file_path, line.new_pos, line.old_pos)
end
def on_diff?
diff --git a/lib/gitlab/group_hierarchy.rb b/lib/gitlab/group_hierarchy.rb
index 635f52131f9..42ded7c286f 100644
--- a/lib/gitlab/group_hierarchy.rb
+++ b/lib/gitlab/group_hierarchy.rb
@@ -17,12 +17,32 @@ module Gitlab
@model = ancestors_base.model
end
+ # Returns the set of descendants of a given relation, but excluding the given
+ # relation
+ def descendants
+ base_and_descendants.where.not(id: descendants_base.select(:id))
+ end
+
+ # Returns the set of ancestors of a given relation, but excluding the given
+ # relation
+ #
+ # Passing an `upto` will stop the recursion once the specified parent_id is
+ # reached. So all ancestors *lower* than the specified ancestor will be
+ # included.
+ def ancestors(upto: nil)
+ base_and_ancestors(upto: upto).where.not(id: ancestors_base.select(:id))
+ end
+
# Returns a relation that includes the ancestors_base set of groups
# and all their ancestors (recursively).
- def base_and_ancestors
+ #
+ # Passing an `upto` will stop the recursion once the specified parent_id is
+ # reached. So all ancestors *lower* than the specified acestor will be
+ # included.
+ def base_and_ancestors(upto: nil)
return ancestors_base unless Group.supports_nested_groups?
- read_only(base_and_ancestors_cte.apply_to(model.all))
+ read_only(base_and_ancestors_cte(upto).apply_to(model.all))
end
# Returns a relation that includes the descendants_base set of groups
@@ -78,17 +98,19 @@ module Gitlab
private
- def base_and_ancestors_cte
+ def base_and_ancestors_cte(stop_id = nil)
cte = SQL::RecursiveCTE.new(:base_and_ancestors)
cte << ancestors_base.except(:order)
# Recursively get all the ancestors of the base set.
- cte << model
+ parent_query = model
.from([groups_table, cte.table])
.where(groups_table[:id].eq(cte.table[:parent_id]))
.except(:order)
+ parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
+ cte << parent_query
cte
end
diff --git a/lib/gitlab/multi_collection_paginator.rb b/lib/gitlab/multi_collection_paginator.rb
new file mode 100644
index 00000000000..eb3c9002710
--- /dev/null
+++ b/lib/gitlab/multi_collection_paginator.rb
@@ -0,0 +1,61 @@
+module Gitlab
+ class MultiCollectionPaginator
+ attr_reader :first_collection, :second_collection, :per_page
+
+ def initialize(*collections, per_page: nil)
+ raise ArgumentError.new('Only 2 collections are supported') if collections.size != 2
+
+ @per_page = per_page || Kaminari.config.default_per_page
+ @first_collection, @second_collection = collections
+ end
+
+ def paginate(page)
+ page = page.to_i
+ paginated_first_collection(page) + paginated_second_collection(page)
+ end
+
+ def total_count
+ @total_count ||= first_collection.size + second_collection.size
+ end
+
+ private
+
+ def paginated_first_collection(page)
+ @first_collection_pages ||= Hash.new do |hash, page|
+ hash[page] = first_collection.page(page).per(per_page)
+ end
+
+ @first_collection_pages[page]
+ end
+
+ def paginated_second_collection(page)
+ @second_collection_pages ||= Hash.new do |hash, page|
+ second_collection_page = page - first_collection_page_count
+
+ offset = if second_collection_page < 1 || first_collection_page_count.zero?
+ 0
+ else
+ per_page - first_collection_last_page_size
+ end
+ hash[page] = second_collection.page(second_collection_page)
+ .per(per_page - paginated_first_collection(page).size)
+ .padding(offset)
+ end
+
+ @second_collection_pages[page]
+ end
+
+ def first_collection_page_count
+ return @first_collection_page_count if defined?(@first_collection_page_count)
+
+ first_collection_page = paginated_first_collection(0)
+ @first_collection_page_count = first_collection_page.total_pages
+ end
+
+ def first_collection_last_page_size
+ return @first_collection_last_page_size if defined?(@first_collection_last_page_size)
+
+ @first_collection_last_page_size = paginated_first_collection(first_collection_page_count).count
+ end
+ end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index 7c02c9c5c48..22f8dd669d0 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -26,7 +26,6 @@ module Gitlab
apple-touch-icon.png
assets
autocomplete
- boards
ci
dashboard
deploy.html
@@ -129,7 +128,6 @@ module Gitlab
notification_setting
pipeline_quota
projects
- subgroups
].freeze
ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES
diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb
index 0b43377a579..ae136202f0c 100644
--- a/lib/gitlab/project_template.rb
+++ b/lib/gitlab/project_template.rb
@@ -25,9 +25,9 @@ module Gitlab
end
TEMPLATES_TABLE = [
- ProjectTemplate.new('rails', 'Ruby on Rails', 'Includes a MVC structure, gemfile, rakefile, and .gitlab-ci.yml file, along with many others, to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/rails'),
- ProjectTemplate.new('spring', 'Spring', 'Includes a MVC structure, mvnw, pom.xml, and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/spring'),
- ProjectTemplate.new('express', 'NodeJS Express', 'Includes a MVC structure, and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/express')
+ ProjectTemplate.new('rails', 'Ruby on Rails', 'Includes an MVC structure, gemfile, rakefile, and .gitlab-ci.yml file, along with many others, to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/rails'),
+ ProjectTemplate.new('spring', 'Spring', 'Includes an MVC structure, mvnw, pom.xml, and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/spring'),
+ ProjectTemplate.new('express', 'NodeJS Express', 'Includes an MVC structure and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/express')
].freeze
class << self
diff --git a/lib/gitlab/quick_actions/spend_time_and_date_separator.rb b/lib/gitlab/quick_actions/spend_time_and_date_separator.rb
new file mode 100644
index 00000000000..3f52402b31f
--- /dev/null
+++ b/lib/gitlab/quick_actions/spend_time_and_date_separator.rb
@@ -0,0 +1,54 @@
+module Gitlab
+ module QuickActions
+ # This class takes spend command argument
+ # and separates date and time from spend command arguments if it present
+ # example:
+ # spend_command_time_and_date = "15m 2017-01-02"
+ # SpendTimeAndDateSeparator.new(spend_command_time_and_date).execute
+ # => [900, Mon, 02 Jan 2017]
+ # if date doesn't present return time with current date
+ # in other cases return nil
+ class SpendTimeAndDateSeparator
+ DATE_REGEX = /(\d{2,4}[\/\-.]\d{1,2}[\/\-.]\d{1,2})/
+
+ def initialize(spend_command_arg)
+ @spend_arg = spend_command_arg
+ end
+
+ def execute
+ return if @spend_arg.blank?
+ return [get_time, DateTime.now.to_date] unless date_present?
+ return unless valid_date?
+
+ [get_time, get_date]
+ end
+
+ private
+
+ def get_time
+ raw_time = @spend_arg.gsub(DATE_REGEX, '')
+ Gitlab::TimeTrackingFormatter.parse(raw_time)
+ end
+
+ def get_date
+ string_date = @spend_arg.match(DATE_REGEX)[0]
+ Date.parse(string_date)
+ end
+
+ def date_present?
+ DATE_REGEX =~ @spend_arg
+ end
+
+ def valid_date?
+ string_date = @spend_arg.match(DATE_REGEX)[0]
+ date = Date.parse(string_date) rescue nil
+
+ date_past_or_today?(date)
+ end
+
+ def date_past_or_today?(date)
+ date&.past? || date&.today?
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/saml/auth_hash.rb b/lib/gitlab/saml/auth_hash.rb
index 67a5f368bdb..33d19373098 100644
--- a/lib/gitlab/saml/auth_hash.rb
+++ b/lib/gitlab/saml/auth_hash.rb
@@ -2,7 +2,7 @@ module Gitlab
module Saml
class AuthHash < Gitlab::OAuth::AuthHash
def groups
- get_raw(Gitlab::Saml::Config.groups)
+ Array.wrap(get_raw(Gitlab::Saml::Config.groups))
end
private
diff --git a/lib/gitlab/sidekiq_status.rb b/lib/gitlab/sidekiq_status.rb
index a0a2769cf9e..a1f689d94d9 100644
--- a/lib/gitlab/sidekiq_status.rb
+++ b/lib/gitlab/sidekiq_status.rb
@@ -51,6 +51,13 @@ module Gitlab
self.num_running(job_ids).zero?
end
+ # Returns true if the given job is running
+ #
+ # job_id - The Sidekiq job ID to check.
+ def self.running?(job_id)
+ num_running([job_id]) > 0
+ end
+
# Returns the number of jobs that are running.
#
# job_ids - The Sidekiq job IDs to check.
diff --git a/lib/gitlab/sql/union.rb b/lib/gitlab/sql/union.rb
index f30c771837a..c99b262f1ca 100644
--- a/lib/gitlab/sql/union.rb
+++ b/lib/gitlab/sql/union.rb
@@ -26,7 +26,11 @@ module Gitlab
@relations.map { |rel| rel.reorder(nil).to_sql }.reject(&:blank?)
end
- fragments.join("\n#{union_keyword}\n")
+ if fragments.any?
+ fragments.join("\n#{union_keyword}\n")
+ else
+ 'NULL'
+ end
end
def union_keyword
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 3f3ba77d47f..70a403652e7 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -49,6 +49,8 @@ module Gitlab
deployments: Deployment.count,
environments: ::Environment.count,
gcp_clusters: ::Gcp::Cluster.count,
+ gcp_clusters_enabled: ::Gcp::Cluster.enabled.count,
+ gcp_clusters_disabled: ::Gcp::Cluster.disabled.count,
in_review_folder: ::Environment.in_review_folder.count,
groups: Group.count,
issues: Issue.count,
diff --git a/lib/gitlab/utils/merge_hash.rb b/lib/gitlab/utils/merge_hash.rb
new file mode 100644
index 00000000000..385141d44d0
--- /dev/null
+++ b/lib/gitlab/utils/merge_hash.rb
@@ -0,0 +1,117 @@
+module Gitlab
+ module Utils
+ module MergeHash
+ extend self
+ # Deep merges an array of hashes
+ #
+ # [{ hello: ["world"] },
+ # { hello: "Everyone" },
+ # { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] } },
+ # "Goodbye", "Hallo"]
+ # => [
+ # {
+ # hello:
+ # [
+ # "world",
+ # "Everyone",
+ # { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] }
+ # ]
+ # },
+ # "Goodbye"
+ # ]
+ def merge(elements)
+ merged, *other_elements = elements
+
+ other_elements.each do |element|
+ merged = merge_hash_tree(merged, element)
+ end
+
+ merged
+ end
+
+ # This extracts all keys and values from a hash into an array
+ #
+ # { hello: "world", this: { crushes: ["an entire", "hash"] } }
+ # => [:hello, "world", :this, :crushes, "an entire", "hash"]
+ def crush(array_or_hash)
+ if array_or_hash.is_a?(Array)
+ crush_array(array_or_hash)
+ else
+ crush_hash(array_or_hash)
+ end
+ end
+
+ private
+
+ def merge_hash_into_array(array, new_hash)
+ crushed_new_hash = crush_hash(new_hash)
+ # Merge the hash into an existing element of the array if there is overlap
+ if mergeable_index = array.index { |element| crushable?(element) && (crush(element) & crushed_new_hash).any? }
+ array[mergeable_index] = merge_hash_tree(array[mergeable_index], new_hash)
+ else
+ array << new_hash
+ end
+
+ array
+ end
+
+ def merge_hash_tree(first_element, second_element)
+ # If one of the elements is an object, and the other is a Hash or Array
+ # we can check if the object is already included. If so, we don't need to do anything
+ #
+ # Handled cases
+ # [Hash, Object], [Array, Object]
+ if crushable?(first_element) && crush(first_element).include?(second_element)
+ first_element
+ elsif crushable?(second_element) && crush(second_element).include?(first_element)
+ second_element
+ # When the first is an array, we need to go over every element to see if
+ # we can merge deeper. If no match is found, we add the element to the array
+ #
+ # Handled cases:
+ # [Array, Hash]
+ elsif first_element.is_a?(Array) && second_element.is_a?(Hash)
+ merge_hash_into_array(first_element, second_element)
+ elsif first_element.is_a?(Hash) && second_element.is_a?(Array)
+ merge_hash_into_array(second_element, first_element)
+ # If both of them are hashes, we can deep_merge with the same logic
+ #
+ # Handled cases:
+ # [Hash, Hash]
+ elsif first_element.is_a?(Hash) && second_element.is_a?(Hash)
+ first_element.deep_merge(second_element) { |key, first, second| merge_hash_tree(first, second) }
+ # If both elements are arrays, we try to merge each element separatly
+ #
+ # Handled cases
+ # [Array, Array]
+ elsif first_element.is_a?(Array) && second_element.is_a?(Array)
+ first_element.map { |child_element| merge_hash_tree(child_element, second_element) }
+ # If one or both elements are a GroupDescendant, we wrap create an array
+ # combining them.
+ #
+ # Handled cases:
+ # [Object, Object], [Array, Array]
+ else
+ (Array.wrap(first_element) + Array.wrap(second_element)).uniq
+ end
+ end
+
+ def crushable?(element)
+ element.is_a?(Hash) || element.is_a?(Array)
+ end
+
+ def crush_hash(hash)
+ hash.flat_map do |key, value|
+ crushed_value = crushable?(value) ? crush(value) : value
+ Array.wrap(key) + Array.wrap(crushed_value)
+ end
+ end
+
+ def crush_array(array)
+ array.flat_map do |element|
+ crushable?(element) ? crush(element) : element
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/dev.rake b/lib/tasks/gitlab/dev.rake
index b4d05f5995a..930b4bc13e2 100644
--- a/lib/tasks/gitlab/dev.rake
+++ b/lib/tasks/gitlab/dev.rake
@@ -5,7 +5,9 @@ namespace :gitlab do
opts =
if ENV['CI']
{
- ce_repo: ENV['CI_REPOSITORY_URL'],
+ # We don't use CI_REPOSITORY_URL since it includes `gitlab-ci-token:xxxxxxxxxxxxxxxxxxxx@`
+ # which is confusing in the steps suggested in the job's output.
+ ce_repo: "#{ENV['CI_PROJECT_URL']}.git",
branch: ENV['CI_COMMIT_REF_NAME']
}
else
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c73e582e608..1f356a231b0 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-10-06 18:33+0200\n"
-"PO-Revision-Date: 2017-10-06 18:33+0200\n"
+"POT-Creation-Date: 2017-10-10 17:50+0200\n"
+"PO-Revision-Date: 2017-10-10 17:50+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -109,6 +109,9 @@ msgstr ""
msgid "All"
msgstr ""
+msgid "An error occurred. Please try again."
+msgstr ""
+
msgid "Appearance"
msgstr ""
@@ -375,6 +378,9 @@ msgstr ""
msgid "Clone repository"
msgstr ""
+msgid "Cluster"
+msgstr ""
+
msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
msgstr ""
@@ -420,13 +426,10 @@ msgstr ""
msgid "ClusterIntegration|Google Container Engine project"
msgstr ""
-msgid "ClusterIntegration|Google Container Engine"
-msgstr ""
-
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
-msgid "ClusterIntegration|See machine types"
+msgid "ClusterIntegration|Machine type"
msgstr ""
msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
@@ -438,9 +441,15 @@ msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
+msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgstr ""
+
msgid "ClusterIntegration|Remove cluster integration"
msgstr ""
@@ -450,7 +459,10 @@ msgstr ""
msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
msgstr ""
-msgid "ClusterIntegration|Save changes"
+msgid "ClusterIntegration|Save"
+msgstr ""
+
+msgid "ClusterIntegration|See machine types"
msgstr ""
msgid "ClusterIntegration|See your projects"
@@ -462,18 +474,12 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine."
-msgstr ""
-
-msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine"
msgstr ""
msgid "ClusterIntegration|Toggle Cluster"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr ""
-
msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
@@ -691,6 +697,9 @@ msgstr ""
msgid "Discard changes"
msgstr ""
+msgid "Dismiss Cycle Analytics introduction box"
+msgstr ""
+
msgid "Don't show again"
msgstr ""
@@ -760,6 +769,9 @@ msgstr ""
msgid "Explore projects"
msgstr ""
+msgid "Explore public groups"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr ""
@@ -846,6 +858,51 @@ msgstr ""
msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
msgstr ""
+msgid "GroupsEmptyState|A group is a collection of several projects."
+msgstr ""
+
+msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder."
+msgstr ""
+
+msgid "GroupsEmptyState|No groups found"
+msgstr ""
+
+msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
+msgstr ""
+
+msgid "GroupsTreeRole|as"
+msgstr ""
+
+msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
+msgstr ""
+
+msgid "GroupsTree|Create a project in this group."
+msgstr ""
+
+msgid "GroupsTree|Create a subgroup in this group."
+msgstr ""
+
+msgid "GroupsTree|Edit group"
+msgstr ""
+
+msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
+msgstr ""
+
+msgid "GroupsTree|Filter by name..."
+msgstr ""
+
+msgid "GroupsTree|Leave this group"
+msgstr ""
+
+msgid "GroupsTree|Loading groups"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups matched your search"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgstr ""
+
msgid "Health Check"
msgstr ""
@@ -876,6 +933,12 @@ msgstr ""
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
+msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgstr ""
+
+msgid "Internal - The project can be accessed by any logged in user."
+msgstr ""
+
msgid "Interval Pattern"
msgstr ""
@@ -935,6 +998,9 @@ msgstr ""
msgid "Learn more in the|pipeline schedules documentation"
msgstr ""
+msgid "Leave"
+msgstr ""
+
msgid "Leave group"
msgstr ""
@@ -946,6 +1012,15 @@ msgid_plural "Limited to showing %d events at most"
msgstr[0] ""
msgstr[1] ""
+msgid "Lock"
+msgstr ""
+
+msgid "Locked"
+msgstr ""
+
+msgid "Login"
+msgstr ""
+
msgid "Median"
msgstr ""
@@ -973,6 +1048,9 @@ msgstr ""
msgid "More information is available|here"
msgstr ""
+msgid "New Cluster"
+msgstr ""
+
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] ""
@@ -990,18 +1068,27 @@ msgstr ""
msgid "New file"
msgstr ""
+msgid "New group"
+msgstr ""
+
msgid "New issue"
msgstr ""
msgid "New merge request"
msgstr ""
+msgid "New project"
+msgstr ""
+
msgid "New schedule"
msgstr ""
msgid "New snippet"
msgstr ""
+msgid "New subgroup"
+msgstr ""
+
msgid "New tag"
msgstr ""
@@ -1080,6 +1167,9 @@ msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr ""
+msgid "Only project members can comment."
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr ""
@@ -1110,6 +1200,9 @@ msgstr ""
msgid "Password"
msgstr ""
+msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgstr ""
+
msgid "Pipeline"
msgstr ""
@@ -1209,9 +1302,51 @@ msgstr ""
msgid "Preferences"
msgstr ""
+msgid "Private - Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Private - The group and its projects can only be viewed by members."
+msgstr ""
+
msgid "Profile"
msgstr ""
+msgid "Profiles|Account scheduled for removal."
+msgstr ""
+
+msgid "Profiles|Delete Account"
+msgstr ""
+
+msgid "Profiles|Delete account"
+msgstr ""
+
+msgid "Profiles|Delete your account?"
+msgstr ""
+
+msgid "Profiles|Deleting an account has the following effects:"
+msgstr ""
+
+msgid "Profiles|Invalid password"
+msgstr ""
+
+msgid "Profiles|Invalid username"
+msgstr ""
+
+msgid "Profiles|Type your %{confirmationValue} to confirm:"
+msgstr ""
+
+msgid "Profiles|You don't have access to delete this user."
+msgstr ""
+
+msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
+msgstr ""
+
+msgid "Profiles|Your account is currently an owner in these groups:"
+msgstr ""
+
+msgid "Profiles|your account"
+msgstr ""
+
msgid "Project '%{project_name}' queued for deletion."
msgstr ""
@@ -1266,6 +1401,9 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph"
msgstr ""
+msgid "Projects"
+msgstr ""
+
msgid "ProjectsDropdown|Frequently visited"
msgstr ""
@@ -1287,6 +1425,12 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr ""
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr ""
+
msgid "Push events"
msgstr ""
@@ -1415,12 +1559,18 @@ msgstr ""
msgid "Something went wrong on our end."
msgstr ""
+msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgstr ""
+
msgid "Something went wrong while fetching the projects."
msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Sort by"
+msgstr ""
+
msgid "SortOptions|Access level, ascending"
msgstr ""
@@ -1529,6 +1679,9 @@ msgstr ""
msgid "Start the Runner!"
msgstr ""
+msgid "Subgroups"
+msgstr ""
+
msgid "Switch branch/tag"
msgstr ""
@@ -1600,12 +1753,24 @@ msgstr ""
msgid "There are problems accessing Git storage: "
msgstr ""
+msgid "This is a confidential issue."
+msgstr ""
+
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential and locked."
+msgstr ""
+
+msgid "This issue is locked."
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr ""
+msgid "This merge request is locked."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr ""
@@ -1760,6 +1925,12 @@ msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
+msgid "Unlock"
+msgstr ""
+
+msgid "Unlocked"
+msgstr ""
+
msgid "Unstar"
msgstr ""
@@ -1922,9 +2093,15 @@ msgstr ""
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
msgstr ""
+msgid "You are on a read-only GitLab instance."
+msgstr ""
+
msgid "You can only add files when you are on a branch"
msgstr ""
+msgid "You cannot write to this read-only GitLab instance."
+msgstr ""
+
msgid "You have reached your project limit"
msgstr ""
@@ -1955,6 +2132,12 @@ msgstr ""
msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
msgstr ""
+msgid "Your comment will not be visible to the public."
+msgstr ""
+
+msgid "Your groups"
+msgstr ""
+
msgid "Your name"
msgstr ""
@@ -1977,5 +2160,11 @@ msgid_plural "parents"
msgstr[0] ""
msgstr[1] ""
+msgid "password"
+msgstr ""
+
msgid "personal access token"
msgstr ""
+
+msgid "username"
+msgstr ""
diff --git a/package.json b/package.json
index 5aa3ce3f757..057cd8f7bc7 100644
--- a/package.json
+++ b/package.json
@@ -43,10 +43,10 @@
"jszip": "^3.1.3",
"jszip-utils": "^0.0.2",
"marked": "^0.3.6",
- "monaco-editor": "0.8.3",
+ "monaco-editor": "0.10.0",
"mousetrap": "^1.4.6",
"name-all-modules-plugin": "^1.0.1",
- "pikaday": "^1.5.1",
+ "pikaday": "^1.6.1",
"prismjs": "^1.6.0",
"raphael": "^2.2.7",
"raven-js": "^3.14.0",
@@ -66,7 +66,7 @@
"vue-loader": "^11.3.4",
"vue-resource": "^1.3.4",
"vue-template-compiler": "^2.2.6",
- "vuex": "^2.3.1",
+ "vuex": "^3.0.0",
"webpack": "^3.5.5",
"webpack-bundle-analyzer": "^2.8.2",
"webpack-stats-plugin": "^0.1.5"
diff --git a/qa/qa.rb b/qa/qa.rb
index eb6f922d0d3..59d9dd131c2 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -18,6 +18,7 @@ module QA
# Support files
#
autoload :Actable, 'qa/scenario/actable'
+ autoload :Entrypoint, 'qa/scenario/entrypoint'
autoload :Template, 'qa/scenario/template'
##
@@ -25,6 +26,10 @@ module QA
#
module Test
autoload :Instance, 'qa/scenario/test/instance'
+
+ module Integration
+ autoload :Mattermost, 'qa/scenario/test/integration/mattermost'
+ end
end
##
diff --git a/qa/qa/scenario/entrypoint.rb b/qa/qa/scenario/entrypoint.rb
new file mode 100644
index 00000000000..33cb2696f8f
--- /dev/null
+++ b/qa/qa/scenario/entrypoint.rb
@@ -0,0 +1,36 @@
+module QA
+ module Scenario
+ ##
+ # Base class for running the suite against any GitLab instance,
+ # including staging and on-premises installation.
+ #
+ class Entrypoint < Template
+ def self.tags(*tags)
+ @tags = tags
+ end
+
+ def self.get_tags
+ @tags
+ end
+
+ def perform(address, *files)
+ Specs::Config.perform do |specs|
+ specs.address = address
+ end
+
+ ##
+ # Perform before hooks, which are different for CE and EE
+ #
+ Runtime::Release.perform_before_hooks
+
+ Specs::Runner.perform do |specs|
+ specs.rspec(
+ tty: true,
+ tags: self.class.get_tags,
+ files: files.any? ? files : 'qa/specs/features'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb
index 689292bc60b..e2a1f6bf2bd 100644
--- a/qa/qa/scenario/test/instance.rb
+++ b/qa/qa/scenario/test/instance.rb
@@ -5,21 +5,8 @@ module QA
# Run test suite against any GitLab instance,
# including staging and on-premises installation.
#
- class Instance < Scenario::Template
- def perform(address, *files)
- Specs::Config.perform do |specs|
- specs.address = address
- end
-
- ##
- # Perform before hooks, which are different for CE and EE
- #
- Runtime::Release.perform_before_hooks
-
- Specs::Runner.perform do |specs|
- specs.rspec('--tty', files.any? ? files : 'qa/specs/features')
- end
- end
+ class Instance < Entrypoint
+ tags :core
end
end
end
diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb
new file mode 100644
index 00000000000..4732f2b635b
--- /dev/null
+++ b/qa/qa/scenario/test/integration/mattermost.rb
@@ -0,0 +1,15 @@
+module QA
+ module Scenario
+ module Test
+ module Integration
+ ##
+ # Run test suite against any GitLab instance where mattermost is enabled,
+ # including staging and on-premises installation.
+ #
+ class Mattermost < Scenario::Entrypoint
+ tags :core, :mattermost
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/login/standard_spec.rb b/qa/qa/specs/features/login/standard_spec.rb
index 8e1ae6efa47..ba19ce17ee5 100644
--- a/qa/qa/specs/features/login/standard_spec.rb
+++ b/qa/qa/specs/features/login/standard_spec.rb
@@ -1,5 +1,5 @@
module QA
- feature 'standard root login' do
+ feature 'standard root login', :core do
scenario 'user logs in using credentials' do
Page::Main::Entry.act { sign_in_using_credentials }
diff --git a/qa/qa/specs/features/mattermost/group_create_spec.rb b/qa/qa/specs/features/mattermost/group_create_spec.rb
new file mode 100644
index 00000000000..c4afd83c8e4
--- /dev/null
+++ b/qa/qa/specs/features/mattermost/group_create_spec.rb
@@ -0,0 +1,16 @@
+module QA
+ feature 'create a new group', :mattermost do
+ scenario 'creating a group with a mattermost team' do
+ Page::Main::Entry.act { sign_in_using_credentials }
+ Page::Main::Menu.act { go_to_groups }
+
+ Page::Dashboard::Groups.perform do |page|
+ page.go_to_new_group
+
+ expect(page).to have_content(
+ /Create a Mattermost team for this group/
+ )
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/create_spec.rb b/qa/qa/specs/features/project/create_spec.rb
index 610492b9717..27eb22f15a6 100644
--- a/qa/qa/specs/features/project/create_spec.rb
+++ b/qa/qa/specs/features/project/create_spec.rb
@@ -1,5 +1,5 @@
module QA
- feature 'create a new project' do
+ feature 'create a new project', :core do
scenario 'user creates a new project' do
Page::Main::Entry.act { sign_in_using_credentials }
diff --git a/qa/qa/specs/features/repository/clone_spec.rb b/qa/qa/specs/features/repository/clone_spec.rb
index 521bd955857..3571173783d 100644
--- a/qa/qa/specs/features/repository/clone_spec.rb
+++ b/qa/qa/specs/features/repository/clone_spec.rb
@@ -1,5 +1,5 @@
module QA
- feature 'clone code from the repository' do
+ feature 'clone code from the repository', :core do
context 'with regular account over http' do
given(:location) do
Page::Project::Show.act do
diff --git a/qa/qa/specs/features/repository/push_spec.rb b/qa/qa/specs/features/repository/push_spec.rb
index 5fe45d63d37..0e691fb0d75 100644
--- a/qa/qa/specs/features/repository/push_spec.rb
+++ b/qa/qa/specs/features/repository/push_spec.rb
@@ -1,7 +1,7 @@
module QA
- feature 'push code to repository' do
+ feature 'push code to repository', :core do
context 'with regular account over http' do
- scenario 'user pushes code to the repository' do
+ scenario 'user pushes code to the repository' do
Page::Main::Entry.act { sign_in_using_credentials }
Scenario::Gitlab::Project::Create.perform do |scenario|
diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb
index 83ae15d0995..2aa18d5d3a1 100644
--- a/qa/qa/specs/runner.rb
+++ b/qa/qa/specs/runner.rb
@@ -5,7 +5,14 @@ module QA
class Runner
include Scenario::Actable
- def rspec(*args)
+ def rspec(tty: false, tags: [], files: ['qa/specs/features'])
+ args = []
+ args << '--tty' if tty
+ tags.to_a.each do |tag|
+ args << ['-t', tag.to_s]
+ end
+ args << files
+
RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status|
abort if status.nonzero?
end
diff --git a/rubocop/cop/rspec/env_assignment.rb b/rubocop/cop/rspec/env_assignment.rb
new file mode 100644
index 00000000000..257454af0e1
--- /dev/null
+++ b/rubocop/cop/rspec/env_assignment.rb
@@ -0,0 +1,58 @@
+require 'rubocop-rspec'
+require_relative '../../spec_helpers'
+
+module RuboCop
+ module Cop
+ module RSpec
+ # This cop checks for ENV assignment in specs
+ #
+ # @example
+ #
+ # # bad
+ # before do
+ # ENV['FOO'] = 'bar'
+ # end
+ #
+ # # good
+ # before do
+ # stub_env('FOO', 'bar')
+ # end
+ class EnvAssignment < Cop
+ include SpecHelpers
+
+ MESSAGE = "Don't assign to ENV, use `stub_env` instead.".freeze
+
+ def_node_search :env_assignment?, <<~PATTERN
+ (send (const nil? :ENV) :[]= ...)
+ PATTERN
+
+ # Following is what node.children looks like on a match
+ # [s(:const, nil, :ENV), :[]=, s(:str, "key"), s(:str, "value")]
+ def on_send(node)
+ return unless in_spec?(node)
+ return unless env_assignment?(node)
+
+ add_offense(node, :expression, MESSAGE)
+ end
+
+ def autocorrect(node)
+ lambda do |corrector|
+ corrector.replace(node.loc.expression, stub_env(env_key(node), env_value(node)))
+ end
+ end
+
+ def env_key(node)
+ node.children[2].source
+ end
+
+ def env_value(node)
+ node.children[3].source
+ end
+
+ def stub_env(key, value)
+ "stub_env(#{key}, #{value})"
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb
index 1df23899efb..4ebbe010e90 100644
--- a/rubocop/rubocop.rb
+++ b/rubocop/rubocop.rb
@@ -1,11 +1,11 @@
+require_relative 'cop/active_record_dependent'
+require_relative 'cop/active_record_serialize'
require_relative 'cop/custom_error_class'
require_relative 'cop/gem_fetcher'
-require_relative 'cop/active_record_serialize'
-require_relative 'cop/redirect_with_status'
+require_relative 'cop/in_batches'
require_relative 'cop/polymorphic_associations'
require_relative 'cop/project_path_helper'
-require_relative 'cop/active_record_dependent'
-require_relative 'cop/in_batches'
+require_relative 'cop/redirect_with_status'
require_relative 'cop/migration/add_column'
require_relative 'cop/migration/add_column_with_default_to_large_table'
require_relative 'cop/migration/add_concurrent_foreign_key'
@@ -13,12 +13,13 @@ require_relative 'cop/migration/add_concurrent_index'
require_relative 'cop/migration/add_index'
require_relative 'cop/migration/add_timestamps'
require_relative 'cop/migration/datetime'
-require_relative 'cop/migration/safer_boolean_column'
require_relative 'cop/migration/hash_index'
require_relative 'cop/migration/remove_concurrent_index'
require_relative 'cop/migration/remove_index'
require_relative 'cop/migration/reversible_add_column_with_default'
+require_relative 'cop/migration/safer_boolean_column'
require_relative 'cop/migration/timestamps'
require_relative 'cop/migration/update_column_in_batches'
+require_relative 'cop/rspec/env_assignment'
require_relative 'cop/rspec/single_line_hook'
require_relative 'cop/rspec/verbose_include_metadata'
diff --git a/rubocop/spec_helpers.rb b/rubocop/spec_helpers.rb
new file mode 100644
index 00000000000..a702a083958
--- /dev/null
+++ b/rubocop/spec_helpers.rb
@@ -0,0 +1,12 @@
+module RuboCop
+ module SpecHelpers
+ SPEC_HELPERS = %w[spec_helper.rb rails_helper.rb].freeze
+
+ # Returns true if the given node originated from the spec directory.
+ def in_spec?(node)
+ path = node.location.expression.source_buffer.name
+
+ !SPEC_HELPERS.include?(File.basename(path)) && path.start_with?(File.join(Dir.pwd, 'spec'))
+ end
+ end
+end
diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh
index 39806901274..7abadef5e89 100644
--- a/scripts/prepare_build.sh
+++ b/scripts/prepare_build.sh
@@ -27,11 +27,9 @@ fi
cp config/database.yml.$GITLAB_DATABASE config/database.yml
if [ "$GITLAB_DATABASE" = 'postgresql' ]; then
- sed -i 's/# host:.*/host: postgres/g' config/database.yml
+ sed -i 's/localhost/postgres/g' config/database.yml
else # Assume it's mysql
- sed -i 's/username:.*/username: root/g' config/database.yml
- sed -i 's/password:.*/password:/g' config/database.yml
- sed -i 's/# host:.*/host: mysql/g' config/database.yml
+ sed -i 's/localhost/mysql/g' config/database.yml
fi
cp config/resque.yml.example config/resque.yml
diff --git a/spec/bin/changelog_spec.rb b/spec/bin/changelog_spec.rb
index 6d8b9865dcb..fc1bf67d7b9 100644
--- a/spec/bin/changelog_spec.rb
+++ b/spec/bin/changelog_spec.rb
@@ -84,7 +84,7 @@ describe 'bin/changelog' do
expect do
expect do
expect { described_class.read_type }.to raise_error(SystemExit)
- end.to output("Invalid category index, please select an index between 1 and 7\n").to_stderr
+ end.to output("Invalid category index, please select an index between 1 and 8\n").to_stderr
end.to output.to_stdout
end
end
diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb
new file mode 100644
index 00000000000..ba84fbf8564
--- /dev/null
+++ b/spec/controllers/concerns/group_tree_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+describe GroupTree do
+ let(:group) { create(:group, :public) }
+ let(:user) { create(:user) }
+
+ controller(ApplicationController) do
+ # `described_class` is not available in this context
+ include GroupTree # rubocop:disable RSpec/DescribedClass
+
+ def index
+ render_group_tree GroupsFinder.new(current_user).execute
+ end
+ end
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ describe 'GET #index' do
+ it 'filters groups' do
+ other_group = create(:group, name: 'filter')
+ other_group.add_owner(user)
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(other_group)
+ end
+
+ context 'for subgroups', :nested_groups do
+ it 'only renders root groups when no parent was given' do
+ create(:group, :public, parent: group)
+
+ get :index, format: :json
+
+ expect(assigns(:groups)).to contain_exactly(group)
+ end
+
+ it 'contains only the subgroup when a parent was given' do
+ subgroup = create(:group, :public, parent: group)
+
+ get :index, parent_id: group.id, format: :json
+
+ expect(assigns(:groups)).to contain_exactly(subgroup)
+ end
+
+ it 'allows filtering for subgroups and includes the parents for rendering' do
+ subgroup = create(:group, :public, parent: group, name: 'filter')
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(group, subgroup)
+ end
+
+ it 'does not include groups the user does not have access to' do
+ parent = create(:group, :private)
+ subgroup = create(:group, :private, parent: parent, name: 'filter')
+ subgroup.add_developer(user)
+ _other_subgroup = create(:group, :private, parent: parent, name: 'filte')
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(parent, subgroup)
+ end
+ end
+
+ context 'json content' do
+ it 'shows groups as json' do
+ get :index, format: :json
+
+ expect(json_response.first['id']).to eq(group.id)
+ end
+
+ context 'nested groups', :nested_groups do
+ it 'expands the tree when filtering' do
+ subgroup = create(:group, :public, parent: group, name: 'filter')
+
+ get :index, filter: 'filt', format: :json
+
+ children_response = json_response.first['children']
+
+ expect(json_response.first['id']).to eq(group.id)
+ expect(children_response.first['id']).to eq(subgroup.id)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/dashboard/groups_controller_spec.rb b/spec/controllers/dashboard/groups_controller_spec.rb
new file mode 100644
index 00000000000..fb9d3efbac0
--- /dev/null
+++ b/spec/controllers/dashboard/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Dashboard::GroupsController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders group trees' do
+ expect(described_class).to include(GroupTree)
+ end
+
+ it 'only includes projects the user is a member of' do
+ member_of_group = create(:group)
+ member_of_group.add_developer(user)
+ create(:group, :public)
+
+ get :index
+
+ expect(assigns(:groups)).to contain_exactly(member_of_group)
+ end
+end
diff --git a/spec/controllers/explore/groups_controller_spec.rb b/spec/controllers/explore/groups_controller_spec.rb
new file mode 100644
index 00000000000..9e0ad9ea86f
--- /dev/null
+++ b/spec/controllers/explore/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Explore::GroupsController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders group trees' do
+ expect(described_class).to include(GroupTree)
+ end
+
+ it 'includes public projects' do
+ member_of_group = create(:group)
+ member_of_group.add_developer(user)
+ public_group = create(:group, :public)
+
+ get :index
+
+ expect(assigns(:groups)).to contain_exactly(member_of_group, public_group)
+ end
+end
diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb
new file mode 100644
index 00000000000..4262d474e59
--- /dev/null
+++ b/spec/controllers/groups/children_controller_spec.rb
@@ -0,0 +1,286 @@
+require 'spec_helper'
+
+describe Groups::ChildrenController do
+ let(:group) { create(:group, :public) }
+ let(:user) { create(:user) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+
+ describe 'GET #index' do
+ context 'for projects' do
+ let!(:public_project) { create(:project, :public, namespace: group) }
+ let!(:private_project) { create(:project, :private, namespace: group) }
+
+ context 'as a user' do
+ before do
+ sign_in(user)
+ end
+
+ it 'shows all children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project, private_project)
+ end
+
+ context 'being member of private subgroup' do
+ it 'shows public and private children the user is member of' do
+ group_member.destroy!
+ private_project.add_guest(user)
+
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project, private_project)
+ end
+ end
+ end
+
+ context 'as a guest' do
+ it 'shows the public children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project)
+ end
+ end
+ end
+
+ context 'for subgroups', :nested_groups do
+ let!(:public_subgroup) { create(:group, :public, parent: group) }
+ let!(:private_subgroup) { create(:group, :private, parent: group) }
+ let!(:public_project) { create(:project, :public, namespace: group) }
+ let!(:private_project) { create(:project, :private, namespace: group) }
+
+ context 'as a user' do
+ before do
+ sign_in(user)
+ end
+
+ it 'shows all children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+ end
+
+ context 'being member of private subgroup' do
+ it 'shows public and private children the user is member of' do
+ group_member.destroy!
+ private_subgroup.add_guest(user)
+ private_project.add_guest(user)
+
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+ end
+ end
+ end
+
+ context 'as a guest' do
+ it 'shows the public children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, public_project)
+ end
+ end
+
+ context 'filtering children' do
+ it 'expands the tree for matching projects' do
+ project = create(:project, :public, namespace: public_subgroup, name: 'filterme')
+
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+ group_json = json_response.first
+ project_json = group_json['children'].first
+
+ expect(group_json['id']).to eq(public_subgroup.id)
+ expect(project_json['id']).to eq(project.id)
+ end
+
+ it 'expands the tree for matching subgroups' do
+ matched_group = create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+ group_json = json_response.first
+ matched_group_json = group_json['children'].first
+
+ expect(group_json['id']).to eq(public_subgroup.id)
+ expect(matched_group_json['id']).to eq(matched_group.id)
+ end
+
+ it 'merges the trees correctly' do
+ shared_subgroup = create(:group, :public, parent: group, path: 'hardware')
+ matched_project_1 = create(:project, :public, namespace: shared_subgroup, name: 'mobile-soc')
+
+ l2_subgroup = create(:group, :public, parent: shared_subgroup, path: 'broadcom')
+ l3_subgroup = create(:group, :public, parent: l2_subgroup, path: 'wifi-group')
+ matched_project_2 = create(:project, :public, namespace: l3_subgroup, name: 'mobile')
+
+ get :index, group_id: group.to_param, filter: 'mobile', format: :json
+
+ shared_group_json = json_response.first
+ expect(shared_group_json['id']).to eq(shared_subgroup.id)
+
+ matched_project_1_json = shared_group_json['children'].detect { |child| child['type'] == 'project' }
+ expect(matched_project_1_json['id']).to eq(matched_project_1.id)
+
+ l2_subgroup_json = shared_group_json['children'].detect { |child| child['type'] == 'group' }
+ expect(l2_subgroup_json['id']).to eq(l2_subgroup.id)
+
+ l3_subgroup_json = l2_subgroup_json['children'].first
+ expect(l3_subgroup_json['id']).to eq(l3_subgroup.id)
+
+ matched_project_2_json = l3_subgroup_json['children'].first
+ expect(matched_project_2_json['id']).to eq(matched_project_2.id)
+ end
+
+ it 'expands the tree upto a specified parent' do
+ subgroup = create(:group, :public, parent: group)
+ l2_subgroup = create(:group, :public, parent: subgroup)
+ create(:project, :public, namespace: l2_subgroup, name: 'test')
+
+ get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns an array with one element when only one result is matched' do
+ create(:project, :public, namespace: group, name: 'match')
+
+ get :index, group_id: group.to_param, filter: 'match', format: :json
+
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(1)
+ end
+
+ it 'returns an empty array when there are no search results' do
+ subgroup = create(:group, :public, parent: group)
+ l2_subgroup = create(:group, :public, parent: subgroup)
+ create(:project, :public, namespace: l2_subgroup, name: 'no-match')
+
+ get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+ expect(json_response).to eq([])
+ end
+
+ it 'includes pagination headers' do
+ 2.times { |i| create(:group, :public, parent: public_subgroup, name: "filterme#{i}") }
+
+ get :index, group_id: group.to_param, filter: 'filter', per_page: 1, format: :json
+
+ expect(response).to include_pagination_headers
+ end
+ end
+
+ context 'queries per rendered element', :request_store do
+ # We need to make sure the following counts are preloaded
+ # otherwise they will cause an extra query
+ # 1. Count of visible projects in the element
+ # 2. Count of visible subgroups in the element
+ # 3. Count of members of a group
+ let(:expected_queries_per_group) { 0 }
+ let(:expected_queries_per_project) { 0 }
+
+ def get_list
+ get :index, group_id: group.to_param, format: :json
+ end
+
+ it 'queries the expected amount for a group row' do
+ control = ActiveRecord::QueryRecorder.new { get_list }
+
+ _new_group = create(:group, :public, parent: group)
+
+ expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_group)
+ end
+
+ it 'queries the expected amount for a project row' do
+ control = ActiveRecord::QueryRecorder.new { get_list }
+ _new_project = create(:project, :public, namespace: group)
+
+ expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_project)
+ end
+
+ context 'when rendering hierarchies' do
+ # When loading hierarchies we load the all the ancestors for matched projects
+ # in 1 separate query
+ let(:extra_queries_for_hierarchies) { 1 }
+
+ def get_filtered_list
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+ end
+
+ it 'queries the expected amount when nested rows are increased for a group' do
+ matched_group = create(:group, :public, parent: group, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ matched_group.update!(parent: public_subgroup)
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+
+ it 'queries the expected amount when a new group match is added' do
+ create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ create(:group, :public, parent: public_subgroup, name: 'filterme2')
+ create(:group, :public, parent: public_subgroup, name: 'filterme3')
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+
+ it 'queries the expected amount when nested rows are increased for a project' do
+ matched_project = create(:project, :public, namespace: group, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ matched_project.update!(namespace: public_subgroup)
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+ end
+ end
+ end
+
+ context 'pagination' do
+ let(:per_page) { 3 }
+
+ before do
+ allow(Kaminari.config).to receive(:default_per_page).and_return(per_page)
+ end
+
+ context 'with only projects' do
+ let!(:other_project) { create(:project, :public, namespace: group) }
+ let!(:first_page_projects) { create_list(:project, per_page, :public, namespace: group ) }
+
+ it 'has projects on the first page' do
+ get :index, group_id: group.to_param, sort: 'id_desc', format: :json
+
+ expect(assigns(:children)).to contain_exactly(*first_page_projects)
+ end
+
+ it 'has projects on the second page' do
+ get :index, group_id: group.to_param, sort: 'id_desc', page: 2, format: :json
+
+ expect(assigns(:children)).to contain_exactly(other_project)
+ end
+ end
+
+ context 'with subgroups and projects', :nested_groups do
+ let!(:first_page_subgroups) { create_list(:group, per_page, :public, parent: group) }
+ let!(:other_subgroup) { create(:group, :public, parent: group) }
+ let!(:next_page_projects) { create_list(:project, per_page, :public, namespace: group) }
+
+ it 'contains all subgroups' do
+ get :index, group_id: group.to_param, sort: 'id_asc', format: :json
+
+ expect(assigns(:children)).to contain_exactly(*first_page_subgroups)
+ end
+
+ it 'contains the project and group on the second page' do
+ get :index, group_id: group.to_param, sort: 'id_asc', page: 2, format: :json
+
+ expect(assigns(:children)).to contain_exactly(other_subgroup, *next_page_projects.take(per_page - 1))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index b0564e27a68..e7631d4d709 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -1,4 +1,4 @@
-require 'rails_helper'
+require 'spec_helper'
describe GroupsController do
let(:user) { create(:user) }
@@ -150,42 +150,6 @@ describe GroupsController do
end
end
- describe 'GET #subgroups', :nested_groups do
- let!(:public_subgroup) { create(:group, :public, parent: group) }
- let!(:private_subgroup) { create(:group, :private, parent: group) }
-
- context 'as a user' do
- before do
- sign_in(user)
- end
-
- it 'shows all subgroups' do
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
- end
-
- context 'being member of private subgroup' do
- it 'shows public and private subgroups the user is member of' do
- group_member.destroy!
- private_subgroup.add_guest(user)
-
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
- end
- end
- end
-
- context 'as a guest' do
- it 'shows the public subgroups' do
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup)
- end
- end
- end
-
describe 'GET #issues' do
let(:issue_1) { create(:issue, project: project) }
let(:issue_2) { create(:issue, project: project) }
@@ -425,62 +389,62 @@ describe GroupsController do
end
end
end
- end
- context 'for a POST request' do
- context 'when requesting the canonical path with different casing' do
- it 'does not 404' do
- post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+ context 'for a POST request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
- expect(response).not_to have_http_status(404)
- end
+ expect(response).not_to have_http_status(404)
+ end
- it 'does not redirect to the correct casing' do
- post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+ it 'does not redirect to the correct casing' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
- expect(response).not_to have_http_status(301)
+ expect(response).not_to have_http_status(301)
+ end
end
- end
- context 'when requesting a redirected path' do
- let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
- it 'returns not found' do
- post :update, id: redirect_route.path, group: { path: 'new_path' }
+ it 'returns not found' do
+ post :update, id: redirect_route.path, group: { path: 'new_path' }
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(404)
+ end
end
end
- end
- context 'for a DELETE request' do
- context 'when requesting the canonical path with different casing' do
- it 'does not 404' do
- delete :destroy, id: group.to_param.upcase
+ context 'for a DELETE request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ delete :destroy, id: group.to_param.upcase
- expect(response).not_to have_http_status(404)
- end
+ expect(response).not_to have_http_status(404)
+ end
- it 'does not redirect to the correct casing' do
- delete :destroy, id: group.to_param.upcase
+ it 'does not redirect to the correct casing' do
+ delete :destroy, id: group.to_param.upcase
- expect(response).not_to have_http_status(301)
+ expect(response).not_to have_http_status(301)
+ end
end
- end
- context 'when requesting a redirected path' do
- let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
- it 'returns not found' do
- delete :destroy, id: redirect_route.path
+ it 'returns not found' do
+ delete :destroy, id: redirect_route.path
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(404)
+ end
end
end
end
- end
- def group_moved_message(redirect_route, group)
- "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+ def group_moved_message(redirect_route, group)
+ "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+ end
end
end
diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb
index ce5040ff02b..d380978b86e 100644
--- a/spec/controllers/profiles_controller_spec.rb
+++ b/spec/controllers/profiles_controller_spec.rb
@@ -1,9 +1,10 @@
require('spec_helper')
-describe ProfilesController do
- describe "PUT update" do
- it "allows an email update from a user without an external email address" do
- user = create(:user)
+describe ProfilesController, :request_store do
+ let(:user) { create(:user) }
+
+ describe 'PUT update' do
+ it 'allows an email update from a user without an external email address' do
sign_in(user)
put :update,
@@ -29,7 +30,7 @@ describe ProfilesController do
expect(user.unconfirmed_email).to eq nil
end
- it "ignores an email update from a user with an external email address" do
+ it 'ignores an email update from a user with an external email address' do
stub_omniauth_setting(sync_profile_from_provider: ['ldap'])
stub_omniauth_setting(sync_profile_attributes: true)
@@ -46,7 +47,7 @@ describe ProfilesController do
expect(ldap_user.unconfirmed_email).not_to eq('john@gmail.com')
end
- it "ignores an email and name update but allows a location update from a user with external email and name, but not external location" do
+ it 'ignores an email and name update but allows a location update from a user with external email and name, but not external location' do
stub_omniauth_setting(sync_profile_from_provider: ['ldap'])
stub_omniauth_setting(sync_profile_attributes: true)
@@ -65,4 +66,35 @@ describe ProfilesController do
expect(ldap_user.location).to eq('City, Country')
end
end
+
+ describe 'PUT update_username' do
+ let(:namespace) { user.namespace }
+ let(:project) { create(:project_empty_repo, namespace: namespace) }
+ let(:gitlab_shell) { Gitlab::Shell.new }
+ let(:new_username) { 'renamedtosomethingelse' }
+
+ it 'allows username change' do
+ sign_in(user)
+
+ put :update_username,
+ user: { username: new_username }
+
+ user.reload
+
+ expect(response.status).to eq(302)
+ expect(user.username).to eq(new_username)
+ end
+
+ it 'moves dependent projects to new namespace' do
+ sign_in(user)
+
+ put :update_username,
+ user: { username: new_username }
+
+ user.reload
+
+ expect(response.status).to eq(302)
+ expect(gitlab_shell.exists?(project.repository_storage_path, "#{new_username}/#{project.path}.git")).to be_truthy
+ end
+ end
end
diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb
index e26731fb691..c459d732507 100644
--- a/spec/controllers/projects/commits_controller_spec.rb
+++ b/spec/controllers/projects/commits_controller_spec.rb
@@ -10,9 +10,36 @@ describe Projects::CommitsController do
end
describe "GET show" do
- context "when the ref name ends in .atom" do
- render_views
+ render_views
+
+ context 'with file path' do
+ before do
+ get(:show,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: id)
+ end
+
+ context "valid branch, valid file" do
+ let(:id) { 'master/README.md' }
+
+ it { is_expected.to respond_with(:success) }
+ end
+
+ context "valid branch, invalid file" do
+ let(:id) { 'master/invalid-path.rb' }
+ it { is_expected.to respond_with(:not_found) }
+ end
+
+ context "invalid branch, valid file" do
+ let(:id) { 'invalid-branch/README.md' }
+
+ it { is_expected.to respond_with(:not_found) }
+ end
+ end
+
+ context "when the ref name ends in .atom" do
context "when the ref does not exist with the suffix" do
it "renders as atom" do
get(:show,
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 053bd73fee3..ed8088a46f0 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -850,47 +850,48 @@ describe Projects::IssuesController do
describe 'GET #discussions' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
-
- before do
- project.add_developer(user)
- sign_in(user)
- end
-
- it 'returns discussion json' do
- get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
-
- expect(JSON.parse(response.body).first.keys).to match_array(%w[id reply_id expanded notes individual_note])
- end
-
- context 'with cross-reference system note', :request_store do
- let(:new_issue) { create(:issue) }
- let(:cross_reference) { "mentioned in #{new_issue.to_reference(issue.project)}" }
-
+ context 'when authenticated' do
before do
- create(:discussion_note_on_issue, :system, noteable: issue, project: issue.project, note: cross_reference)
+ project.add_developer(user)
+ sign_in(user)
end
- it 'filters notes that the user should not see' do
+ it 'returns discussion json' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
- expect(JSON.parse(response.body).count).to eq(1)
+ expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes individual_note])
end
- it 'does not result in N+1 queries' do
- # Instantiate the controller variables to ensure QueryRecorder has an accurate base count
- get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
+ context 'with cross-reference system note', :request_store do
+ let(:new_issue) { create(:issue) }
+ let(:cross_reference) { "mentioned in #{new_issue.to_reference(issue.project)}" }
- RequestStore.clear!
+ before do
+ create(:discussion_note_on_issue, :system, noteable: issue, project: issue.project, note: cross_reference)
+ end
- control_count = ActiveRecord::QueryRecorder.new do
+ it 'filters notes that the user should not see' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
- end.count
- RequestStore.clear!
+ expect(JSON.parse(response.body).count).to eq(1)
+ end
- create_list(:discussion_note_on_issue, 2, :system, noteable: issue, project: issue.project, note: cross_reference)
+ it 'does not result in N+1 queries' do
+ # Instantiate the controller variables to ensure QueryRecorder has an accurate base count
+ get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
- expect { get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid }.not_to exceed_query_limit(control_count)
+ RequestStore.clear!
+
+ control_count = ActiveRecord::QueryRecorder.new do
+ get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
+ end.count
+
+ RequestStore.clear!
+
+ create_list(:discussion_note_on_issue, 2, :system, noteable: issue, project: issue.project, note: cross_reference)
+
+ expect { get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid }.not_to exceed_query_limit(control_count)
+ end
end
end
end
diff --git a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
index 393d38c6e6b..c6d50c28106 100644
--- a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
@@ -17,8 +17,8 @@ describe Projects::MergeRequests::ConflictsController do
describe 'GET show' do
context 'when the conflicts cannot be resolved in the UI' do
before do
- allow_any_instance_of(Gitlab::Conflict::Parser)
- .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
+ allow(Gitlab::Git::Conflict::Parser).to receive(:parse)
+ .and_raise(Gitlab::Git::Conflict::Parser::UnmergeableFile)
get :show,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
@@ -109,8 +109,8 @@ describe Projects::MergeRequests::ConflictsController do
context 'when the conflicts cannot be resolved in the UI' do
before do
- allow_any_instance_of(Gitlab::Conflict::Parser)
- .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
+ allow(Gitlab::Git::Conflict::Parser).to receive(:parse)
+ .and_raise(Gitlab::Git::Conflict::Parser::UnmergeableFile)
conflict_for_path('files/ruby/regex.rb')
end
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 167e80ed9cd..67b53d2acce 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -3,32 +3,36 @@ require 'spec_helper'
describe Projects::PipelinesController do
include ApiHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ set(:user) { create(:user) }
+ set(:project) { create(:project, :public, :repository) }
let(:feature) { ProjectFeature::DISABLED }
before do
stub_not_protect_default_branch
project.add_developer(user)
- project.project_feature.update(
- builds_access_level: feature)
+ project.project_feature.update(builds_access_level: feature)
sign_in(user)
end
describe 'GET index.json' do
before do
- create(:ci_empty_pipeline, status: 'pending', project: project)
- create(:ci_empty_pipeline, status: 'running', project: project)
- create(:ci_empty_pipeline, status: 'created', project: project)
- create(:ci_empty_pipeline, status: 'success', project: project)
+ branch_head = project.commit
+ parent = branch_head.parent
- get :index, namespace_id: project.namespace,
- project_id: project,
- format: :json
+ create(:ci_empty_pipeline, status: 'pending', project: project, sha: branch_head.id)
+ create(:ci_empty_pipeline, status: 'running', project: project, sha: branch_head.id)
+ create(:ci_empty_pipeline, status: 'created', project: project, sha: parent.id)
+ create(:ci_empty_pipeline, status: 'success', project: project, sha: parent.id)
+ end
+
+ subject do
+ get :index, namespace_id: project.namespace, project_id: project, format: :json
end
it 'returns JSON with serialized pipelines' do
+ subject
+
expect(response).to have_http_status(:ok)
expect(response).to match_response_schema('pipeline')
@@ -39,6 +43,12 @@ describe Projects::PipelinesController do
expect(json_response['count']['pending']).to eq 1
expect(json_response['count']['finished']).to eq 1
end
+
+ context 'when performing gitaly calls', :request_store do
+ it 'limits the Gitaly requests' do
+ expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(10)
+ end
+ end
end
describe 'GET show JSON' do
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index 0544afe31ed..7569052c3aa 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -222,6 +222,14 @@ describe ProjectsController do
get :show, namespace_id: public_project.namespace, id: public_project
expect(response).to render_template('_files')
end
+
+ it "renders the readme view" do
+ allow(controller).to receive(:current_user).and_return(user)
+ allow(user).to receive(:project_view).and_return('readme')
+
+ get :show, namespace_id: public_project.namespace, id: public_project
+ expect(response).to render_template('_readme')
+ end
end
context "when the url contains .atom" do
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index 2c732aaf4ed..7c4a22c94c2 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -73,6 +73,12 @@ FactoryGirl.define do
merge_user author
end
+ trait :remove_source_branch do
+ merge_params do
+ { 'force_remove_source_branch' => '1' }
+ end
+ end
+
after(:build) do |merge_request|
target_project = merge_request.target_project
source_project = merge_request.source_project
diff --git a/spec/features/admin/admin_health_check_spec.rb b/spec/features/admin/admin_health_check_spec.rb
index 09e6965849a..4430fc15501 100644
--- a/spec/features/admin/admin_health_check_spec.rb
+++ b/spec/features/admin/admin_health_check_spec.rb
@@ -65,9 +65,11 @@ feature "Admin Health Check", :feature, :broken_storage do
it 'shows storage failure information' do
hostname = Gitlab::Environment.hostname
+ maximum_failures = Gitlab::CurrentSettings.current_application_settings
+ .circuitbreaker_failure_count_threshold
expect(page).to have_content('broken: failed storage access attempt on host:')
- expect(page).to have_content("#{hostname}: 1 of 10 failures.")
+ expect(page).to have_content("#{hostname}: 1 of #{maximum_failures} failures.")
end
it 'allows resetting storage failures' do
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index c4817eb947b..4965f803883 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -1,6 +1,8 @@
require 'rails_helper'
describe 'Issue Boards', :js do
+ include BoardHelpers
+
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:project) { create(:project, :public) }
@@ -309,6 +311,21 @@ describe 'Issue Boards', :js do
expect(card).to have_selector('.label', count: 1)
expect(card).not_to have_content(stretch.title)
end
+
+ it 'creates new label' do
+ click_card(card)
+
+ page.within('.labels') do
+ click_link 'Edit'
+ click_link 'Create new label'
+ fill_in 'new_label_name', with: 'test label'
+ first('.suggest-colors-dropdown a').click
+ click_button 'Create'
+ wait_for_requests
+
+ expect(page).to have_link 'test label'
+ end
+ end
end
context 'subscription' do
@@ -322,19 +339,4 @@ describe 'Issue Boards', :js do
end
end
end
-
- def click_card(card)
- page.within(card) do
- first('.card-number').click
- end
-
- wait_for_sidebar
- end
-
- def wait_for_sidebar
- # loop until the CSS transition is complete
- Timeout.timeout(0.5) do
- loop until evaluate_script('$(".right-sidebar").outerWidth()') == 290
- end
- end
end
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index a6329b5c78d..c6873d1923c 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -6,6 +6,13 @@ feature 'Dashboard Groups page', :js do
let(:nested_group) { create(:group, :nested) }
let(:another_group) { create(:group) }
+ def click_group_caret(group)
+ within("#group-#{group.id}") do
+ first('.folder-caret').click
+ end
+ wait_for_requests
+ end
+
it 'shows groups user is member of' do
group.add_owner(user)
nested_group.add_owner(user)
@@ -13,13 +20,27 @@ feature 'Dashboard Groups page', :js do
sign_in(user)
visit dashboard_groups_path
+ wait_for_requests
+
+ expect(page).to have_content(group.name)
+
+ expect(page).not_to have_content(another_group.name)
+ end
+
+ it 'shows subgroups the user is member of', :nested_groups do
+ group.add_owner(user)
+ nested_group.add_owner(user)
+
+ sign_in(user)
+ visit dashboard_groups_path
+ wait_for_requests
- expect(page).to have_content(group.full_name)
- expect(page).to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
+ expect(page).to have_content(nested_group.parent.name)
+ click_group_caret(nested_group.parent)
+ expect(page).to have_content(nested_group.name)
end
- describe 'when filtering groups' do
+ describe 'when filtering groups', :nested_groups do
before do
group.add_owner(user)
nested_group.add_owner(user)
@@ -30,25 +51,26 @@ feature 'Dashboard Groups page', :js do
visit dashboard_groups_path
end
- it 'filters groups' do
- fill_in 'filter_groups', with: group.name
+ it 'expands when filtering groups' do
+ fill_in 'filter', with: nested_group.name
wait_for_requests
- expect(page).to have_content(group.full_name)
- expect(page).not_to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
+ expect(page).not_to have_content(group.name)
+ expect(page).to have_content(nested_group.parent.name)
+ expect(page).to have_content(nested_group.name)
+ expect(page).not_to have_content(another_group.name)
end
it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
- fill_in 'filter_groups', with: ''
+ fill_in 'filter', with: ''
wait_for_requests
- expect(page).to have_content(group.full_name)
- expect(page).to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
+ expect(page).to have_content(group.name)
+ expect(page).to have_content(nested_group.parent.name)
+ expect(page).not_to have_content(another_group.name)
expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
end
end
@@ -66,28 +88,29 @@ feature 'Dashboard Groups page', :js do
end
it 'shows subgroups inside of its parent group' do
- expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 2)
- expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1)
+ expect(page).to have_selector("#group-#{group.id}")
+ click_group_caret(group)
+ expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end
it 'can toggle parent group' do
- # Expanded by default
- expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
+ # Collapsed by default
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
- # Collapse
- find("#group-#{group.id}").trigger('click')
+ # expand
+ click_group_caret(group)
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down")
- expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
- expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-down")
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
+ expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
- # Expand
- find("#group-#{group.id}").trigger('click')
+ # collapse
+ click_group_caret(group)
- expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
- expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
+ expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end
end
diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb
index b5325301968..801a33979ff 100644
--- a/spec/features/explore/groups_list_spec.rb
+++ b/spec/features/explore/groups_list_spec.rb
@@ -13,6 +13,7 @@ describe 'Explore Groups page', :js do
sign_in(user)
visit explore_groups_path
+ wait_for_requests
end
it 'shows groups user is member of' do
@@ -22,7 +23,7 @@ describe 'Explore Groups page', :js do
end
it 'filters groups' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
expect(page).to have_content(group.full_name)
@@ -31,10 +32,10 @@ describe 'Explore Groups page', :js do
end
it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
- fill_in 'filter_groups', with: ""
+ fill_in 'filter', with: ""
wait_for_requests
expect(page).to have_content(group.full_name)
@@ -45,21 +46,21 @@ describe 'Explore Groups page', :js do
it 'shows non-archived projects count' do
# Initially project is not archived
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
# Archive project
empty_project.archive!
visit explore_groups_path
# Check project count
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0")
# Unarchive project
empty_project.unarchive!
visit explore_groups_path
# Check project count
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
end
describe 'landing component' do
diff --git a/spec/features/explore/user_explores_projects_spec.rb b/spec/features/explore/user_explores_projects_spec.rb
new file mode 100644
index 00000000000..6ac9497b024
--- /dev/null
+++ b/spec/features/explore/user_explores_projects_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe 'User explores projects' do
+ set(:archived_project) { create(:project, :archived) }
+ set(:internal_project) { create(:project, :internal) }
+ set(:private_project) { create(:project, :private) }
+ set(:public_project) { create(:project, :public) }
+
+ shared_examples_for 'shows public projects' do
+ it 'shows projects' do
+ expect(page).to have_content(public_project.title)
+ expect(page).not_to have_content(internal_project.title)
+ expect(page).not_to have_content(private_project.title)
+ expect(page).not_to have_content(archived_project.title)
+ end
+ end
+
+ shared_examples_for 'shows public and internal projects' do
+ it 'shows projects' do
+ expect(page).to have_content(public_project.title)
+ expect(page).to have_content(internal_project.title)
+ expect(page).not_to have_content(private_project.title)
+ expect(page).not_to have_content(archived_project.title)
+ end
+ end
+
+ context 'when not signed in' do
+ context 'when viewing public projects' do
+ before do
+ visit(explore_projects_path)
+ end
+
+ include_examples 'shows public projects'
+ end
+ end
+
+ context 'when signed in' do
+ set(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when viewing public projects' do
+ before do
+ visit(explore_projects_path)
+ end
+
+ include_examples 'shows public and internal projects'
+ end
+
+ context 'when viewing most starred projects' do
+ before do
+ visit(starred_explore_projects_path)
+ end
+
+ include_examples 'shows public and internal projects'
+ end
+
+ context 'when viewing trending projects' do
+ before do
+ [archived_project, public_project].each { |project| create(:note_on_issue, project: project) }
+
+ TrendingProject.refresh!
+
+ visit(trending_explore_projects_path)
+ end
+
+ include_examples 'shows public projects'
+ end
+ end
+end
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 303013e59d5..7fc2b383749 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -24,4 +24,35 @@ feature 'Group show page' do
it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
+
+ context 'subgroup support' do
+ let(:user) { create(:user) }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ context 'when subgroups are supported', :js, :nested_groups do
+ before do
+ allow(Group).to receive(:supports_nested_groups?) { true }
+ visit path
+ end
+
+ it 'allows creating subgroups' do
+ expect(page).to have_css("li[data-text='New subgroup']", visible: false)
+ end
+ end
+
+ context 'when subgroups are not supported' do
+ before do
+ allow(Group).to receive(:supports_nested_groups?) { false }
+ visit path
+ end
+
+ it 'allows creating subgroups' do
+ expect(page).not_to have_selector("li[data-text='New subgroup']", visible: false)
+ end
+ end
+ end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 862823d862e..cc8906fa969 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -90,8 +90,7 @@ feature 'Group' do
context 'as admin' do
before do
- visit subgroups_group_path(group)
- click_link 'New Subgroup'
+ visit new_group_path(group, parent_id: group.id)
end
it 'creates a nested group' do
@@ -111,8 +110,8 @@ feature 'Group' do
sign_out(:user)
sign_in(user)
- visit subgroups_group_path(group)
- click_link 'New Subgroup'
+ visit new_group_path(group, parent_id: group.id)
+
fill_in 'Group path', with: 'bar'
click_button 'Create group'
@@ -120,16 +119,6 @@ feature 'Group' do
expect(page).to have_content("Group 'bar' was successfully created.")
end
end
-
- context 'when nested group feature is disabled' do
- it 'renders 404' do
- allow(Group).to receive(:supports_nested_groups?).and_return(false)
-
- visit subgroups_group_path(group)
-
- expect(page.status_code).to eq(404)
- end
- end
end
it 'checks permissions to avoid exposing groups by parent_id' do
@@ -210,13 +199,15 @@ feature 'Group' do
describe 'group page with nested groups', :nested_groups, :js do
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
+ let!(:project) { create(:project, namespace: group) }
let!(:path) { group_path(group) }
- it 'has nested groups tab with nested groups inside' do
+ it 'it renders projects and groups on the page' do
visit path
- click_link 'Subgroups'
+ wait_for_requests
expect(page).to have_content(nested_group.name)
+ expect(page).to have_content(project.name)
end
end
diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb
index 475c8586f45..3db0729cafb 100644
--- a/spec/features/merge_requests/diff_notes_resolve_spec.rb
+++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb
@@ -97,14 +97,33 @@ feature 'Diff notes resolve', :js do
visit_merge_request
end
- it 'hides when resolve discussion is clicked' do
- expect(page).to have_selector('.discussion-body', visible: false)
+ describe 'timeline view' do
+ it 'hides when resolve discussion is clicked' do
+ expect(page).to have_selector('.discussion-body', visible: false)
+ end
+
+ it 'shows resolved discussion when toggled' do
+ find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click
+
+ expect(page.find(".timeline-content #note_#{note.noteable_id}")).to be_visible
+ end
end
- it 'shows resolved discussion when toggled' do
- find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click
+ describe 'side-by-side view' do
+ before do
+ page.within('.merge-request-tabs') { click_link 'Changes' }
+ page.find('#parallel-diff-btn').click
+ end
- expect(page.find(".timeline-content #note_#{note.noteable_id}")).to be_visible
+ it 'hides when resolve discussion is clicked' do
+ expect(page).to have_selector('.diffs .diff-file .notes_holder', visible: false)
+ end
+
+ it 'shows resolved discussion when toggled' do
+ find('.diff-comment-avatar-holders').click
+
+ expect(find('.diffs .diff-file .notes_holder')).to be_visible
+ end
end
end
diff --git a/spec/features/projects/issues/list_spec.rb b/spec/features/projects/issues/list_spec.rb
deleted file mode 100644
index 9fc03f49f5b..00000000000
--- a/spec/features/projects/issues/list_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-require 'spec_helper'
-
-feature 'Issues List' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
-
- background do
- project.team << [user, :developer]
-
- sign_in(user)
- end
-
- scenario 'user does not see create new list button' do
- create(:issue, project: project)
-
- visit project_issues_path(project)
-
- expect(page).not_to have_selector('.js-new-board-list')
- end
-end
diff --git a/spec/features/projects/issues/user_views_issues_spec.rb b/spec/features/projects/issues/user_views_issues_spec.rb
new file mode 100644
index 00000000000..d35009b8974
--- /dev/null
+++ b/spec/features/projects/issues/user_views_issues_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe 'User views issues' do
+ set(:user) { create(:user) }
+
+ shared_examples_for 'shows issues' do
+ it 'shows issues' do
+ expect(page).to have_content(project.name)
+ .and have_content(issue1.title)
+ .and have_content(issue2.title)
+ .and have_no_selector('.js-new-board-list')
+ end
+ end
+
+ context 'when project is public' do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:issue1) { create(:issue, project: project) }
+ set(:issue2) { create(:issue, project: project) }
+
+ context 'when signed in' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit(project_issues_path(project))
+ end
+
+ include_examples 'shows issues'
+ end
+
+ context 'when not signed in' do
+ before do
+ visit(project_issues_path(project))
+ end
+
+ include_examples 'shows issues'
+ end
+ end
+
+ context 'when project is internal' do
+ set(:project) { create(:project_empty_repo, :internal) }
+ set(:issue1) { create(:issue, project: project) }
+ set(:issue2) { create(:issue, project: project) }
+
+ context 'when signed in' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit(project_issues_path(project))
+ end
+
+ include_examples 'shows issues'
+ end
+ end
+end
diff --git a/spec/features/projects/merge_requests/user_views_open_merge_requests_spec.rb b/spec/features/projects/merge_requests/user_views_open_merge_requests_spec.rb
index 07b8c1ef479..bf95dbb7d09 100644
--- a/spec/features/projects/merge_requests/user_views_open_merge_requests_spec.rb
+++ b/spec/features/projects/merge_requests/user_views_open_merge_requests_spec.rb
@@ -1,72 +1,115 @@
require 'spec_helper'
describe 'User views open merge requests' do
- let(:project) { create(:project, :public, :repository) }
+ set(:user) { create(:user) }
- context "when the target branch is the project's default branch" do
- let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
- let!(:closed_merge_request) { create(:closed_merge_request, source_project: project, target_project: project) }
-
- before do
- visit(project_merge_requests_path(project))
+ shared_examples_for 'shows merge requests' do
+ it 'shows merge requests' do
+ expect(page).to have_content(project.name).and have_content(merge_request.source_project.name)
end
+ end
- it 'shows open merge requests' do
- expect(page).to have_content(merge_request.title).and have_no_content(closed_merge_request.title)
- end
+ context 'when project is public' do
+ set(:project) { create(:project, :public, :repository) }
- it 'does not show target branch name' do
- expect(page).to have_content(merge_request.title)
- expect(find('.issuable-info')).not_to have_content(project.default_branch)
- end
- end
+ context 'when not signed in' do
+ context "when the target branch is the project's default branch" do
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+ let!(:closed_merge_request) { create(:closed_merge_request, source_project: project, target_project: project) }
- context "when the target branch is different from the project's default branch" do
- let!(:merge_request) do
- create(:merge_request,
- source_project: project,
- target_project: project,
- source_branch: 'fix',
- target_branch: 'feature_conflict')
- end
+ before do
+ visit(project_merge_requests_path(project))
+ end
- before do
- visit(project_merge_requests_path(project))
- end
+ include_examples 'shows merge requests'
- it 'shows target branch name' do
- expect(page).to have_content(merge_request.target_branch)
- end
- end
+ it 'shows open merge requests' do
+ expect(page).to have_content(merge_request.title).and have_no_content(closed_merge_request.title)
+ end
- context 'when a merge request has pipelines' do
- let!(:build) { create :ci_build, pipeline: pipeline }
+ it 'does not show target branch name' do
+ expect(page).to have_content(merge_request.title)
+ expect(find('.issuable-info')).not_to have_content(project.default_branch)
+ end
+ end
- let(:merge_request) do
- create(:merge_request_with_diffs,
- source_project: project,
- target_project: project,
- source_branch: 'merge-test')
- end
+ context "when the target branch is different from the project's default branch" do
+ let!(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ target_project: project,
+ source_branch: 'fix',
+ target_branch: 'feature_conflict')
+ end
+
+ before do
+ visit(project_merge_requests_path(project))
+ end
+
+ it 'shows target branch name' do
+ expect(page).to have_content(merge_request.target_branch)
+ end
+ end
- let(:pipeline) do
- create(:ci_pipeline,
- project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch,
- head_pipeline_of: merge_request)
+ context 'when a merge request has pipelines' do
+ let!(:build) { create :ci_build, pipeline: pipeline }
+
+ let(:merge_request) do
+ create(:merge_request_with_diffs,
+ source_project: project,
+ target_project: project,
+ source_branch: 'merge-test')
+ end
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ head_pipeline_of: merge_request)
+ end
+
+ before do
+ project.enable_ci
+
+ visit(project_merge_requests_path(project))
+ end
+
+ it 'shows pipeline status' do
+ page.within('.mr-list') do
+ expect(page).to have_link('Pipeline: pending')
+ end
+ end
+ end
end
- before do
- project.enable_ci
+ context 'when signed in' do
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
- visit(project_merge_requests_path(project))
+ visit(project_merge_requests_path(project))
+ end
+
+ include_examples 'shows merge requests'
end
+ end
- it 'shows pipeline status' do
- page.within('.mr-list') do
- expect(page).to have_link('Pipeline: pending')
+ context 'when project is internal' do
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+ set(:project) { create(:project, :internal, :repository) }
+
+ context 'when signed in' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit(project_merge_requests_path(project))
end
+
+ include_examples 'shows merge requests'
end
end
end
diff --git a/spec/features/projects/user_views_details_spec.rb b/spec/features/projects/user_views_details_spec.rb
new file mode 100644
index 00000000000..ffc063654cd
--- /dev/null
+++ b/spec/features/projects/user_views_details_spec.rb
@@ -0,0 +1,151 @@
+require 'spec_helper'
+
+describe 'User views details' do
+ set(:user) { create(:user) }
+
+ shared_examples_for 'redirects to the sign in page' do
+ it 'redirects to the sign in page' do
+ expect(current_path).to eq(new_user_session_path)
+ end
+ end
+
+ shared_examples_for 'shows details of empty project' do
+ let(:user_has_ssh_key) { false }
+
+ it 'shows details' do
+ expect(page).not_to have_content('Git global setup')
+
+ page.all(:css, '.git-empty .clone').each do |element|
+ expect(element.text).to include(project.http_url_to_repo)
+ end
+
+ expect(page).to have_field('project_clone', with: project.http_url_to_repo) unless user_has_ssh_key
+ end
+ end
+
+ shared_examples_for 'shows details of non empty project' do
+ let(:user_has_ssh_key) { false }
+
+ it 'shows details' do
+ page.within('.breadcrumbs .breadcrumb-item-text') do
+ expect(page).to have_content(project.title)
+ end
+
+ expect(page).to have_field('project_clone', with: project.http_url_to_repo) unless user_has_ssh_key
+ end
+ end
+
+ context 'when project is public' do
+ context 'when project is empty' do
+ set(:project) { create(:project_empty_repo, :public) }
+
+ context 'when not signed in' do
+ before do
+ visit(project_path(project))
+ end
+
+ include_examples 'shows details of empty project'
+ end
+
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
+
+ context 'when user does not have ssh keys' do
+ before do
+ visit(project_path(project))
+ end
+
+ include_examples 'shows details of empty project'
+ end
+
+ context 'when user has ssh keys' do
+ before do
+ create(:personal_key, user: user)
+
+ visit(project_path(project))
+ end
+
+ include_examples 'shows details of empty project' do
+ let(:user_has_ssh_key) { true }
+ end
+ end
+ end
+ end
+
+ context 'when project is not empty' do
+ set(:project) { create(:project, :public, :repository) }
+
+ before do
+ visit(project_path(project))
+ end
+
+ context 'when not signed in' do
+ before do
+ allow(Gitlab.config.gitlab).to receive(:host).and_return('www.example.com')
+ end
+
+ include_examples 'shows details of non empty project'
+ end
+
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
+
+ context 'when user does not have ssh keys' do
+ before do
+ visit(project_path(project))
+ end
+
+ include_examples 'shows details of non empty project'
+ end
+
+ context 'when user has ssh keys' do
+ before do
+ create(:personal_key, user: user)
+
+ visit(project_path(project))
+ end
+
+ include_examples 'shows details of non empty project' do
+ let(:user_has_ssh_key) { true }
+ end
+ end
+ end
+ end
+ end
+
+ context 'when project is internal' do
+ set(:project) { create(:project, :internal, :repository) }
+
+ context 'when not signed in' do
+ before do
+ visit(project_path(project))
+ end
+
+ include_examples 'redirects to the sign in page'
+ end
+
+ context 'when signed in' do
+ before do
+ sign_in(user)
+
+ visit(project_path(project))
+ end
+
+ include_examples 'shows details of non empty project'
+ end
+ end
+
+ context 'when project is private' do
+ set(:project) { create(:project, :private) }
+
+ before do
+ visit(project_path(project))
+ end
+
+ include_examples 'redirects to the sign in page'
+ end
+end
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index 78c350c8ee4..d63cbe578d8 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -14,7 +14,6 @@ feature 'Projects > Wiki > User previews markdown changes', :js do
background do
project.team << [user, :master]
- WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute
sign_in(user)
diff --git a/spec/features/projects/wiki/shortcuts_spec.rb b/spec/features/projects/wiki/shortcuts_spec.rb
index eaff5f876b6..f70d1e710dd 100644
--- a/spec/features/projects/wiki/shortcuts_spec.rb
+++ b/spec/features/projects/wiki/shortcuts_spec.rb
@@ -3,9 +3,7 @@ require 'spec_helper'
feature 'Wiki shortcuts', :js do
let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace) }
- let(:wiki_page) do
- WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute
- end
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: 'Home page' }) }
before do
sign_in(user)
diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
index 9a92622ba2b..37a118c34ab 100644
--- a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
@@ -3,14 +3,7 @@ require 'spec_helper'
describe 'Projects > Wiki > User views Git access wiki page' do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
- let(:wiki_page) do
- WikiPages::CreateService.new(
- project,
- user,
- title: 'home',
- content: '[some link](other-page)'
- ).execute
- end
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: '[some link](other-page)' }) }
before do
sign_in(user)
diff --git a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
index cf9fe4c1ad1..ebb3bd044c1 100644
--- a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
@@ -18,12 +18,7 @@ describe 'Projects > Wiki > User views wiki in project page' do
context 'when wiki homepage contains a link' do
before do
- WikiPages::CreateService.new(
- project,
- user,
- title: 'home',
- content: '[some link](other-page)'
- ).execute
+ create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: '[some link](other-page)' })
end
it 'displays the correct URL for the link' do
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 3bc7ec3123f..3b01ed442bf 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -139,7 +139,7 @@ feature 'Project' do
it 'removes a project' do
expect { remove_with_confirm('Remove project', project.path) }.to change {Project.count}.by(-1)
- expect(page).to have_content "Project 'test / project1' will be deleted."
+ expect(page).to have_content "Project 'test / project1' is in the process of being deleted."
expect(Project.all.count).to be_zero
expect(project.issues).to be_empty
expect(project.merge_requests).to be_empty
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
new file mode 100644
index 00000000000..074914420a1
--- /dev/null
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendantsFinder do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:params) { {} }
+ subject(:finder) do
+ described_class.new(current_user: user, parent_group: group, params: params)
+ end
+
+ before do
+ group.add_owner(user)
+ end
+
+ describe '#has_children?' do
+ it 'is true when there are projects' do
+ create(:project, namespace: group)
+
+ expect(finder.has_children?).to be_truthy
+ end
+
+ context 'when there are subgroups', :nested_groups do
+ it 'is true when there are projects' do
+ create(:group, parent: group)
+
+ expect(finder.has_children?).to be_truthy
+ end
+ end
+ end
+
+ describe '#execute' do
+ it 'includes projects' do
+ project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(project)
+ end
+
+ context 'when archived is `true`' do
+ let(:params) { { archived: 'true' } }
+
+ it 'includes archived projects' do
+ archived_project = create(:project, namespace: group, archived: true)
+ project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(archived_project, project)
+ end
+ end
+
+ context 'when archived is `only`' do
+ let(:params) { { archived: 'only' } }
+
+ it 'includes only archived projects' do
+ archived_project = create(:project, namespace: group, archived: true)
+ _project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(archived_project)
+ end
+ end
+
+ it 'does not include archived projects' do
+ _archived_project = create(:project, :archived, namespace: group)
+
+ expect(finder.execute).to be_empty
+ end
+
+ context 'with a filter' do
+ let(:params) { { filter: 'test' } }
+
+ it 'includes only projects matching the filter' do
+ _other_project = create(:project, namespace: group)
+ matching_project = create(:project, namespace: group, name: 'testproject')
+
+ expect(finder.execute).to contain_exactly(matching_project)
+ end
+ end
+ end
+
+ context 'with nested groups', :nested_groups do
+ let!(:project) { create(:project, namespace: group) }
+ let!(:subgroup) { create(:group, :private, parent: group) }
+
+ describe '#execute' do
+ it 'contains projects and subgroups' do
+ expect(finder.execute).to contain_exactly(subgroup, project)
+ end
+
+ it 'does not include subgroups the user does not have access to' do
+ subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+ public_subgroup = create(:group, :public, parent: group, path: 'public-group')
+ other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group')
+ other_user = create(:user)
+ other_subgroup.add_developer(other_user)
+
+ finder = described_class.new(current_user: other_user, parent_group: group)
+
+ expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup)
+ end
+
+ it 'only includes public groups when no user is given' do
+ public_subgroup = create(:group, :public, parent: group)
+ _private_subgroup = create(:group, :private, parent: group)
+
+ finder = described_class.new(current_user: nil, parent_group: group)
+
+ expect(finder.execute).to contain_exactly(public_subgroup)
+ end
+
+ context 'when archived is `true`' do
+ let(:params) { { archived: 'true' } }
+
+ it 'includes archived projects in the count of subgroups' do
+ create(:project, namespace: subgroup, archived: true)
+
+ expect(finder.execute.first.preloaded_project_count).to eq(1)
+ end
+ end
+
+ context 'with a filter' do
+ let(:params) { { filter: 'test' } }
+
+ it 'contains only matching projects and subgroups' do
+ matching_project = create(:project, namespace: group, name: 'Testproject')
+ matching_subgroup = create(:group, name: 'testgroup', parent: group)
+
+ expect(finder.execute).to contain_exactly(matching_subgroup, matching_project)
+ end
+
+ it 'does not include subgroups the user does not have access to' do
+ _invisible_subgroup = create(:group, :private, parent: group, name: 'test1')
+ other_subgroup = create(:group, :private, parent: group, name: 'test2')
+ public_subgroup = create(:group, :public, parent: group, name: 'test3')
+ other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4')
+ other_user = create(:user)
+ other_subgroup.add_developer(other_user)
+
+ finder = described_class.new(current_user: other_user,
+ parent_group: group,
+ params: params)
+
+ expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup)
+ end
+
+ context 'with matching children' do
+ it 'includes a group that has a subgroup matching the query and its parent' do
+ matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup)
+
+ expect(finder.execute).to contain_exactly(subgroup, matching_subgroup)
+ end
+
+ it 'includes the parent of a matching project' do
+ matching_project = create(:project, namespace: subgroup, name: 'Testproject')
+
+ expect(finder.execute).to contain_exactly(subgroup, matching_project)
+ end
+
+ it 'does not include the parent itself' do
+ group.update!(name: 'test')
+
+ expect(finder.execute).not_to include(group)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/registry/tag.json b/spec/fixtures/api/schemas/registry/tag.json
index 5bc307e0e64..3a2c88791e1 100644
--- a/spec/fixtures/api/schemas/registry/tag.json
+++ b/spec/fixtures/api/schemas/registry/tag.json
@@ -14,6 +14,11 @@
"revision": {
"type": "string"
},
+ "short_revision": {
+ "type": "string",
+ "minLength": 9,
+ "maxLength": 9
+ },
"total_size": {
"type": "integer"
},
diff --git a/spec/fixtures/pages.tar.gz b/spec/fixtures/pages.tar.gz
index d0e89378b3e..5c4ea9690e8 100644
--- a/spec/fixtures/pages.tar.gz
+++ b/spec/fixtures/pages.tar.gz
Binary files differ
diff --git a/spec/fixtures/pages.zip b/spec/fixtures/pages.zip
index 9558fcd4b94..9bb75f953f8 100644
--- a/spec/fixtures/pages.zip
+++ b/spec/fixtures/pages.zip
Binary files differ
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 87ae1fa5660..7a241b02d28 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -309,4 +309,12 @@ describe ApplicationHelper do
end
end
end
+
+ describe '#locale_path' do
+ it 'returns the locale path with an `_`' do
+ Gitlab::I18n.with_locale('pt-BR') do
+ expect(helper.locale_path).to include('assets/locale/pt_BR/app')
+ end
+ end
+ end
end
diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb
index 9a974e70e8c..a11824d0ac5 100644
--- a/spec/initializers/settings_spec.rb
+++ b/spec/initializers/settings_spec.rb
@@ -18,26 +18,6 @@ describe Settings do
end
end
- describe '#repositories' do
- it 'assigns the default failure attributes' do
- repository_settings = Gitlab.config.repositories.storages['broken']
-
- expect(repository_settings['failure_count_threshold']).to eq(10)
- expect(repository_settings['failure_wait_time']).to eq(30)
- expect(repository_settings['failure_reset_time']).to eq(1800)
- expect(repository_settings['storage_timeout']).to eq(5)
- end
-
- it 'can be accessed with dot syntax all the way down' do
- expect(Gitlab.config.repositories.storages.broken.failure_count_threshold).to eq(10)
- end
-
- it 'can be accessed in a very specific way that breaks without reassigning each element with Settingslogic' do
- storage_settings = Gitlab.config.repositories.storages['broken']
- expect(storage_settings.failure_count_threshold).to eq(10)
- end
- end
-
describe '#host_without_www' do
context 'URL with protocol' do
it 'returns the host' do
diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js
index a22b71fd1dc..268b5b83b73 100644
--- a/spec/javascripts/awards_handler_spec.js
+++ b/spec/javascripts/awards_handler_spec.js
@@ -28,7 +28,7 @@ import '~/lib/utils/common_utils';
preloadFixtures('merge_requests/diff_comment.html.raw');
beforeEach(function(done) {
loadFixtures('merge_requests/diff_comment.html.raw');
- $('body').data('page', 'projects:merge_requests:show');
+ $('body').attr('data-page', 'projects:merge_requests:show');
loadAwardsHandler(true).then((obj) => {
awardsHandler = obj;
spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb());
@@ -55,6 +55,9 @@ import '~/lib/utils/common_utils';
// restore original url root value
gon.relative_url_root = urlRoot;
+ // Undo what we did to the shared <body>
+ $('body').removeAttr('data-page');
+
awardsHandler.destroy();
});
describe('::showEmojiMenu', function() {
diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js
index f62bf43adb9..d5300d9c63d 100644
--- a/spec/javascripts/behaviors/quick_submit_spec.js
+++ b/spec/javascripts/behaviors/quick_submit_spec.js
@@ -19,6 +19,11 @@ describe('Quick Submit behavior', () => {
this.textarea = $('.js-quick-submit textarea').first();
});
+ afterEach(() => {
+ // Undo what we did to the shared <body>
+ $('body').removeAttr('data-page');
+ });
+
it('does not respond to other keyCodes', () => {
this.textarea.trigger(keydownEvent({
keyCode: 32,
diff --git a/spec/javascripts/blob/blob_file_dropzone_spec.js b/spec/javascripts/blob/blob_file_dropzone_spec.js
index 2c8183ff77b..47de63e6690 100644
--- a/spec/javascripts/blob/blob_file_dropzone_spec.js
+++ b/spec/javascripts/blob/blob_file_dropzone_spec.js
@@ -1,4 +1,3 @@
-import 'dropzone';
import BlobFileDropzone from '~/blob/blob_file_dropzone';
describe('BlobFileDropzone', () => {
diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
index 67166802c70..2ecb64d84b5 100644
--- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
@@ -791,6 +791,29 @@ describe('Filtered Search Visual Tokens', () => {
expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
const avatar = tokenValueElement.querySelector('img.avatar');
expect(avatar.src).toBe(dummyUser.avatar_url);
+ expect(avatar.alt).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('escapes user name when creating token', (done) => {
+ const dummyUser = {
+ name: '<script>',
+ avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`,
+ };
+ const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = (username) => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(dummyUser);
+ };
+
+ subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ tokenValueElement.querySelector('.avatar').remove();
+ expect(tokenValueElement.innerHTML.trim()).toBe(_.escape(dummyUser.name));
})
.then(done)
.catch(done.fail);
diff --git a/spec/javascripts/flash_spec.js b/spec/javascripts/flash_spec.js
index 060ffaa339b..b669aabcee4 100644
--- a/spec/javascripts/flash_spec.js
+++ b/spec/javascripts/flash_spec.js
@@ -2,6 +2,7 @@ import flash, {
createFlashEl,
createAction,
hideFlash,
+ removeFlashClickListener,
} from '~/flash';
describe('Flash', () => {
@@ -266,4 +267,24 @@ describe('Flash', () => {
});
});
});
+
+ describe('removeFlashClickListener', () => {
+ beforeEach(() => {
+ document.body.innerHTML += '<div class="flash-container"><div class="flash"></div></div>';
+ });
+
+ it('removes global flash on click', (done) => {
+ const flashEl = document.querySelector('.flash');
+
+ removeFlashClickListener(flashEl, false);
+
+ flashEl.parentNode.click();
+
+ setTimeout(() => {
+ expect(document.querySelector('.flash')).toBeNull();
+
+ done();
+ });
+ });
+ });
});
diff --git a/spec/javascripts/gl_field_errors_spec.js b/spec/javascripts/gl_field_errors_spec.js
index fa24aa426b6..2779686a6f5 100644
--- a/spec/javascripts/gl_field_errors_spec.js
+++ b/spec/javascripts/gl_field_errors_spec.js
@@ -1,110 +1,108 @@
/* eslint-disable space-before-function-paren, arrow-body-style */
-import '~/gl_field_errors';
+import GlFieldErrors from '~/gl_field_errors';
-((global) => {
+describe('GL Style Field Errors', function() {
preloadFixtures('static/gl_field_errors.html.raw');
- describe('GL Style Field Errors', function() {
- beforeEach(function() {
- loadFixtures('static/gl_field_errors.html.raw');
- const $form = this.$form = $('form.gl-show-field-errors');
- this.fieldErrors = new global.GlFieldErrors($form);
- });
+ beforeEach(function() {
+ loadFixtures('static/gl_field_errors.html.raw');
+ const $form = this.$form = $('form.gl-show-field-errors');
+ this.fieldErrors = new GlFieldErrors($form);
+ });
- it('should select the correct input elements', function() {
- expect(this.$form).toBeDefined();
- expect(this.$form.length).toBe(1);
- expect(this.fieldErrors).toBeDefined();
- const inputs = this.fieldErrors.state.inputs;
- expect(inputs.length).toBe(4);
- });
+ it('should select the correct input elements', function() {
+ expect(this.$form).toBeDefined();
+ expect(this.$form.length).toBe(1);
+ expect(this.fieldErrors).toBeDefined();
+ const inputs = this.fieldErrors.state.inputs;
+ expect(inputs.length).toBe(4);
+ });
- it('should ignore elements with custom error handling', function() {
- const customErrorFlag = 'gl-field-error-ignore';
- const customErrorElem = $(`.${customErrorFlag}`);
+ it('should ignore elements with custom error handling', function() {
+ const customErrorFlag = 'gl-field-error-ignore';
+ const customErrorElem = $(`.${customErrorFlag}`);
- expect(customErrorElem.length).toBe(1);
+ expect(customErrorElem.length).toBe(1);
- const customErrors = this.fieldErrors.state.inputs.filter((input) => {
- return input.inputElement.hasClass(customErrorFlag);
- });
- expect(customErrors.length).toBe(0);
+ const customErrors = this.fieldErrors.state.inputs.filter((input) => {
+ return input.inputElement.hasClass(customErrorFlag);
});
+ expect(customErrors.length).toBe(0);
+ });
- it('should not show any errors before submit attempt', function() {
- this.$form.find('.email').val('not-a-valid-email').keyup();
- this.$form.find('.text-required').val('').keyup();
- this.$form.find('.alphanumberic').val('?---*').keyup();
+ it('should not show any errors before submit attempt', function() {
+ this.$form.find('.email').val('not-a-valid-email').keyup();
+ this.$form.find('.text-required').val('').keyup();
+ this.$form.find('.alphanumberic').val('?---*').keyup();
- const errorsShown = this.$form.find('.gl-field-error-outline');
- expect(errorsShown.length).toBe(0);
- });
+ const errorsShown = this.$form.find('.gl-field-error-outline');
+ expect(errorsShown.length).toBe(0);
+ });
- it('should show errors when input valid is submitted', function() {
- this.$form.find('.email').val('not-a-valid-email').keyup();
- this.$form.find('.text-required').val('').keyup();
- this.$form.find('.alphanumberic').val('?---*').keyup();
+ it('should show errors when input valid is submitted', function() {
+ this.$form.find('.email').val('not-a-valid-email').keyup();
+ this.$form.find('.text-required').val('').keyup();
+ this.$form.find('.alphanumberic').val('?---*').keyup();
- this.$form.submit();
+ this.$form.submit();
- const errorsShown = this.$form.find('.gl-field-error-outline');
- expect(errorsShown.length).toBe(4);
- });
+ const errorsShown = this.$form.find('.gl-field-error-outline');
+ expect(errorsShown.length).toBe(4);
+ });
- it('should properly track validity state on input after invalid submission attempt', function() {
- this.$form.submit();
-
- const emailInputModel = this.fieldErrors.state.inputs[1];
- const fieldState = emailInputModel.state;
- const emailInputElement = emailInputModel.inputElement;
-
- // No input
- expect(emailInputElement).toHaveClass('gl-field-error-outline');
- expect(fieldState.empty).toBe(true);
- expect(fieldState.valid).toBe(false);
-
- // Then invalid input
- emailInputElement.val('not-a-valid-email').keyup();
- expect(emailInputElement).toHaveClass('gl-field-error-outline');
- expect(fieldState.empty).toBe(false);
- expect(fieldState.valid).toBe(false);
-
- // Then valid input
- emailInputElement.val('email@gitlab.com').keyup();
- expect(emailInputElement).not.toHaveClass('gl-field-error-outline');
- expect(fieldState.empty).toBe(false);
- expect(fieldState.valid).toBe(true);
-
- // Then invalid input
- emailInputElement.val('not-a-valid-email').keyup();
- expect(emailInputElement).toHaveClass('gl-field-error-outline');
- expect(fieldState.empty).toBe(false);
- expect(fieldState.valid).toBe(false);
-
- // Then empty input
- emailInputElement.val('').keyup();
- expect(emailInputElement).toHaveClass('gl-field-error-outline');
- expect(fieldState.empty).toBe(true);
- expect(fieldState.valid).toBe(false);
-
- // Then valid input
- emailInputElement.val('email@gitlab.com').keyup();
- expect(emailInputElement).not.toHaveClass('gl-field-error-outline');
- expect(fieldState.empty).toBe(false);
- expect(fieldState.valid).toBe(true);
- });
+ it('should properly track validity state on input after invalid submission attempt', function() {
+ this.$form.submit();
+
+ const emailInputModel = this.fieldErrors.state.inputs[1];
+ const fieldState = emailInputModel.state;
+ const emailInputElement = emailInputModel.inputElement;
+
+ // No input
+ expect(emailInputElement).toHaveClass('gl-field-error-outline');
+ expect(fieldState.empty).toBe(true);
+ expect(fieldState.valid).toBe(false);
+
+ // Then invalid input
+ emailInputElement.val('not-a-valid-email').keyup();
+ expect(emailInputElement).toHaveClass('gl-field-error-outline');
+ expect(fieldState.empty).toBe(false);
+ expect(fieldState.valid).toBe(false);
+
+ // Then valid input
+ emailInputElement.val('email@gitlab.com').keyup();
+ expect(emailInputElement).not.toHaveClass('gl-field-error-outline');
+ expect(fieldState.empty).toBe(false);
+ expect(fieldState.valid).toBe(true);
+
+ // Then invalid input
+ emailInputElement.val('not-a-valid-email').keyup();
+ expect(emailInputElement).toHaveClass('gl-field-error-outline');
+ expect(fieldState.empty).toBe(false);
+ expect(fieldState.valid).toBe(false);
+
+ // Then empty input
+ emailInputElement.val('').keyup();
+ expect(emailInputElement).toHaveClass('gl-field-error-outline');
+ expect(fieldState.empty).toBe(true);
+ expect(fieldState.valid).toBe(false);
+
+ // Then valid input
+ emailInputElement.val('email@gitlab.com').keyup();
+ expect(emailInputElement).not.toHaveClass('gl-field-error-outline');
+ expect(fieldState.empty).toBe(false);
+ expect(fieldState.valid).toBe(true);
+ });
- it('should properly infer error messages', function() {
- this.$form.submit();
- const trackedInputs = this.fieldErrors.state.inputs;
- const inputHasTitle = trackedInputs[1];
- const hasTitleErrorElem = inputHasTitle.inputElement.siblings('.gl-field-error');
- const inputNoTitle = trackedInputs[2];
- const noTitleErrorElem = inputNoTitle.inputElement.siblings('.gl-field-error');
+ it('should properly infer error messages', function() {
+ this.$form.submit();
+ const trackedInputs = this.fieldErrors.state.inputs;
+ const inputHasTitle = trackedInputs[1];
+ const hasTitleErrorElem = inputHasTitle.inputElement.siblings('.gl-field-error');
+ const inputNoTitle = trackedInputs[2];
+ const noTitleErrorElem = inputNoTitle.inputElement.siblings('.gl-field-error');
- expect(noTitleErrorElem.text()).toBe('This field is required.');
- expect(hasTitleErrorElem.text()).toBe('Please provide a valid email address.');
- });
+ expect(noTitleErrorElem.text()).toBe('This field is required.');
+ expect(hasTitleErrorElem.text()).toBe('Please provide a valid email address.');
});
-})(window.gl || (window.gl = {}));
+});
diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js
index 837feacec1d..124fc030774 100644
--- a/spec/javascripts/gl_form_spec.js
+++ b/spec/javascripts/gl_form_spec.js
@@ -1,18 +1,11 @@
import autosize from 'vendor/autosize';
-import '~/gl_form';
+import GLForm from '~/gl_form';
import '~/lib/utils/text_utility';
import '~/lib/utils/common_utils';
window.autosize = autosize;
describe('GLForm', () => {
- const global = window.gl || (window.gl = {});
- const GLForm = global.GLForm;
-
- it('should be defined in the global scope', () => {
- expect(GLForm).toBeDefined();
- });
-
describe('when instantiated', function () {
beforeEach((done) => {
this.form = $('<form class="gfm-form"><textarea class="js-gfm-input"></form>');
diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js
new file mode 100644
index 00000000000..cd19a0fae1e
--- /dev/null
+++ b/spec/javascripts/groups/components/app_spec.js
@@ -0,0 +1,443 @@
+import Vue from 'vue';
+
+import appComponent from '~/groups/components/app.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+
+import eventHub from '~/groups/event_hub';
+import GroupsStore from '~/groups/store/groups_store';
+import GroupsService from '~/groups/service/groups_service';
+
+import {
+ mockEndpoint, mockGroups, mockSearchedGroups,
+ mockRawPageInfo, mockParentGroupItem, mockRawChildren,
+ mockChildren, mockPageInfo,
+} from '../mock_data';
+
+const createComponent = (hideProjects = false) => {
+ const Component = Vue.extend(appComponent);
+ const store = new GroupsStore(false);
+ const service = new GroupsService(mockEndpoint);
+
+ return new Component({
+ propsData: {
+ store,
+ service,
+ hideProjects,
+ },
+ });
+};
+
+const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
+ if (failed) {
+ reject(data);
+ } else {
+ resolve({
+ json() {
+ return data;
+ },
+ });
+ }
+});
+
+describe('AppComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ describe('computed', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('groups', () => {
+ it('should return list of groups from store', () => {
+ spyOn(vm.store, 'getGroups');
+
+ const groups = vm.groups;
+ expect(vm.store.getGroups).toHaveBeenCalled();
+ expect(groups).not.toBeDefined();
+ });
+ });
+
+ describe('pageInfo', () => {
+ it('should return pagination info from store', () => {
+ spyOn(vm.store, 'getPaginationInfo');
+
+ const pageInfo = vm.pageInfo;
+ expect(vm.store.getPaginationInfo).toHaveBeenCalled();
+ expect(pageInfo).not.toBeDefined();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('fetchGroups', () => {
+ it('should call `getGroups` with all the params provided', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
+
+ vm.fetchGroups({
+ parentId: 1,
+ page: 2,
+ filterGroupsBy: 'git',
+ sortBy: 'created_desc',
+ archived: true,
+ });
+ setTimeout(() => {
+ expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
+ done();
+ }, 0);
+ });
+
+ it('should set headers to store for building pagination info when called with `updatePagination`', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo }));
+ spyOn(vm, 'updatePagination');
+
+ vm.fetchGroups({ updatePagination: true });
+ setTimeout(() => {
+ expect(vm.service.getGroups).toHaveBeenCalled();
+ expect(vm.updatePagination).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should show flash error when request fails', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
+ spyOn($, 'scrollTo');
+ spyOn(window, 'Flash');
+
+ vm.fetchGroups({});
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
+ done();
+ }, 0);
+ });
+ });
+
+ describe('fetchAllGroups', () => {
+ it('should fetch default set of groups', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+ spyOn(vm, 'updatePagination').and.callThrough();
+ spyOn(vm, 'updateGroups').and.callThrough();
+
+ vm.fetchAllGroups();
+ expect(vm.isLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should fetch matching set of groups when app is loaded with search query', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
+ spyOn(vm, 'updateGroups').and.callThrough();
+
+ vm.fetchAllGroups();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: null,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: null,
+ });
+ setTimeout(() => {
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('fetchPage', () => {
+ it('should fetch groups for provided page details and update window state', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+ spyOn(vm, 'updateGroups').and.callThrough();
+ spyOn(gl.utils, 'mergeUrlParams').and.callThrough();
+ spyOn(window.history, 'replaceState');
+ spyOn($, 'scrollTo');
+
+ vm.fetchPage(2, null, null, true);
+ expect(vm.isLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: 2,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: true,
+ });
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(gl.utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
+ expect(window.history.replaceState).toHaveBeenCalledWith({
+ page: jasmine.any(String),
+ }, jasmine.any(String), jasmine.any(String));
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('toggleChildren', () => {
+ let groupItem;
+
+ beforeEach(() => {
+ groupItem = Object.assign({}, mockParentGroupItem);
+ groupItem.isOpen = false;
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
+ spyOn(vm.store, 'setGroupChildren');
+
+ vm.toggleChildren(groupItem);
+ expect(groupItem.isChildrenLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ parentId: groupItem.id,
+ });
+ setTimeout(() => {
+ expect(vm.store.setGroupChildren).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should skip network request while expanding group if children are already loaded', () => {
+ spyOn(vm, 'fetchGroups');
+ groupItem.children = mockRawChildren;
+
+ vm.toggleChildren(groupItem);
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBeTruthy();
+ });
+
+ it('should collapse group if it is already expanded', () => {
+ spyOn(vm, 'fetchGroups');
+ groupItem.isOpen = true;
+
+ vm.toggleChildren(groupItem);
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBeFalsy();
+ });
+
+ it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
+
+ vm.toggleChildren(groupItem);
+ expect(groupItem.isChildrenLoading).toBeTruthy();
+ setTimeout(() => {
+ expect(groupItem.isChildrenLoading).toBeFalsy();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ let groupItem;
+ let childGroupItem;
+
+ beforeEach(() => {
+ groupItem = Object.assign({}, mockParentGroupItem);
+ groupItem.children = mockChildren;
+ childGroupItem = groupItem.children[0];
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should leave group and remove group item from tree', (done) => {
+ const notice = `You left the "${childGroupItem.fullName}" group.`;
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+ spyOn($, 'scrollTo');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem);
+ expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
+ done();
+ }, 0);
+ });
+
+ it('should show error flash message if request failed to leave group', (done) => {
+ const message = 'An error occurred. Please try again.';
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ done();
+ }, 0);
+ });
+
+ it('should show appropriate error flash message if request forbids to leave group', (done) => {
+ const message = 'Failed to leave the group. Please make sure you are not the only owner.';
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('updatePagination', () => {
+ it('should set pagination info to store from provided headers', () => {
+ spyOn(vm.store, 'setPaginationInfo');
+
+ vm.updatePagination(mockRawPageInfo);
+ expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
+ });
+ });
+
+ describe('updateGroups', () => {
+ it('should call setGroups on store if method was called directly', () => {
+ spyOn(vm.store, 'setGroups');
+
+ vm.updateGroups(mockGroups);
+ expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
+ spyOn(vm.store, 'setSearchedGroups');
+
+ vm.updateGroups(mockGroups, true);
+ expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should set `isSearchEmpty` prop based on groups count', () => {
+ vm.updateGroups(mockGroups);
+ expect(vm.isSearchEmpty).toBeFalsy();
+
+ vm.updateGroups([]);
+ expect(vm.isSearchEmpty).toBeTruthy();
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', (done) => {
+ spyOn(eventHub, '$on');
+
+ const newVm = createComponent();
+ newVm.$mount();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+ newVm.$destroy();
+ done();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => {
+ const newVm = createComponent();
+ newVm.$mount();
+ Vue.nextTick(() => {
+ expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search');
+ newVm.$destroy();
+ done();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => {
+ const newVm = createComponent(true);
+ newVm.$mount();
+ Vue.nextTick(() => {
+ expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search');
+ newVm.$destroy();
+ done();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', (done) => {
+ spyOn(eventHub, '$off');
+
+ const newVm = createComponent();
+ newVm.$mount();
+ newVm.$destroy();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render loading icon', (done) => {
+ vm.isLoading = true;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ expect(vm.$el.querySelector('i.fa').getAttribute('aria-label')).toBe('Loading groups');
+ done();
+ });
+ });
+
+ it('should render groups tree', (done) => {
+ vm.groups = [mockParentGroupItem];
+ vm.isLoading = false;
+ vm.pageInfo = mockPageInfo;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js
new file mode 100644
index 00000000000..4eb198595fb
--- /dev/null
+++ b/spec/javascripts/groups/components/group_folder_spec.js
@@ -0,0 +1,66 @@
+import Vue from 'vue';
+
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import { mockGroups, mockParentGroupItem } from '../mock_data';
+
+const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
+ const Component = Vue.extend(groupFolderComponent);
+
+ return new Component({
+ propsData: {
+ groups,
+ parentGroup,
+ },
+ });
+};
+
+describe('GroupFolderComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hasMoreChildren', () => {
+ it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
+ expect(vm.hasMoreChildren).toBeFalsy();
+ });
+ });
+
+ describe('moreChildrenStats', () => {
+ it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
+ expect(vm.moreChildrenStats).toBe('3 more items');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+ });
+
+ it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
+ const parentGroup = Object.assign({}, mockParentGroupItem);
+ parentGroup.childrenCount = 21;
+
+ const newVm = createComponent(mockGroups, parentGroup);
+ newVm.$mount();
+ expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js
new file mode 100644
index 00000000000..0f4fbdae445
--- /dev/null
+++ b/spec/javascripts/groups/components/group_item_spec.js
@@ -0,0 +1,177 @@
+import Vue from 'vue';
+
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(groupItemComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('GroupItemComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('groupDomId', () => {
+ it('should return ID string suffixed with group ID', () => {
+ expect(vm.groupDomId).toBe('group-55');
+ });
+ });
+
+ describe('rowClass', () => {
+ it('should return map of classes based on group details', () => {
+ const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
+ const rowClass = vm.rowClass;
+
+ expect(Object.keys(rowClass).length).toBe(classes.length);
+ Object.keys(rowClass).forEach((className) => {
+ expect(classes.indexOf(className) > -1).toBeTruthy();
+ });
+ });
+ });
+
+ describe('hasChildren', () => {
+ it('should return boolean value representing if group has any children present', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.childrenCount = 5;
+ newVm = createComponent(group);
+ expect(newVm.hasChildren).toBeTruthy();
+ newVm.$destroy();
+
+ group.childrenCount = 0;
+ newVm = createComponent(group);
+ expect(newVm.hasChildren).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('hasAvatar', () => {
+ it('should return boolean value representing if group has any avatar present', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.avatarUrl = null;
+ newVm = createComponent(group);
+ expect(newVm.hasAvatar).toBeFalsy();
+ newVm.$destroy();
+
+ group.avatarUrl = '/uploads/group_avatar.png';
+ newVm = createComponent(group);
+ expect(newVm.hasAvatar).toBeTruthy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing if group item is of type `group` or not', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.type = 'group';
+ newVm = createComponent(group);
+ expect(newVm.isGroup).toBeTruthy();
+ newVm.$destroy();
+
+ group.type = 'project';
+ newVm = createComponent(group);
+ expect(newVm.isGroup).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onClickRowGroup', () => {
+ let event;
+
+ beforeEach(() => {
+ const classList = {
+ contains() {
+ return false;
+ },
+ };
+
+ event = {
+ target: {
+ classList,
+ parentElement: {
+ classList,
+ },
+ },
+ };
+ });
+
+ it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
+ spyOn(eventHub, '$emit');
+
+ vm.onClickRowGroup(event);
+ expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
+ });
+
+ it('should navigate page to group homepage if group does not have any children present', (done) => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.childrenCount = 0;
+ const newVm = createComponent(group);
+ spyOn(gl.utils, 'visitUrl').and.stub();
+ spyOn(eventHub, '$emit');
+
+ newVm.onClickRowGroup(event);
+ setTimeout(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
+ done();
+ }, 0);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.getAttribute('id')).toBe('group-55');
+ expect(vm.$el.classList.contains('group-row')).toBeTruthy();
+
+ expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
+
+ expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
+
+ expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
+
+ expect(vm.$el.querySelector('.title')).toBeDefined();
+ expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.access-type')).toBeDefined();
+ expect(vm.$el.querySelector('.description')).toBeDefined();
+
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js
new file mode 100644
index 00000000000..90e818c1545
--- /dev/null
+++ b/spec/javascripts/groups/components/groups_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+
+import groupsComponent from '~/groups/components/groups.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import { mockGroups, mockPageInfo } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (searchEmpty = false) => {
+ const Component = Vue.extend(groupsComponent);
+
+ return mountComponent(Component, {
+ groups: mockGroups,
+ pageInfo: mockPageInfo,
+ searchEmptyMessage: 'No matching results',
+ searchEmpty,
+ });
+};
+
+describe('GroupsComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('change', () => {
+ it('should emit `fetchPage` event when page is changed via pagination', () => {
+ spyOn(eventHub, '$emit').and.stub();
+
+ vm.change(2);
+ expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object), jasmine.any(Object));
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', (done) => {
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.has-no-search-results').length === 0).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should render empty search message when `searchEmpty` is `true`', (done) => {
+ vm.searchEmpty = true;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js
new file mode 100644
index 00000000000..2ce1a749a96
--- /dev/null
+++ b/spec/javascripts/groups/components/item_actions_spec.js
@@ -0,0 +1,110 @@
+import Vue from 'vue';
+
+import itemActionsComponent from '~/groups/components/item_actions.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(itemActionsComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('ItemActionsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('leaveConfirmationMessage', () => {
+ it('should return appropriate string for leave group confirmation', () => {
+ expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onLeaveGroup', () => {
+ it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => {
+ expect(vm.dialogStatus).toBeFalsy();
+ vm.onLeaveGroup();
+ expect(vm.dialogStatus).toBeTruthy();
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => {
+ spyOn(eventHub, '$emit');
+ vm.dialogStatus = true;
+ vm.leaveGroup(true);
+ expect(vm.dialogStatus).toBeFalsy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup);
+ });
+
+ it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => {
+ spyOn(eventHub, '$emit');
+ vm.dialogStatus = true;
+ vm.leaveGroup(false);
+ expect(vm.dialogStatus).toBeFalsy();
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('controls')).toBeTruthy();
+ });
+
+ it('should render Edit Group button with correct attribute values', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.canEdit = true;
+ const newVm = createComponent(group);
+
+ const editBtn = newVm.$el.querySelector('a.edit-group');
+ expect(editBtn).toBeDefined();
+ expect(editBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(editBtn.getAttribute('href')).toBe(group.editPath);
+ expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
+ expect(editBtn.dataset.originalTitle).toBe('Edit group');
+ expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined();
+
+ newVm.$destroy();
+ });
+
+ it('should render Leave Group button with correct attribute values', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.canLeave = true;
+ const newVm = createComponent(group);
+
+ const leaveBtn = newVm.$el.querySelector('a.leave-group');
+ expect(leaveBtn).toBeDefined();
+ expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
+ expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
+ expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
+ expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined();
+
+ newVm.$destroy();
+ });
+
+ it('should show modal dialog when `dialogStatus` is set to `true`', () => {
+ vm.dialogStatus = true;
+ const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog');
+ expect(modalDialogEl).toBeDefined();
+ expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+ expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js
new file mode 100644
index 00000000000..4310a07e6e6
--- /dev/null
+++ b/spec/javascripts/groups/components/item_caret_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+
+import itemCaretComponent from '~/groups/components/item_caret.vue';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (isGroupOpen = false) => {
+ const Component = Vue.extend(itemCaretComponent);
+
+ return mountComponent(Component, {
+ isGroupOpen,
+ });
+};
+
+describe('ItemCaretComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
+ const vm = createComponent(true);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1);
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0);
+ vm.$destroy();
+ });
+
+ it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0);
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1);
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js
new file mode 100644
index 00000000000..e200f9f08bd
--- /dev/null
+++ b/spec/javascripts/groups/components/item_stats_spec.js
@@ -0,0 +1,159 @@
+import Vue from 'vue';
+
+import itemStatsComponent from '~/groups/components/item_stats.vue';
+import {
+ mockParentGroupItem,
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+} from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (item = mockParentGroupItem) => {
+ const Component = Vue.extend(itemStatsComponent);
+
+ return mountComponent(Component, {
+ item,
+ });
+};
+
+describe('ItemStatsComponent', () => {
+ describe('computed', () => {
+ describe('visibilityIcon', () => {
+ it('should return icon class based on `item.visibility` value', () => {
+ Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, { visibility });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('visibilityTooltip', () => {
+ it('should return tooltip string for Group based on `item.visibility` value', () => {
+ Object.keys(GROUP_VISIBILITY_TYPE).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ visibility,
+ type: ITEM_TYPE.GROUP,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+
+ it('should return tooltip string for Project based on `item.visibility` value', () => {
+ Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ visibility,
+ type: ITEM_TYPE.PROJECT,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('isProject', () => {
+ it('should return boolean value representing whether `item.type` is Project or not', () => {
+ let item;
+ let vm;
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isProject).toBeTruthy();
+ vm.$destroy();
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isProject).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing whether `item.type` is Group or not', () => {
+ let item;
+ let vm;
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isGroup).toBeTruthy();
+ vm.$destroy();
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isGroup).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+
+ const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+ expect(vm.$el.classList.contains('.stats')).toBeDefined();
+ expect(visibilityIconEl).toBeDefined();
+ expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+ expect(visibilityIconEl.querySelector('i.fa')).toBeDefined();
+
+ vm.$destroy();
+ });
+
+ it('should render stat icons if `item.type` is Group', () => {
+ const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ const vm = createComponent(item);
+ vm.$mount();
+
+ const subgroupIconEl = vm.$el.querySelector('span.number-subgroups');
+ expect(subgroupIconEl).toBeDefined();
+ expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups');
+ expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined();
+ expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`);
+
+ const projectsIconEl = vm.$el.querySelector('span.number-projects');
+ expect(projectsIconEl).toBeDefined();
+ expect(projectsIconEl.dataset.originalTitle).toBe('Projects');
+ expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined();
+ expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`);
+
+ const membersIconEl = vm.$el.querySelector('span.number-users');
+ expect(membersIconEl).toBeDefined();
+ expect(membersIconEl.dataset.originalTitle).toBe('Members');
+ expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined();
+ expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`);
+
+ vm.$destroy();
+ });
+
+ it('should render stat icons if `item.type` is Project', () => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ type: ITEM_TYPE.PROJECT,
+ starCount: 4,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+
+ const projectStarIconEl = vm.$el.querySelector('.project-stars');
+ expect(projectStarIconEl).toBeDefined();
+ expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined();
+ expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`);
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js
new file mode 100644
index 00000000000..528e6ed1b4c
--- /dev/null
+++ b/spec/javascripts/groups/components/item_type_icon_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+
+import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { ITEM_TYPE } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
+ const Component = Vue.extend(itemTypeIconComponent);
+
+ return mountComponent(Component, {
+ itemType,
+ isGroupOpen,
+ });
+};
+
+describe('ItemTypeIconComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render folder open or close icon based `isGroupOpen` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.GROUP, true);
+ vm.$mount();
+ expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined();
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+ vm.$mount();
+ expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined();
+ vm.$destroy();
+ });
+
+ it('should render bookmark icon based on `isProject` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.PROJECT);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1);
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0);
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js
deleted file mode 100644
index 25e10552d95..00000000000
--- a/spec/javascripts/groups/group_item_spec.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import Vue from 'vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import GroupsStore from '~/groups/stores/groups_store';
-import { group1 } from './mock_data';
-
-describe('Groups Component', () => {
- let GroupItemComponent;
- let component;
- let store;
- let group;
-
- describe('group with default data', () => {
- beforeEach((done) => {
- GroupItemComponent = Vue.extend(groupItemComponent);
- store = new GroupsStore();
- group = store.decorateGroup(group1);
-
- component = new GroupItemComponent({
- propsData: {
- group,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should render the group item correctly', () => {
- expect(component.$el.classList.contains('group-row')).toBe(true);
- expect(component.$el.classList.contains('.no-description')).toBe(false);
- expect(component.$el.querySelector('.number-projects').textContent).toContain(group.numberProjects);
- expect(component.$el.querySelector('.number-users').textContent).toContain(group.numberUsers);
- expect(component.$el.querySelector('.group-visibility')).toBeDefined();
- expect(component.$el.querySelector('.avatar-container')).toBeDefined();
- expect(component.$el.querySelector('.title').textContent).toContain(group.name);
- expect(component.$el.querySelector('.access-type').textContent).toContain(group.permissions.humanGroupAccess);
- expect(component.$el.querySelector('.description').textContent).toContain(group.description);
- expect(component.$el.querySelector('.edit-group')).toBeDefined();
- expect(component.$el.querySelector('.leave-group')).toBeDefined();
- });
- });
-
- describe('group without description', () => {
- beforeEach((done) => {
- GroupItemComponent = Vue.extend(groupItemComponent);
- store = new GroupsStore();
- group1.description = '';
- group = store.decorateGroup(group1);
-
- component = new GroupItemComponent({
- propsData: {
- group,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should render group item correctly', () => {
- expect(component.$el.querySelector('.description').textContent).toBe('');
- expect(component.$el.classList.contains('.no-description')).toBe(false);
- });
- });
-
- describe('user has not access to group', () => {
- beforeEach((done) => {
- GroupItemComponent = Vue.extend(groupItemComponent);
- store = new GroupsStore();
- group1.permissions.human_group_access = null;
- group = store.decorateGroup(group1);
-
- component = new GroupItemComponent({
- propsData: {
- group,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should not display access type', () => {
- expect(component.$el.querySelector('.access-type')).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js
deleted file mode 100644
index b14153dbbfa..00000000000
--- a/spec/javascripts/groups/groups_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import Vue from 'vue';
-import eventHub from '~/groups/event_hub';
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import groupsComponent from '~/groups/components/groups.vue';
-import GroupsStore from '~/groups/stores/groups_store';
-import { groupsData } from './mock_data';
-
-describe('Groups Component', () => {
- let GroupsComponent;
- let store;
- let component;
- let groups;
-
- beforeEach((done) => {
- Vue.component('group-folder', groupFolderComponent);
- Vue.component('group-item', groupItemComponent);
-
- store = new GroupsStore();
- groups = store.setGroups(groupsData.groups);
-
- store.storePagination(groupsData.pagination);
-
- GroupsComponent = Vue.extend(groupsComponent);
-
- component = new GroupsComponent({
- propsData: {
- groups: store.state.groups,
- pageInfo: store.state.pageInfo,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- describe('with data', () => {
- it('should render a list of groups', () => {
- expect(component.$el.classList.contains('groups-list-tree-container')).toBe(true);
- expect(component.$el.querySelector('#group-12')).toBeDefined();
- expect(component.$el.querySelector('#group-1119')).toBeDefined();
- expect(component.$el.querySelector('#group-1120')).toBeDefined();
- });
-
- it('should respect the order of groups', () => {
- const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree');
- expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12');
- expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119');
- });
-
- it('should render group and its subgroup', () => {
- const lists = component.$el.querySelectorAll('.group-list-tree');
-
- expect(lists.length).toBe(3); // one parent and two subgroups
-
- expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true);
- expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true);
-
- expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name);
- });
-
- it('should render group identicon when group avatar is not present', () => {
- const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar');
- expect(avatar.nodeName).toBe('DIV');
- expect(avatar.classList.contains('identicon')).toBeTruthy();
- expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
- });
-
- it('should render group avatar when group avatar is present', () => {
- const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar');
- expect(avatar.nodeName).toBe('IMG');
- expect(avatar.classList.contains('identicon')).toBeFalsy();
- });
-
- it('should remove prefix of parent group', () => {
- expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
- });
-
- it('should remove the group after leaving the group', (done) => {
- spyOn(window, 'confirm').and.returnValue(true);
-
- eventHub.$on('leaveGroup', (group, collection) => {
- store.removeGroup(group, collection);
- });
-
- component.$el.querySelector('#group-12 .leave-group').click();
-
- Vue.nextTick(() => {
- expect(component.$el.querySelector('#group-12')).toBeNull();
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js
index 5bb84b591f4..6184d671790 100644
--- a/spec/javascripts/groups/mock_data.js
+++ b/spec/javascripts/groups/mock_data.js
@@ -1,114 +1,380 @@
-const group1 = {
- id: 12,
- name: 'level1',
- path: 'level1',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/level1',
- group_path: '/level1',
- full_name: 'level1',
- full_path: 'level1',
- parent_id: null,
- created_at: '2017-05-15T19:01:23.670Z',
- updated_at: '2017-05-15T19:01:23.670Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+export const mockEndpoint = '/dashboard/groups.json';
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
};
-// This group has no direct parent, should be placed as subgroup of group1
-const group14 = {
- id: 1128,
- name: 'level4',
- path: 'level4',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/level1/level2/level3/level4',
- group_path: '/level1/level2/level3/level4',
- full_name: 'level1 / level2 / level3 / level4',
- full_path: 'level1/level2/level3/level4',
- parent_id: 1127,
- created_at: '2017-05-15T19:02:01.645Z',
- updated_at: '2017-05-15T19:02:01.645Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+export const GROUP_VISIBILITY_TYPE = {
+ public: 'Public - The group and any public projects can be viewed without any authentication.',
+ internal: 'Internal - The group and any internal projects can be viewed by any logged in user.',
+ private: 'Private - The group and its projects can only be viewed by members.',
};
-const group2 = {
- id: 1119,
- name: 'devops',
- path: 'devops',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/devops',
- group_path: '/devops',
- full_name: 'devops',
- full_path: 'devops',
- parent_id: null,
- created_at: '2017-05-11T19:35:09.635Z',
- updated_at: '2017-05-11T19:35:09.635Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+export const PROJECT_VISIBILITY_TYPE = {
+ public: 'Public - The project can be accessed without any authentication.',
+ internal: 'Internal - The project can be accessed by any logged in user.',
+ private: 'Private - Project access must be granted explicitly to each user.',
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ public: 'fa-globe',
+ internal: 'fa-shield',
+ private: 'fa-lock',
};
-const group21 = {
- id: 1120,
- name: 'chef',
- path: 'chef',
- description: 'foo',
+export const mockParentGroupItem = {
+ id: 55,
+ name: 'hardware',
+ description: '',
visibility: 'public',
- avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
- web_url: 'http://localhost:3000/groups/devops/chef',
- group_path: '/devops/chef',
- full_name: 'devops / chef',
- full_path: 'devops/chef',
- parent_id: 1119,
- created_at: '2017-05-11T19:51:04.060Z',
- updated_at: '2017-05-11T19:51:04.060Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+ fullName: 'platform / hardware',
+ relativePath: '/platform/hardware',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/edit',
+ childrenCount: 3,
+ leavePath: '/groups/platform/hardware/group_members/leave',
+ parentId: 54,
+ memberCount: '1',
+ projectCount: 1,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
};
-const groupsData = {
- groups: [group1, group14, group2, group21],
- pagination: {
- Date: 'Mon, 22 May 2017 22:31:52 GMT',
- 'X-Prev-Page': '1',
- 'X-Content-Type-Options': 'nosniff',
- 'X-Total': '31',
- 'Transfer-Encoding': 'chunked',
- 'X-Runtime': '0.611144',
- 'X-Xss-Protection': '1; mode=block',
- 'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09',
- 'X-Ua-Compatible': 'IE=edge',
- 'X-Per-Page': '20',
- Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"',
- 'X-Next-Page': '',
- Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"',
- 'X-Frame-Options': 'DENY',
- 'Content-Type': 'application/json; charset=utf-8',
- 'Cache-Control': 'max-age=0, private, must-revalidate',
- 'X-Total-Pages': '2',
- 'X-Page': '2',
+export const mockRawChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [],
+ },
+];
+
+export const mockChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ fullName: 'platform / hardware / bsp',
+ relativePath: '/platform/hardware/bsp',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/bsp/edit',
+ childrenCount: 6,
+ leavePath: '/groups/platform/hardware/bsp/group_members/leave',
+ parentId: 55,
+ memberCount: '1',
+ projectCount: 4,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
},
+];
+
+export const mockGroups = [
+ {
+ id: 75,
+ name: 'test-group',
+ description: '',
+ visibility: 'public',
+ full_name: 'test-group',
+ relative_path: '/test-group',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/test-group/edit',
+ children_count: 2,
+ leave_path: '/groups/test-group/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 67,
+ name: 'open-source',
+ description: '',
+ visibility: 'private',
+ full_name: 'open-source',
+ relative_path: '/open-source',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/open-source/edit',
+ children_count: 0,
+ leave_path: '/groups/open-source/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 54,
+ name: 'platform',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform',
+ relative_path: '/platform',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ },
+ {
+ id: 5,
+ name: 'H5bp',
+ description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.',
+ visibility: 'public',
+ full_name: 'H5bp',
+ relative_path: '/h5bp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/h5bp/edit',
+ children_count: 1,
+ leave_path: '/groups/h5bp/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 4,
+ name: 'Twitter',
+ description: 'Deserunt hic nostrum placeat veniam.',
+ visibility: 'public',
+ full_name: 'Twitter',
+ relative_path: '/twitter',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/twitter/edit',
+ children_count: 2,
+ leave_path: '/groups/twitter/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 3,
+ name: 'Documentcloud',
+ description: 'Consequatur saepe totam ea pariatur maxime.',
+ visibility: 'public',
+ full_name: 'Documentcloud',
+ relative_path: '/documentcloud',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/documentcloud/edit',
+ children_count: 1,
+ leave_path: '/groups/documentcloud/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 2,
+ name: 'Gitlab Org',
+ description: 'Debitis ea quas aperiam velit doloremque ab.',
+ visibility: 'public',
+ full_name: 'Gitlab Org',
+ relative_path: '/gitlab-org',
+ can_edit: true,
+ type: 'group',
+ avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
+ permission: 'Owner',
+ edit_path: '/groups/gitlab-org/edit',
+ children_count: 4,
+ leave_path: '/groups/gitlab-org/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 4,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+];
+
+export const mockSearchedGroups = [
+ {
+ id: 55,
+ name: 'hardware',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware',
+ relative_path: '/platform/hardware',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/edit',
+ children_count: 3,
+ leave_path: '/groups/platform/hardware/group_members/leave',
+ parent_id: 54,
+ number_users_with_delimiter: '1',
+ project_count: 1,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [
+ {
+ id: 60,
+ name: 'kernel',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel',
+ relative_path: '/platform/hardware/bsp/kernel',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave',
+ parent_id: 57,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ children: [
+ {
+ id: 61,
+ name: 'common',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common',
+ relative_path: '/platform/hardware/bsp/kernel/common',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/common/edit',
+ children_count: 2,
+ leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave',
+ parent_id: 60,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ children: [
+ {
+ id: 17,
+ name: 'v4.4',
+ description: 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.4',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.4',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit',
+ star_count: 0,
+ },
+ {
+ id: 16,
+ name: 'v4.1',
+ description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.1',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.1',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit',
+ star_count: 0,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const mockRawPageInfo = {
+ 'x-per-page': 10,
+ 'x-page': 10,
+ 'x-total': 10,
+ 'x-total-pages': 10,
+ 'x-next-page': 10,
+ 'x-prev-page': 10,
};
-export { groupsData, group1 };
+export const mockPageInfo = {
+ perPage: 10,
+ page: 10,
+ total: 10,
+ totalPages: 10,
+ nextPage: 10,
+ prevPage: 10,
+};
diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js
new file mode 100644
index 00000000000..20bb63687f7
--- /dev/null
+++ b/spec/javascripts/groups/service/groups_service_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import GroupsService from '~/groups/service/groups_service';
+import { mockEndpoint, mockParentGroupItem } from '../mock_data';
+
+Vue.use(VueResource);
+
+describe('GroupsService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new GroupsService(mockEndpoint);
+ });
+
+ describe('getGroups', () => {
+ it('should return promise for `GET` request on provided endpoint', () => {
+ spyOn(service.groups, 'get').and.stub();
+ const queryParams = {
+ page: 2,
+ filter: 'git',
+ sort: 'created_asc',
+ archived: true,
+ };
+
+ service.getGroups(55, 2, 'git', 'created_asc', true);
+ expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 });
+
+ service.getGroups(null, 2, 'git', 'created_asc', true);
+ expect(service.groups.get).toHaveBeenCalledWith(queryParams);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should return promise for `DELETE` request on provided endpoint', () => {
+ spyOn(Vue.http, 'delete').and.stub();
+
+ service.leaveGroup(mockParentGroupItem.leavePath);
+ expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
+ });
+ });
+});
diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js
new file mode 100644
index 00000000000..d74f38f476e
--- /dev/null
+++ b/spec/javascripts/groups/store/groups_store_spec.js
@@ -0,0 +1,110 @@
+import GroupsStore from '~/groups/store/groups_store';
+import {
+ mockGroups, mockSearchedGroups,
+ mockParentGroupItem, mockRawChildren,
+ mockRawPageInfo,
+} from '../mock_data';
+
+describe('ProjectsStore', () => {
+ describe('constructor', () => {
+ it('should initialize default state', () => {
+ let store;
+
+ store = new GroupsStore();
+ expect(Object.keys(store.state).length).toBe(2);
+ expect(Array.isArray(store.state.groups)).toBeTruthy();
+ expect(Object.keys(store.state.pageInfo).length).toBe(0);
+ expect(store.hideProjects).not.toBeDefined();
+
+ store = new GroupsStore(true);
+ expect(store.hideProjects).toBeTruthy();
+ });
+ });
+
+ describe('setGroups', () => {
+ it('should set groups to state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setGroups(mockGroups);
+ expect(store.state.groups.length).toBe(mockGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+ });
+ });
+
+ describe('setSearchedGroups', () => {
+ it('should set searched groups to state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setSearchedGroups(mockSearchedGroups);
+ expect(store.state.groups.length).toBe(mockSearchedGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+ expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy();
+ });
+ });
+
+ describe('setGroupChildren', () => {
+ it('should set children to group item in state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setGroupChildren(mockParentGroupItem, mockRawChildren);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(mockParentGroupItem.children.length).toBe(1);
+ expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy();
+ expect(mockParentGroupItem.isOpen).toBeTruthy();
+ expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
+ });
+ });
+
+ describe('setPaginationInfo', () => {
+ it('should parse and set pagination info in state', () => {
+ const store = new GroupsStore();
+
+ store.setPaginationInfo(mockRawPageInfo);
+ expect(store.state.pageInfo.perPage).toBe(10);
+ expect(store.state.pageInfo.page).toBe(10);
+ expect(store.state.pageInfo.total).toBe(10);
+ expect(store.state.pageInfo.totalPages).toBe(10);
+ expect(store.state.pageInfo.nextPage).toBe(10);
+ expect(store.state.pageInfo.previousPage).toBe(10);
+ });
+ });
+
+ describe('formatGroupItem', () => {
+ it('should parse group item object and return updated object', () => {
+ let store;
+ let updatedGroupItem;
+
+ store = new GroupsStore();
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+ expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
+ expect(updatedGroupItem.isChildrenLoading).toBe(false);
+ expect(updatedGroupItem.isBeingRemoved).toBe(false);
+
+ store = new GroupsStore(true);
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+ expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
+ });
+ });
+
+ describe('removeGroup', () => {
+ it('should remove children from group item in state', () => {
+ const store = new GroupsStore();
+ const rawParentGroup = Object.assign({}, mockGroups[0]);
+ const rawChildGroup = Object.assign({}, mockGroups[1]);
+
+ store.setGroups([rawParentGroup]);
+ store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
+ const childItem = store.state.groups[0].children[0];
+
+ store.removeGroup(childItem, store.state.groups[0]);
+ expect(store.state.groups[0].children.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/helpers/set_timeout_promise_helper.js b/spec/javascripts/helpers/set_timeout_promise_helper.js
new file mode 100644
index 00000000000..1478073413c
--- /dev/null
+++ b/spec/javascripts/helpers/set_timeout_promise_helper.js
@@ -0,0 +1,3 @@
+export default (time = 0) => new Promise((resolve) => {
+ setTimeout(resolve, time);
+});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 583a3a74d77..2ea290108a4 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -332,4 +332,15 @@ describe('Issuable output', () => {
.catch(done.fail);
});
});
+
+ describe('show inline edit button', () => {
+ it('should not render by default', () => {
+ expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+ });
+
+ it('should render if showInlineEditButton', () => {
+ vm.showInlineEditButton = true;
+ expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+ });
+ });
});
diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js
index a2d90a9b9f5..c1edc785d0f 100644
--- a/spec/javascripts/issue_show/components/title_spec.js
+++ b/spec/javascripts/issue_show/components/title_spec.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Store from '~/issue_show/stores';
import titleComponent from '~/issue_show/components/title.vue';
+import eventHub from '~/issue_show/event_hub';
describe('Title component', () => {
let vm;
@@ -25,7 +26,7 @@ describe('Title component', () => {
it('renders title HTML', () => {
expect(
- vm.$el.innerHTML.trim(),
+ vm.$el.querySelector('.title').innerHTML.trim(),
).toBe('Testing <img>');
});
@@ -47,12 +48,12 @@ describe('Title component', () => {
Vue.nextTick(() => {
expect(
- vm.$el.classList.contains('issue-realtime-pre-pulse'),
+ vm.$el.querySelector('.title').classList.contains('issue-realtime-pre-pulse'),
).toBeTruthy();
setTimeout(() => {
expect(
- vm.$el.classList.contains('issue-realtime-trigger-pulse'),
+ vm.$el.querySelector('.title').classList.contains('issue-realtime-trigger-pulse'),
).toBeTruthy();
done();
@@ -72,4 +73,36 @@ describe('Title component', () => {
done();
});
});
+
+ describe('inline edit button', () => {
+ beforeEach(() => {
+ spyOn(eventHub, '$emit');
+ });
+
+ it('should not show by default', () => {
+ expect(vm.$el.querySelector('.note-action-button')).toBeNull();
+ });
+
+ it('should not show if canUpdate is false', () => {
+ vm.showInlineEditButton = true;
+ vm.canUpdate = false;
+ expect(vm.$el.querySelector('.note-action-button')).toBeNull();
+ });
+
+ it('should show if showInlineEditButton and canUpdate', () => {
+ vm.showInlineEditButton = true;
+ vm.canUpdate = true;
+ expect(vm.$el.querySelector('.note-action-button')).toBeDefined();
+ });
+
+ it('should trigger open.form event when clicked', () => {
+ vm.showInlineEditButton = true;
+ vm.canUpdate = true;
+
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.note-action-button').click();
+ expect(eventHub.$emit).toHaveBeenCalledWith('open.form');
+ });
+ });
+ });
});
diff --git a/spec/javascripts/jobs/header_spec.js b/spec/javascripts/jobs/header_spec.js
index c7179b3e03d..4a210faa017 100644
--- a/spec/javascripts/jobs/header_spec.js
+++ b/spec/javascripts/jobs/header_spec.js
@@ -30,7 +30,6 @@ describe('Job details header', () => {
email: 'foo@bar.com',
avatar_url: 'link',
},
- retry_path: 'path',
new_issue_path: 'path',
},
isLoading: false,
@@ -49,12 +48,6 @@ describe('Job details header', () => {
).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
});
- it('should render retry link', () => {
- expect(
- vm.$el.querySelector('.js-retry-button').getAttribute('href'),
- ).toEqual(props.job.retry_path);
- });
-
it('should render new issue link', () => {
expect(
vm.$el.querySelector('.js-new-issue').getAttribute('href'),
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index f86f2f260c3..a5298be5669 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -467,15 +467,27 @@ describe('common_utils', () => {
commonUtils.ajaxPost(requestURL, data);
expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST');
});
+ });
- describe('gl.utils.spriteIcon', () => {
- beforeEach(() => {
- window.gon.sprite_icons = 'icons.svg';
- });
+ describe('spriteIcon', () => {
+ let beforeGon;
- it('should return the svg for a linked icon', () => {
- expect(gl.utils.spriteIcon('test')).toEqual('<svg><use xlink:href="icons.svg#test" /></svg>');
- });
+ beforeEach(() => {
+ window.gon = window.gon || {};
+ beforeGon = Object.assign({}, window.gon);
+ window.gon.sprite_icons = 'icons.svg';
+ });
+
+ afterEach(() => {
+ window.gon = beforeGon;
+ });
+
+ it('should return the svg for a linked icon', () => {
+ expect(commonUtils.spriteIcon('test')).toEqual('<svg ><use xlink:href="icons.svg#test" /></svg>');
+ });
+
+ it('should set svg className when passed', () => {
+ expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual('<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>');
});
});
});
diff --git a/spec/javascripts/lib/utils/datefix_spec.js b/spec/javascripts/lib/utils/datefix_spec.js
new file mode 100644
index 00000000000..0b9fde2be67
--- /dev/null
+++ b/spec/javascripts/lib/utils/datefix_spec.js
@@ -0,0 +1,29 @@
+import { pad, parsePikadayDate, pikadayToString } from '~/lib/utils/datefix';
+
+describe('datefix', () => {
+ describe('pad', () => {
+ it('should add a 0 when length is smaller than 2', () => {
+ expect(pad(2)).toEqual('02');
+ });
+
+ it('should not add a zero when lenght matches the default', () => {
+ expect(pad(12)).toEqual('12');
+ });
+
+ it('should add a 0 when lenght is smaller than the provided', () => {
+ expect(pad(12, 3)).toEqual('012');
+ });
+ });
+
+ describe('parsePikadayDate', () => {
+ it('should return a UTC date', () => {
+ expect(parsePikadayDate('2020-01-29')).toEqual(new Date('2020-01-29'));
+ });
+ });
+
+ describe('pikadayToString', () => {
+ it('should format a UTC date into yyyy-mm-dd format', () => {
+ expect(pikadayToString(new Date('2020-01-29'))).toEqual('2020-01-29');
+ });
+ });
+});
diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js
index 395dc560671..ac6ace48108 100644
--- a/spec/javascripts/merge_request_notes_spec.js
+++ b/spec/javascripts/merge_request_notes_spec.js
@@ -23,12 +23,17 @@ describe('Merge request notes', () => {
loadFixtures(discussionTabFixture);
gl.utils.disableButtonIfEmptyField = _.noop;
window.project_uploads_path = 'http://test.host/uploads';
- $('body').data('page', 'projects:merge_requests:show');
+ $('body').attr('data-page', 'projects:merge_requests:show');
window.gon.current_user_id = $('.note:last').data('author-id');
return new Notes('', []);
});
+ afterEach(() => {
+ // Undo what we did to the shared <body>
+ $('body').removeAttr('data-page');
+ });
+
describe('up arrow', () => {
it('edits last comment when triggered in main form', () => {
const upArrowEvent = $.Event('keydown');
@@ -71,12 +76,17 @@ describe('Merge request notes', () => {
<textarea class="js-note-text"></textarea>
</form>`;
setFixtures(diffsResponse.html + noteFormHtml);
- $('body').data('page', 'projects:merge_requests:show');
+ $('body').attr('data-page', 'projects:merge_requests:show');
window.gon.current_user_id = $('.note:last').data('author-id');
return new Notes('', []);
});
+ afterEach(() => {
+ // Undo what we did to the shared <body>
+ $('body').removeAttr('data-page');
+ });
+
describe('up arrow', () => {
it('edits last comment in discussion when triggered in discussion form', (done) => {
const upArrowEvent = $.Event('keydown');
diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js
index ccdbfcba692..18916c5aa97 100644
--- a/spec/javascripts/merge_request_tabs_spec.js
+++ b/spec/javascripts/merge_request_tabs_spec.js
@@ -277,7 +277,7 @@ import 'vendor/jquery.scrollTo';
describe('loadDiff', function () {
beforeEach(() => {
loadFixtures('merge_requests/diff_comment.html.raw');
- spyOn(window.gl.utils, 'getPagePath').and.returnValue('merge_requests');
+ $('body').attr('data-page', 'projects:merge_requests:show');
window.gl.ImageFile = () => {};
window.notes = new Notes('', []);
spyOn(window.notes, 'toggleDiffNote').and.callThrough();
@@ -286,6 +286,9 @@ import 'vendor/jquery.scrollTo';
afterEach(() => {
delete window.gl.ImageFile;
delete window.notes;
+
+ // Undo what we did to the shared <body>
+ $('body').removeAttr('data-page');
});
it('requires an absolute pathname', function () {
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 65d2e8fd9fb..66c52611614 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -39,7 +39,12 @@ import '~/notes';
loadFixtures(commentsTemplate);
gl.utils.disableButtonIfEmptyField = _.noop;
window.project_uploads_path = 'http://test.host/uploads';
- $('body').data('page', 'projects:merge_requets:show');
+ $('body').attr('data-page', 'projects:merge_requets:show');
+ });
+
+ afterEach(() => {
+ // Undo what we did to the shared <body>
+ $('body').removeAttr('data-page');
});
describe('task lists', function() {
@@ -426,19 +431,17 @@ import '~/notes';
});
describe('putEditFormInPlace', () => {
- it('should call gl.GLForm with GFM parameter passed through', () => {
- spyOn(gl, 'GLForm');
-
- const $el = jasmine.createSpyObj('$form', ['find', 'closest']);
- $el.find.and.returnValue($('<div>'));
- $el.closest.and.returnValue($('<div>'));
+ it('should call GLForm with GFM parameter passed through', () => {
+ const notes = new Notes('', []);
+ const $el = $(`
+ <div>
+ <form></form>
+ </div>
+ `);
- Notes.prototype.putEditFormInPlace.call({
- getEditFormSelector: () => '',
- enableGFM: true
- }, $el);
+ notes.putEditFormInPlace($el);
- expect(gl.GLForm).toHaveBeenCalledWith(jasmine.any(Object), true);
+ expect(notes.glForm.enableGFM).toBeTruthy();
});
});
diff --git a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
index 2b3a821dbd9..b24567ffc0c 100644
--- a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
+++ b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
@@ -109,12 +109,16 @@ describe('PrometheusMetrics', () => {
it('should show loader animation while response is being loaded and hide it when request is complete', (done) => {
const deferred = $.Deferred();
- spyOn($, 'getJSON').and.returnValue(deferred.promise());
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
prometheusMetrics.loadActiveMetrics();
expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
- expect($.getJSON).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint);
+ expect($.ajax).toHaveBeenCalledWith({
+ url: prometheusMetrics.activeMetricsEndpoint,
+ dataType: 'json',
+ global: false,
+ });
deferred.resolve({ data: metrics, success: true });
@@ -126,7 +130,7 @@ describe('PrometheusMetrics', () => {
it('should show empty state if response failed to load', (done) => {
const deferred = $.Deferred();
- spyOn($, 'getJSON').and.returnValue(deferred.promise());
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
spyOn(prometheusMetrics, 'populateActiveMetrics');
prometheusMetrics.loadActiveMetrics();
@@ -142,7 +146,7 @@ describe('PrometheusMetrics', () => {
it('should populate metrics list once response is loaded', (done) => {
const deferred = $.Deferred();
- spyOn($, 'getJSON').and.returnValue(deferred.promise());
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
spyOn(prometheusMetrics, 'populateActiveMetrics');
prometheusMetrics.loadActiveMetrics();
diff --git a/spec/javascripts/registry/mock_data.js b/spec/javascripts/registry/mock_data.js
index 18600d00bff..6bffb47be55 100644
--- a/spec/javascripts/registry/mock_data.js
+++ b/spec/javascripts/registry/mock_data.js
@@ -26,7 +26,7 @@ export const registryServerResponse = [
name: 'centos7',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
- size: 679,
+ total_size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
@@ -36,7 +36,7 @@ export const registryServerResponse = [
name: 'centos6',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
- size: 679,
+ total_size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
@@ -70,7 +70,7 @@ export const parsedRegistryServerResponse = [
tag: registryServerResponse[0].name,
revision: registryServerResponse[0].revision,
shortRevision: registryServerResponse[0].short_revision,
- size: registryServerResponse[0].size,
+ size: registryServerResponse[0].total_size,
layers: registryServerResponse[0].layers,
location: registryServerResponse[0].location,
createdAt: registryServerResponse[0].created_at,
@@ -81,7 +81,7 @@ export const parsedRegistryServerResponse = [
tag: registryServerResponse[1].name,
revision: registryServerResponse[1].revision,
shortRevision: registryServerResponse[1].short_revision,
- size: registryServerResponse[1].size,
+ size: registryServerResponse[1].total_size,
layers: registryServerResponse[1].layers,
location: registryServerResponse[1].location,
createdAt: registryServerResponse[1].created_at,
diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js
index e604dcc152d..e09d593f04c 100644
--- a/spec/javascripts/repo/components/repo_commit_section_spec.js
+++ b/spec/javascripts/repo/components/repo_commit_section_spec.js
@@ -2,29 +2,13 @@ import Vue from 'vue';
import repoCommitSection from '~/repo/components/repo_commit_section.vue';
import RepoStore from '~/repo/stores/repo_store';
import RepoService from '~/repo/services/repo_service';
+import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper';
describe('RepoCommitSection', () => {
const branch = 'master';
const projectUrl = 'projectUrl';
- const changedFiles = [{
- id: 0,
- changed: true,
- url: `/namespace/${projectUrl}/blob/${branch}/dir/file0.ext`,
- path: 'dir/file0.ext',
- newContent: 'a',
- }, {
- id: 1,
- changed: true,
- url: `/namespace/${projectUrl}/blob/${branch}/dir/file1.ext`,
- path: 'dir/file1.ext',
- newContent: 'b',
- }];
- const openedFiles = changedFiles.concat([{
- id: 2,
- url: `/namespace/${projectUrl}/blob/${branch}/dir/file2.ext`,
- path: 'dir/file2.ext',
- changed: false,
- }]);
+ let changedFiles;
+ let openedFiles;
RepoStore.projectUrl = projectUrl;
@@ -34,6 +18,29 @@ describe('RepoCommitSection', () => {
return new RepoCommitSection().$mount(el);
}
+ beforeEach(() => {
+ // Create a copy for each test because these can get modified directly
+ changedFiles = [{
+ id: 0,
+ changed: true,
+ url: `/namespace/${projectUrl}/blob/${branch}/dir/file0.ext`,
+ path: 'dir/file0.ext',
+ newContent: 'a',
+ }, {
+ id: 1,
+ changed: true,
+ url: `/namespace/${projectUrl}/blob/${branch}/dir/file1.ext`,
+ path: 'dir/file1.ext',
+ newContent: 'b',
+ }];
+ openedFiles = changedFiles.concat([{
+ id: 2,
+ url: `/namespace/${projectUrl}/blob/${branch}/dir/file2.ext`,
+ path: 'dir/file2.ext',
+ changed: false,
+ }]);
+ });
+
it('renders a commit section', () => {
RepoStore.isCommitable = true;
RepoStore.currentBranch = branch;
@@ -85,55 +92,105 @@ describe('RepoCommitSection', () => {
expect(vm.$el.innerHTML).toBeFalsy();
});
- it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => {
+ describe('when submitting', () => {
+ let el;
+ let vm;
const projectId = 'projectId';
const commitMessage = 'commitMessage';
- RepoStore.isCommitable = true;
- RepoStore.currentBranch = branch;
- RepoStore.targetBranch = branch;
- RepoStore.openedFiles = openedFiles;
- RepoStore.projectId = projectId;
- // We need to append to body to get form `submit` events working
- // Otherwise we run into, "Form submission canceled because the form is not connected"
- // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
- const el = document.createElement('div');
- document.body.appendChild(el);
-
- const vm = createComponent(el);
- const commitMessageEl = vm.$el.querySelector('#commit-message');
- const submitCommit = vm.$refs.submitCommit;
+ beforeEach((done) => {
+ RepoStore.isCommitable = true;
+ RepoStore.currentBranch = branch;
+ RepoStore.targetBranch = branch;
+ RepoStore.openedFiles = openedFiles;
+ RepoStore.projectId = projectId;
+
+ // We need to append to body to get form `submit` events working
+ // Otherwise we run into, "Form submission canceled because the form is not connected"
+ // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
+ el = document.createElement('div');
+ document.body.appendChild(el);
+
+ vm = createComponent(el);
+ vm.commitMessage = commitMessage;
+
+ spyOn(vm, 'tryCommit').and.callThrough();
+ spyOn(vm, 'redirectToNewMr').and.stub();
+ spyOn(vm, 'redirectToBranch').and.stub();
+ spyOn(RepoService, 'commitFiles').and.returnValue(Promise.resolve());
+ spyOn(RepoService, 'getBranch').and.returnValue(Promise.resolve({
+ commit: {
+ id: 1,
+ short_id: 1,
+ },
+ }));
+
+ // Wait for the vm data to be in place
+ Vue.nextTick(() => {
+ done();
+ });
+ });
- vm.commitMessage = commitMessage;
+ afterEach(() => {
+ vm.$destroy();
+ el.remove();
+ RepoStore.openedFiles = [];
+ });
- Vue.nextTick(() => {
+ it('shows commit message', () => {
+ const commitMessageEl = vm.$el.querySelector('#commit-message');
expect(commitMessageEl.value).toBe(commitMessage);
- expect(submitCommit.disabled).toBeFalsy();
+ });
- spyOn(vm, 'makeCommit').and.callThrough();
- spyOn(RepoService, 'commitFiles').and.callFake(() => Promise.resolve());
+ it('allows you to submit', () => {
+ const submitCommit = vm.$refs.submitCommit;
+ expect(submitCommit.disabled).toBeFalsy();
+ });
+ it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => {
+ const submitCommit = vm.$refs.submitCommit;
submitCommit.click();
- Vue.nextTick(() => {
- expect(vm.makeCommit).toHaveBeenCalled();
- expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeTruthy();
-
- const args = RepoService.commitFiles.calls.allArgs()[0];
- const { commit_message, actions, branch: payloadBranch } = args[0];
-
- expect(commit_message).toBe(commitMessage);
- expect(actions.length).toEqual(2);
- expect(payloadBranch).toEqual(branch);
- expect(actions[0].action).toEqual('update');
- expect(actions[1].action).toEqual('update');
- expect(actions[0].content).toEqual(openedFiles[0].newContent);
- expect(actions[1].content).toEqual(openedFiles[1].newContent);
- expect(actions[0].file_path).toEqual(openedFiles[0].path);
- expect(actions[1].file_path).toEqual(openedFiles[1].path);
+ // Wait for the branch check to finish
+ getSetTimeoutPromise()
+ .then(() => Vue.nextTick())
+ .then(() => {
+ expect(vm.tryCommit).toHaveBeenCalled();
+ expect(submitCommit.querySelector('.js-commit-loading-icon')).toBeTruthy();
+ expect(vm.redirectToBranch).toHaveBeenCalled();
+
+ const args = RepoService.commitFiles.calls.allArgs()[0];
+ const { commit_message, actions, branch: payloadBranch } = args[0];
+
+ expect(commit_message).toBe(commitMessage);
+ expect(actions.length).toEqual(2);
+ expect(payloadBranch).toEqual(branch);
+ expect(actions[0].action).toEqual('update');
+ expect(actions[1].action).toEqual('update');
+ expect(actions[0].content).toEqual(openedFiles[0].newContent);
+ expect(actions[1].content).toEqual(openedFiles[1].newContent);
+ expect(actions[0].file_path).toEqual(openedFiles[0].path);
+ expect(actions[1].file_path).toEqual(openedFiles[1].path);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
- done();
- });
+ it('redirects to MR creation page if start new MR checkbox checked', (done) => {
+ vm.startNewMR = true;
+
+ Vue.nextTick()
+ .then(() => {
+ const submitCommit = vm.$refs.submitCommit;
+ submitCommit.click();
+ })
+ // Wait for the branch check to finish
+ .then(() => getSetTimeoutPromise())
+ .then(() => {
+ expect(vm.redirectToNewMr).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
});
});
@@ -143,6 +200,7 @@ describe('RepoCommitSection', () => {
const vm = {
submitCommitsLoading: true,
changedFiles: new Array(10),
+ openedFiles: new Array(3),
commitMessage: 'commitMessage',
editMode: true,
};
diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js
index 411514009dc..dff2fac191d 100644
--- a/spec/javascripts/repo/components/repo_edit_button_spec.js
+++ b/spec/javascripts/repo/components/repo_edit_button_spec.js
@@ -9,6 +9,10 @@ describe('RepoEditButton', () => {
return new RepoEditButton().$mount();
}
+ afterEach(() => {
+ RepoStore.openedFiles = [];
+ });
+
it('renders an edit button that toggles the view state', (done) => {
RepoStore.isCommitable = true;
RepoStore.changedFiles = [];
@@ -38,12 +42,4 @@ describe('RepoEditButton', () => {
expect(vm.$el.innerHTML).toBeUndefined();
});
-
- describe('methods', () => {
- describe('editCancelClicked', () => {
- it('sets dialog to open when there are changedFiles');
-
- it('toggles editMode and calls toggleBlobView');
- });
- });
});
diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js
index 85d55d171f9..a25a600b3be 100644
--- a/spec/javascripts/repo/components/repo_editor_spec.js
+++ b/spec/javascripts/repo/components/repo_editor_spec.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import RepoStore from '~/repo/stores/repo_store';
import repoEditor from '~/repo/components/repo_editor.vue';
describe('RepoEditor', () => {
@@ -8,6 +9,10 @@ describe('RepoEditor', () => {
this.vm = new RepoEditor().$mount();
});
+ afterEach(() => {
+ RepoStore.openedFiles = [];
+ });
+
it('renders an ide container', (done) => {
this.vm.openedFiles = ['idiidid'];
this.vm.binary = false;
diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js
index dfab51710c3..701c260224f 100644
--- a/spec/javascripts/repo/components/repo_file_buttons_spec.js
+++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js
@@ -9,6 +9,10 @@ describe('RepoFileButtons', () => {
return new RepoFileButtons().$mount();
}
+ afterEach(() => {
+ RepoStore.openedFiles = [];
+ });
+
it('renders Raw, Blame, History, Permalink and Preview toggle', () => {
const activeFile = {
extension: 'md',
diff --git a/spec/javascripts/repo/components/repo_file_options_spec.js b/spec/javascripts/repo/components/repo_file_options_spec.js
deleted file mode 100644
index 9759b4bf12d..00000000000
--- a/spec/javascripts/repo/components/repo_file_options_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import Vue from 'vue';
-import repoFileOptions from '~/repo/components/repo_file_options.vue';
-
-describe('RepoFileOptions', () => {
- const projectName = 'projectName';
-
- function createComponent(propsData) {
- const RepoFileOptions = Vue.extend(repoFileOptions);
-
- return new RepoFileOptions({
- propsData,
- }).$mount();
- }
-
- it('renders the title and new file/folder buttons if isMini is true', () => {
- const vm = createComponent({
- isMini: true,
- projectName,
- });
-
- expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy();
- expect(vm.$el.querySelector('.title').textContent).toEqual(projectName);
- });
-
- it('does not render if isMini is false', () => {
- const vm = createComponent({
- isMini: false,
- projectName,
- });
-
- expect(vm.$el.innerHTML).toBeFalsy();
- });
-});
diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js
index 620b604f404..334bf0997ca 100644
--- a/spec/javascripts/repo/components/repo_file_spec.js
+++ b/spec/javascripts/repo/components/repo_file_spec.js
@@ -1,21 +1,11 @@
import Vue from 'vue';
import repoFile from '~/repo/components/repo_file.vue';
import RepoStore from '~/repo/stores/repo_store';
+import eventHub from '~/repo/event_hub';
+import { file } from '../mock_data';
describe('RepoFile', () => {
const updated = 'updated';
- const file = {
- icon: 'icon',
- url: 'url',
- name: 'name',
- lastCommitMessage: 'message',
- lastCommitUpdate: Date.now(),
- level: 10,
- };
- const activeFile = {
- pageTitle: 'pageTitle',
- url: 'url',
- };
const otherFile = {
html: '<p class="file-content">html</p>',
pageTitle: 'otherpageTitle',
@@ -29,12 +19,15 @@ describe('RepoFile', () => {
}).$mount();
}
+ beforeEach(() => {
+ RepoStore.openedFiles = [];
+ });
+
it('renders link, icon, name and last commit details', () => {
const RepoFile = Vue.extend(repoFile);
const vm = new RepoFile({
propsData: {
- file,
- activeFile,
+ file: file(),
},
});
spyOn(vm, 'timeFormated').and.returnValue(updated);
@@ -43,28 +36,20 @@ describe('RepoFile', () => {
const name = vm.$el.querySelector('.repo-file-name');
const fileIcon = vm.$el.querySelector('.file-icon');
- expect(vm.$el.classList.contains('active')).toBeTruthy();
- expect(vm.$el.querySelector(`.${file.icon}`).style.marginLeft).toEqual('100px');
- expect(name.title).toEqual(file.url);
- expect(name.href).toMatch(`/${file.url}`);
- expect(name.textContent.trim()).toEqual(file.name);
- expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(file.lastCommitMessage);
+ expect(vm.$el.querySelector(`.${vm.file.icon}`).style.marginLeft).toEqual('0px');
+ expect(name.href).toMatch(`/${vm.file.url}`);
+ expect(name.textContent.trim()).toEqual(vm.file.name);
+ expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(vm.file.lastCommit.message);
expect(vm.$el.querySelector('.commit-update').textContent.trim()).toBe(updated);
- expect(fileIcon.classList.contains(file.icon)).toBeTruthy();
- expect(fileIcon.style.marginLeft).toEqual(`${file.level * 10}px`);
+ expect(fileIcon.classList.contains(vm.file.icon)).toBeTruthy();
+ expect(fileIcon.style.marginLeft).toEqual(`${vm.file.level * 10}px`);
});
it('does render if hasFiles is true and is loading tree', () => {
const vm = createComponent({
- file,
- activeFile,
- loading: {
- tree: true,
- },
- hasFiles: true,
+ file: file(),
});
- expect(vm.$el.innerHTML).toBeTruthy();
expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy();
});
@@ -75,75 +60,51 @@ describe('RepoFile', () => {
});
it('renders a spinner if the file is loading', () => {
- file.loading = true;
- const vm = createComponent({
- file,
- activeFile,
- loading: {
- tree: true,
- },
- hasFiles: true,
- });
-
- expect(vm.$el.innerHTML).toBeTruthy();
- expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${file.level * 10}px`);
- });
-
- it('does not render if loading tree', () => {
+ const f = file();
+ f.loading = true;
const vm = createComponent({
- file,
- activeFile,
- loading: {
- tree: true,
- },
+ file: f,
});
- expect(vm.$el.innerHTML).toBeFalsy();
+ expect(vm.$el.querySelector('.fa-spin.fa-spinner')).not.toBeNull();
+ expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`);
});
it('does not render commit message and datetime if mini', () => {
+ RepoStore.openedFiles.push(file());
+
const vm = createComponent({
- file,
- activeFile,
- isMini: true,
+ file: file(),
});
expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
});
- it('does not set active class if file is active file', () => {
- const vm = createComponent({
- file,
- activeFile: {},
- });
-
- expect(vm.$el.classList.contains('active')).toBeFalsy();
- });
-
it('fires linkClicked when the link is clicked', () => {
const vm = createComponent({
- file,
- activeFile,
+ file: file(),
});
spyOn(vm, 'linkClicked');
- vm.$el.querySelector('.repo-file-name').click();
+ vm.$el.click();
- expect(vm.linkClicked).toHaveBeenCalledWith(file);
+ expect(vm.linkClicked).toHaveBeenCalledWith(vm.file);
});
describe('methods', () => {
describe('linkClicked', () => {
- const vm = jasmine.createSpyObj('vm', ['$emit']);
+ it('$emits fileNameClicked with file obj', () => {
+ spyOn(eventHub, '$emit');
- it('$emits linkclicked with file obj', () => {
- const theFile = {};
+ const vm = createComponent({
+ file: file(),
+ });
- repoFile.methods.linkClicked.call(vm, theFile);
+ vm.linkClicked(vm.file);
- expect(vm.$emit).toHaveBeenCalledWith('linkclicked', theFile);
+ expect(eventHub.$emit).toHaveBeenCalledWith('fileNameClicked', vm.file);
});
});
});
diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js
index a030314d749..e9f95a02028 100644
--- a/spec/javascripts/repo/components/repo_loading_file_spec.js
+++ b/spec/javascripts/repo/components/repo_loading_file_spec.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import RepoStore from '~/repo/stores/repo_store';
import repoLoadingFile from '~/repo/components/repo_loading_file.vue';
describe('RepoLoadingFile', () => {
@@ -28,6 +29,10 @@ describe('RepoLoadingFile', () => {
});
}
+ afterEach(() => {
+ RepoStore.openedFiles = [];
+ });
+
it('renders 3 columns of animated LoC', () => {
const vm = createComponent({
loading: {
@@ -42,38 +47,16 @@ describe('RepoLoadingFile', () => {
});
it('renders 1 column of animated LoC if isMini', () => {
+ RepoStore.openedFiles = new Array(1);
const vm = createComponent({
loading: {
tree: true,
},
hasFiles: false,
- isMini: true,
});
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1);
assertColumns(columns);
});
-
- it('does not render if tree is not loading', () => {
- const vm = createComponent({
- loading: {
- tree: false,
- },
- hasFiles: false,
- });
-
- expect(vm.$el.innerHTML).toBeFalsy();
- });
-
- it('does not render if hasFiles is true', () => {
- const vm = createComponent({
- loading: {
- tree: true,
- },
- hasFiles: true,
- });
-
- expect(vm.$el.innerHTML).toBeFalsy();
- });
});
diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js
index 34dde545e6a..4c064f21084 100644
--- a/spec/javascripts/repo/components/repo_prev_directory_spec.js
+++ b/spec/javascripts/repo/components/repo_prev_directory_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue';
+import eventHub from '~/repo/event_hub';
describe('RepoPrevDirectory', () => {
function createComponent(propsData) {
@@ -20,7 +21,7 @@ describe('RepoPrevDirectory', () => {
spyOn(vm, 'linkClicked');
expect(link.href).toMatch(`/${prevUrl}`);
- expect(link.textContent).toEqual('..');
+ expect(link.textContent).toEqual('...');
link.click();
@@ -29,14 +30,17 @@ describe('RepoPrevDirectory', () => {
describe('methods', () => {
describe('linkClicked', () => {
- const vm = jasmine.createSpyObj('vm', ['$emit']);
+ it('$emits linkclicked with prevUrl', () => {
+ const prevUrl = 'prevUrl';
+ const vm = createComponent({
+ prevUrl,
+ });
- it('$emits linkclicked with file obj', () => {
- const file = {};
+ spyOn(eventHub, '$emit');
- repoPrevDirectory.methods.linkClicked.call(vm, file);
+ vm.linkClicked(prevUrl);
- expect(vm.$emit).toHaveBeenCalledWith('linkclicked', file);
+ expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl);
});
});
});
diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js
index 35d2b37ac2a..61283da8257 100644
--- a/spec/javascripts/repo/components/repo_sidebar_spec.js
+++ b/spec/javascripts/repo/components/repo_sidebar_spec.js
@@ -3,6 +3,7 @@ import Helper from '~/repo/helpers/repo_helper';
import RepoService from '~/repo/services/repo_service';
import RepoStore from '~/repo/stores/repo_store';
import repoSidebar from '~/repo/components/repo_sidebar.vue';
+import { file } from '../mock_data';
describe('RepoSidebar', () => {
let vm;
@@ -15,14 +16,15 @@ describe('RepoSidebar', () => {
afterEach(() => {
vm.$destroy();
+
+ RepoStore.files = [];
+ RepoStore.openedFiles = [];
});
it('renders a sidebar', () => {
- RepoStore.files = [{
- id: 0,
- }];
+ RepoStore.files = [file()];
RepoStore.openedFiles = [];
- RepoStore.isRoot = false;
+ RepoStore.isRoot = true;
vm = createComponent();
const thead = vm.$el.querySelector('thead');
@@ -30,9 +32,9 @@ describe('RepoSidebar', () => {
expect(vm.$el.id).toEqual('sidebar');
expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy();
- expect(thead.querySelector('.name').textContent).toEqual('Name');
- expect(thead.querySelector('.last-commit').textContent).toEqual('Last commit');
- expect(thead.querySelector('.last-update').textContent).toEqual('Last update');
+ expect(thead.querySelector('.name').textContent.trim()).toEqual('Name');
+ expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit');
+ expect(thead.querySelector('.last-update').textContent.trim()).toEqual('Last update');
expect(tbody.querySelector('.repo-file-options')).toBeFalsy();
expect(tbody.querySelector('.prev-directory')).toBeFalsy();
expect(tbody.querySelector('.loading-file')).toBeFalsy();
@@ -46,76 +48,74 @@ describe('RepoSidebar', () => {
vm = createComponent();
expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
- expect(vm.$el.querySelector('thead')).toBeFalsy();
- expect(vm.$el.querySelector('tbody .repo-file-options')).toBeTruthy();
+ expect(vm.$el.querySelector('thead')).toBeTruthy();
+ expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy();
});
it('renders 5 loading files if tree is loading and not hasFiles', () => {
- RepoStore.loading = {
- tree: true,
- };
+ RepoStore.loading.tree = true;
RepoStore.files = [];
vm = createComponent();
expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5);
});
- it('renders a prev directory if isRoot', () => {
- RepoStore.files = [{
- id: 0,
- }];
- RepoStore.isRoot = true;
+ it('renders a prev directory if is not root', () => {
+ RepoStore.files = [file()];
+ RepoStore.isRoot = false;
+ RepoStore.loading.tree = false;
vm = createComponent();
expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
});
+ describe('flattendFiles', () => {
+ it('returns a flattend array of files', () => {
+ const f = file();
+ f.files.push(file('testing 123'));
+ const files = [f, file()];
+ vm = createComponent();
+ vm.files = files;
+
+ expect(vm.flattendFiles.length).toBe(3);
+ expect(vm.flattendFiles[1].name).toBe('testing 123');
+ });
+ });
+
describe('methods', () => {
describe('fileClicked', () => {
it('should fetch data for new file', () => {
spyOn(Helper, 'getContent').and.callThrough();
- const file1 = {
- id: 0,
- url: '',
- };
- RepoStore.files = [file1];
+ RepoStore.files = [file()];
RepoStore.isRoot = true;
vm = createComponent();
- vm.fileClicked(file1);
+ vm.fileClicked(RepoStore.files[0]);
- expect(Helper.getContent).toHaveBeenCalledWith(file1);
+ expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[0]);
});
it('should not fetch data for already opened files', () => {
- const file = {
- id: 42,
- url: 'foo',
- };
-
- spyOn(Helper, 'getFileFromPath').and.returnValue(file);
+ const f = file();
+ spyOn(Helper, 'getFileFromPath').and.returnValue(f);
spyOn(RepoStore, 'setActiveFiles');
vm = createComponent();
- vm.fileClicked(file);
+ vm.fileClicked(f);
- expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(file);
+ expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(f);
});
it('should hide files in directory if already open', () => {
- spyOn(RepoStore, 'removeChildFilesOfTree').and.callThrough();
- const file1 = {
- id: 0,
- type: 'tree',
- url: '',
- opened: true,
- };
- RepoStore.files = [file1];
- RepoStore.isRoot = true;
+ spyOn(Helper, 'setDirectoryToClosed').and.callThrough();
+ const f = file();
+ f.opened = true;
+ f.type = 'tree';
+ RepoStore.files = [f];
vm = createComponent();
- vm.fileClicked(file1);
+ vm.fileClicked(RepoStore.files[0]);
- expect(RepoStore.removeChildFilesOfTree).toHaveBeenCalledWith(file1);
+ expect(Helper.setDirectoryToClosed).toHaveBeenCalledWith(RepoStore.files[0]);
});
});
@@ -131,36 +131,31 @@ describe('RepoSidebar', () => {
});
describe('back button', () => {
- const file1 = {
- id: 1,
- url: 'file1',
- };
- const file2 = {
- id: 2,
- url: 'file2',
- };
- RepoStore.files = [file1, file2];
- RepoStore.openedFiles = [file1, file2];
- RepoStore.isRoot = true;
+ beforeEach(() => {
+ const f = file();
+ const file2 = Object.assign({}, file());
+ file2.url = 'test';
+ RepoStore.files = [f, file2];
+ RepoStore.openedFiles = [];
+ RepoStore.isRoot = true;
- vm = createComponent();
- vm.fileClicked(file1);
+ vm = createComponent();
+ });
it('render previous file when using back button', () => {
spyOn(Helper, 'getContent').and.callThrough();
- vm.fileClicked(file2);
- expect(Helper.getContent).toHaveBeenCalledWith(file2);
- Helper.getContent.calls.reset();
+ vm.fileClicked(RepoStore.files[1]);
+ expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[1]);
history.pushState({
key: Math.random(),
- }, '', file1.url);
+ }, '', RepoStore.files[1].url);
const popEvent = document.createEvent('Event');
popEvent.initEvent('popstate', true, true);
window.dispatchEvent(popEvent);
- expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(file1.url);
+ expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(RepoStore.files[1].url);
window.history.pushState({}, null, '/');
});
diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js
index d2a790ad73a..37e297437f0 100644
--- a/spec/javascripts/repo/components/repo_tab_spec.js
+++ b/spec/javascripts/repo/components/repo_tab_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import repoTab from '~/repo/components/repo_tab.vue';
+import RepoStore from '~/repo/stores/repo_store';
describe('RepoTab', () => {
function createComponent(propsData) {
@@ -18,7 +19,7 @@ describe('RepoTab', () => {
const vm = createComponent({
tab,
});
- const close = vm.$el.querySelector('.close');
+ const close = vm.$el.querySelector('.close-btn');
const name = vm.$el.querySelector(`a[title="${tab.url}"]`);
spyOn(vm, 'closeTab');
@@ -44,26 +45,43 @@ describe('RepoTab', () => {
tab,
});
- expect(vm.$el.querySelector('.close .fa-circle')).toBeTruthy();
+ expect(vm.$el.querySelector('.close-btn .fa-circle')).toBeTruthy();
});
describe('methods', () => {
describe('closeTab', () => {
- const vm = jasmine.createSpyObj('vm', ['$emit']);
-
it('returns undefined and does not $emit if file is changed', () => {
- const file = { changed: true };
- const returnVal = repoTab.methods.closeTab.call(vm, file);
+ const tab = {
+ url: 'url',
+ name: 'name',
+ changed: true,
+ };
+ const vm = createComponent({
+ tab,
+ });
+
+ spyOn(RepoStore, 'removeFromOpenedFiles');
+
+ vm.$el.querySelector('.close-btn').click();
- expect(returnVal).toBeUndefined();
- expect(vm.$emit).not.toHaveBeenCalled();
+ expect(RepoStore.removeFromOpenedFiles).not.toHaveBeenCalled();
});
it('$emits tabclosed event with file obj', () => {
- const file = { changed: false };
- repoTab.methods.closeTab.call(vm, file);
+ const tab = {
+ url: 'url',
+ name: 'name',
+ changed: false,
+ };
+ const vm = createComponent({
+ tab,
+ });
+
+ spyOn(RepoStore, 'removeFromOpenedFiles');
+
+ vm.$el.querySelector('.close-btn').click();
- expect(vm.$emit).toHaveBeenCalledWith('tabclosed', file);
+ expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(tab);
});
});
});
diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js
index a02b54efafc..431129bc866 100644
--- a/spec/javascripts/repo/components/repo_tabs_spec.js
+++ b/spec/javascripts/repo/components/repo_tabs_spec.js
@@ -16,6 +16,10 @@ describe('RepoTabs', () => {
return new RepoTabs().$mount();
}
+ afterEach(() => {
+ RepoStore.openedFiles = [];
+ });
+
it('renders a list of tabs', () => {
RepoStore.openedFiles = openedFiles;
@@ -28,18 +32,4 @@ describe('RepoTabs', () => {
expect(tabs[1].classList.contains('active')).toBeFalsy();
expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy();
});
-
- describe('methods', () => {
- describe('tabClosed', () => {
- it('calls removeFromOpenedFiles with file obj', () => {
- const file = {};
-
- spyOn(RepoStore, 'removeFromOpenedFiles');
-
- repoTabs.methods.tabClosed(file);
-
- expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(file);
- });
- });
- });
});
diff --git a/spec/javascripts/repo/mock_data.js b/spec/javascripts/repo/mock_data.js
new file mode 100644
index 00000000000..836b867205e
--- /dev/null
+++ b/spec/javascripts/repo/mock_data.js
@@ -0,0 +1,13 @@
+import RepoHelper from '~/repo/helpers/repo_helper';
+
+// eslint-disable-next-line import/prefer-default-export
+export const file = (name = 'name') => RepoHelper.serializeRepoEntity('blob', {
+ icon: 'icon',
+ url: 'url',
+ name,
+ last_commit: {
+ id: '123',
+ message: 'test',
+ committed_date: '',
+ },
+});
diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js
index a53f58b5d0d..cf811af3d6c 100644
--- a/spec/javascripts/search_autocomplete_spec.js
+++ b/spec/javascripts/search_autocomplete_spec.js
@@ -6,7 +6,7 @@ import '~/lib/utils/common_utils';
import 'vendor/fuzzaldrin-plus';
(function() {
- var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget;
+ var assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget;
var userName = 'root';
widget = null;
@@ -29,25 +29,31 @@ import 'vendor/fuzzaldrin-plus';
groupName = 'Gitlab Org';
+ const removeBodyAttributes = function() {
+ const $body = $('body');
+
+ $body.removeAttr('data-page');
+ $body.removeAttr('data-project');
+ $body.removeAttr('data-group');
+ };
+
// Add required attributes to body before starting the test.
// section would be dashboard|group|project
- addBodyAttributes = function(section) {
- var $body;
+ const addBodyAttributes = function(section) {
if (section == null) {
section = 'dashboard';
}
- $body = $('body');
- $body.removeAttr('data-page');
- $body.removeAttr('data-project');
- $body.removeAttr('data-group');
+
+ const $body = $('body');
+ removeBodyAttributes();
switch (section) {
case 'dashboard':
- return $body.data('page', 'root:index');
+ return $body.attr('data-page', 'root:index');
case 'group':
- $body.data('page', 'groups:show');
+ $body.attr('data-page', 'groups:show');
return $body.data('group', 'gitlab-org');
case 'project':
- $body.data('page', 'projects:show');
+ $body.attr('data-page', 'projects:show');
return $body.data('project', 'gitlab-ce');
}
};
@@ -108,7 +114,7 @@ import 'vendor/fuzzaldrin-plus';
preloadFixtures('static/search_autocomplete.html.raw');
beforeEach(function() {
loadFixtures('static/search_autocomplete.html.raw');
- widget = new gl.SearchAutocomplete;
+
// Prevent turbolinks from triggering within gl_dropdown
spyOn(window.gl.utils, 'visitUrl').and.returnValue(true);
@@ -120,6 +126,8 @@ import 'vendor/fuzzaldrin-plus';
});
afterEach(function() {
+ // Undo what we did to the shared <body>
+ removeBodyAttributes();
window.gon = {};
});
it('should show Dashboard specific dropdown menu', function() {
diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js
index a912e150e9b..f6320db8dc4 100644
--- a/spec/javascripts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/shortcuts_issuable_spec.js
@@ -1,7 +1,5 @@
-/* global ShortcutsIssuable */
-
import '~/copy_as_gfm';
-import '~/shortcuts_issuable';
+import ShortcutsIssuable from '~/shortcuts_issuable';
describe('ShortcutsIssuable', () => {
const fixtureName = 'merge_requests/diff_comment.html.raw';
diff --git a/spec/javascripts/shortcuts_spec.js b/spec/javascripts/shortcuts_spec.js
index 53e4c68beb3..a2a609edef9 100644
--- a/spec/javascripts/shortcuts_spec.js
+++ b/spec/javascripts/shortcuts_spec.js
@@ -1,4 +1,5 @@
-/* global Shortcuts */
+import Shortcuts from '~/shortcuts';
+
describe('Shortcuts', () => {
const fixtureName = 'merge_requests/diff_comment.html.raw';
const createEvent = (type, target) => $.Event(type, {
@@ -8,19 +9,17 @@ describe('Shortcuts', () => {
preloadFixtures(fixtureName);
describe('toggleMarkdownPreview', () => {
- let sc;
-
beforeEach(() => {
loadFixtures(fixtureName);
spyOnEvent('.js-new-note-form .js-md-preview-button', 'focus');
spyOnEvent('.edit-note .js-md-preview-button', 'focus');
- sc = new Shortcuts();
+ new Shortcuts(); // eslint-disable-line no-new
});
it('focuses preview button in form', () => {
- sc.toggleMarkdownPreview(
+ Shortcuts.toggleMarkdownPreview(
createEvent('KeyboardEvent', document.querySelector('.js-new-note-form .js-note-text'),
));
@@ -31,7 +30,7 @@ describe('Shortcuts', () => {
document.querySelector('.js-note-edit').click();
setTimeout(() => {
- sc.toggleMarkdownPreview(
+ Shortcuts.toggleMarkdownPreview(
createEvent('KeyboardEvent', document.querySelector('.edit-note .js-note-text'),
));
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
new file mode 100644
index 00000000000..aa93134f2dd
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import { placeholderImage } from '~/lazy_loader';
+import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import mountComponent from '../../../helpers/vue_mount_component_helper';
+
+const DEFAULT_PROPS = {
+ size: 99,
+ imgSrc: 'myavatarurl.com',
+ imgAlt: 'mydisplayname',
+ cssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+};
+
+describe('User Avatar Image Component', function () {
+ let vm;
+ let UserAvatarImage;
+
+ beforeEach(() => {
+ UserAvatarImage = Vue.extend(userAvatarImage);
+ });
+
+ describe('Initialization', function () {
+ beforeEach(function () {
+ vm = mountComponent(UserAvatarImage, {
+ ...DEFAULT_PROPS,
+ }).$mount();
+ });
+
+ it('should return a defined Vue component', function () {
+ expect(vm).toBeDefined();
+ });
+
+ 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('alt')).toBe(DEFAULT_PROPS.imgAlt);
+ });
+
+ it('should properly compute tooltipContainer', function () {
+ expect(vm.tooltipContainer).toBe('body');
+ });
+
+ it('should properly render tooltipContainer', function () {
+ expect(vm.$el.getAttribute('data-container')).toBe('body');
+ });
+
+ it('should properly compute avatarSizeClass', function () {
+ expect(vm.avatarSizeClass).toBe('s99');
+ });
+
+ it('should properly render img css', function () {
+ const classList = vm.$el.classList;
+ const containsAvatar = classList.contains('avatar');
+ const containsSizeClass = classList.contains('s99');
+ const containsCustomClass = classList.contains(DEFAULT_PROPS.cssClasses);
+ const lazyClass = classList.contains('lazy');
+
+ expect(containsAvatar).toBe(true);
+ expect(containsSizeClass).toBe(true);
+ expect(containsCustomClass).toBe(true);
+ expect(lazyClass).toBe(false);
+ });
+ });
+
+ describe('Initialization when lazy', function () {
+ beforeEach(function () {
+ vm = mountComponent(UserAvatarImage, {
+ ...DEFAULT_PROPS,
+ lazy: true,
+ }).$mount();
+ });
+
+ it('should add lazy attributes', function () {
+ const classList = vm.$el.classList;
+ 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);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
index 52e450e9ba5..52e450e9ba5 100644
--- a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js
index b8d639ffbec..b8d639ffbec 100644
--- a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js
diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js
deleted file mode 100644
index 8daa7610274..00000000000
--- a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import Vue from 'vue';
-import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
-
-const UserAvatarImageComponent = Vue.extend(UserAvatarImage);
-
-describe('User Avatar Image Component', function () {
- describe('Initialization', function () {
- beforeEach(function () {
- this.propsData = {
- size: 99,
- imgSrc: 'myavatarurl.com',
- imgAlt: 'mydisplayname',
- cssClasses: 'myextraavatarclass',
- tooltipText: 'tooltip text',
- tooltipPlacement: 'bottom',
- };
-
- this.userAvatarImage = new UserAvatarImageComponent({
- propsData: this.propsData,
- }).$mount();
- });
-
- it('should return a defined Vue component', function () {
- expect(this.userAvatarImage).toBeDefined();
- });
-
- it('should have <img> as a child element', function () {
- expect(this.userAvatarImage.$el.tagName).toBe('IMG');
- });
-
- it('should properly compute tooltipContainer', function () {
- expect(this.userAvatarImage.tooltipContainer).toBe('body');
- });
-
- it('should properly render tooltipContainer', function () {
- expect(this.userAvatarImage.$el.getAttribute('data-container')).toBe('body');
- });
-
- it('should properly compute avatarSizeClass', function () {
- expect(this.userAvatarImage.avatarSizeClass).toBe('s99');
- });
-
- it('should properly render img css', function () {
- const classList = this.userAvatarImage.$el.classList;
- const containsAvatar = classList.contains('avatar');
- const containsSizeClass = classList.contains('s99');
- const containsCustomClass = classList.contains('myextraavatarclass');
-
- expect(containsAvatar).toBe(true);
- expect(containsSizeClass).toBe(true);
- expect(containsCustomClass).toBe(true);
- });
- });
-});
diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js
index bd18f79cea7..7047053d131 100644
--- a/spec/javascripts/zen_mode_spec.js
+++ b/spec/javascripts/zen_mode_spec.js
@@ -1,7 +1,6 @@
/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, object-shorthand, comma-dangle, no-return-assign, new-cap, max-len */
-/* global Dropzone */
/* global Mousetrap */
-
+import Dropzone from 'dropzone';
import ZenMode from '~/zen_mode';
(function() {
diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb
index 5f41e28fece..17a620ef603 100644
--- a/spec/lib/banzai/filter/sanitization_filter_spec.rb
+++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb
@@ -217,6 +217,11 @@ describe Banzai::Filter::SanitizationFilter do
output: '<img>'
},
+ 'protocol-based JS injection: Unicode' => {
+ input: %Q(<a href="\u0001java\u0003script:alert('XSS')">foo</a>),
+ output: '<a>foo</a>'
+ },
+
'protocol-based JS injection: spaces and entities' => {
input: '<a href=" &#14; javascript:alert(\'XSS\');">foo</a>',
output: '<a href="">foo</a>'
diff --git a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
index d2e7243ee05..4d3fdbd9554 100644
--- a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
+++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
@@ -31,8 +31,8 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
end
it 'creates correct entries in the merge_request_diff_commits table' do
- expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(commits.count)
- expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(commits)
+ expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(expected_commits.count)
+ expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(expected_commits)
end
it 'creates correct entries in the merge_request_diff_files table' do
@@ -199,6 +199,16 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diff has valid commits and diffs' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
+ let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
+ let(:expected_diffs) { diffs }
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diff has diffs but no commits' do
+ let(:commits) { nil }
+ let(:expected_commits) { [] }
let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
let(:expected_diffs) { diffs }
@@ -207,6 +217,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs do not have too_large set' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
let(:diffs) do
@@ -218,6 +229,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs do not have a_mode and b_mode set' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
let(:diffs) do
@@ -229,6 +241,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs have binary content' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:expected_diffs) { diffs }
# The start of a PDF created by Illustrator
@@ -257,6 +270,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diff has commits, but no diffs' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:diffs) { [] }
let(:expected_diffs) { diffs }
@@ -265,6 +279,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs have invalid content' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:diffs) { ['--broken-diff'] }
let(:expected_diffs) { [] }
@@ -274,6 +289,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs are Rugged::Patch instances' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+ let(:expected_commits) { commits }
let(:diffs) { first_commit.rugged_diff_from_parent.patches }
let(:expected_diffs) { [] }
@@ -283,6 +299,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs are Rugged::Diff::Delta instances' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+ let(:expected_commits) { commits }
let(:diffs) { first_commit.rugged_diff_from_parent.deltas }
let(:expected_diffs) { [] }
diff --git a/spec/lib/gitlab/backup/manager_spec.rb b/spec/lib/gitlab/backup/manager_spec.rb
index 422f2af7266..b68301a066a 100644
--- a/spec/lib/gitlab/backup/manager_spec.rb
+++ b/spec/lib/gitlab/backup/manager_spec.rb
@@ -172,10 +172,6 @@ describe Backup::Manager do
end
describe '#unpack' do
- before do
- allow(Dir).to receive(:chdir)
- end
-
context 'when there are no backup files in the directory' do
before do
allow(Dir).to receive(:glob).and_return([])
diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb
index a4d7628b03a..5944ce8049a 100644
--- a/spec/lib/gitlab/conflict/file_collection_spec.rb
+++ b/spec/lib/gitlab/conflict/file_collection_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::Conflict::FileCollection do
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') }
- let(:file_collection) { described_class.read_only(merge_request) }
+ let(:file_collection) { described_class.new(merge_request) }
describe '#files' do
it 'returns an array of Conflict::Files' do
diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb
index 5356e9742b4..bf981d2f6f6 100644
--- a/spec/lib/gitlab/conflict/file_spec.rb
+++ b/spec/lib/gitlab/conflict/file_spec.rb
@@ -8,9 +8,10 @@ describe Gitlab::Conflict::File do
let(:our_commit) { rugged.branches['conflict-resolvable'].target }
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) }
let(:index) { rugged.merge_commits(our_commit, their_commit) }
- let(:conflict) { index.conflicts.last }
- let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') }
- let(:conflict_file) { described_class.new(merge_file_result, conflict, merge_request: merge_request) }
+ let(:rugged_conflict) { index.conflicts.last }
+ let(:raw_conflict_content) { index.merge_file('files/ruby/regex.rb')[:data] }
+ let(:raw_conflict_file) { Gitlab::Git::Conflict::File.new(repository, our_commit.oid, rugged_conflict, raw_conflict_content) }
+ let(:conflict_file) { described_class.new(raw_conflict_file, merge_request: merge_request) }
describe '#resolve_lines' do
let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact }
@@ -48,18 +49,18 @@ describe Gitlab::Conflict::File do
end
end
- it 'raises MissingResolution when passed a hash without resolutions for all sections' do
+ it 'raises ResolutionError when passed a hash without resolutions for all sections' do
empty_hash = section_keys.map { |key| [key, nil] }.to_h
invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h
expect { conflict_file.resolve_lines({}) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
expect { conflict_file.resolve_lines(empty_hash) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
expect { conflict_file.resolve_lines(invalid_hash) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
@@ -144,7 +145,7 @@ describe Gitlab::Conflict::File do
end
context 'with an example file' do
- let(:file) do
+ let(:raw_conflict_content) do
<<FILE
# Ensure there is no match line header here
def username_regexp
@@ -220,7 +221,6 @@ end
FILE
end
- let(:conflict_file) { described_class.new({ data: file }, conflict, merge_request: merge_request) }
let(:sections) { conflict_file.sections }
it 'sets the correct match line headers' do
diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb
index 8af49ed50ff..80c8c189665 100644
--- a/spec/lib/gitlab/diff/parser_spec.rb
+++ b/spec/lib/gitlab/diff/parser_spec.rb
@@ -143,4 +143,21 @@ eos
it { expect(parser.parse([])).to eq([]) }
it { expect(parser.parse(nil)).to eq([]) }
end
+
+ describe 'tolerates special diff markers in a content' do
+ it "counts lines correctly" do
+ diff = <<~END
+ --- a/test
+ +++ b/test
+ @@ -1,2 +1,2 @@
+ +ipsum
+ +++ b
+ -ipsum
+ END
+
+ lines = parser.parse(diff.lines).to_a
+
+ expect(lines.size).to eq(3)
+ end
+ end
end
diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb
index 9bf54fdecc4..245f24e96d4 100644
--- a/spec/lib/gitlab/diff/position_spec.rb
+++ b/spec/lib/gitlab/diff/position_spec.rb
@@ -40,7 +40,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 0)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 0)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -108,7 +108,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 15)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 15)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -149,7 +149,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -189,7 +189,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 13, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 13, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -233,7 +233,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 5)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 5)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -274,7 +274,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -314,7 +314,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 4, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 4, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -357,7 +357,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 0, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 0, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -399,7 +399,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 0)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 0)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -447,7 +447,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 0, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 0, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb
index 8b14b227e65..9151c66afb3 100644
--- a/spec/lib/gitlab/encoding_helper_spec.rb
+++ b/spec/lib/gitlab/encoding_helper_spec.rb
@@ -6,6 +6,9 @@ describe Gitlab::EncodingHelper do
describe '#encode!' do
[
+ ["nil", nil, nil],
+ ["empty string", "".encode("ASCII-8BIT"), "".encode("UTF-8")],
+ ["invalid utf-8 encoded string", "my bad string\xE5".force_encoding("UTF-8"), "my bad string"],
[
'leaves ascii only string as is',
'ascii only string',
@@ -81,6 +84,9 @@ describe Gitlab::EncodingHelper do
describe '#encode_utf8' do
[
+ ["nil", nil, nil],
+ ["empty string", "".encode("ASCII-8BIT"), "".encode("UTF-8")],
+ ["invalid utf-8 encoded string", "my bad string\xE5".force_encoding("UTF-8"), "my bad stringå"],
[
"encodes valid utf8 encoded string to utf8",
"λ, λ, λ".encode("UTF-8"),
@@ -95,12 +101,18 @@ describe Gitlab::EncodingHelper do
"encodes valid ISO-8859-1 encoded string to utf8",
"Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("ISO-8859-1", "UTF-8"),
"Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("UTF-8")
+ ],
+ [
+ # Test case from https://gitlab.com/gitlab-org/gitlab-ce/issues/39227
+ "Equifax branch name",
+ "refs/heads/Equifax".encode("UTF-8"),
+ "refs/heads/Equifax".encode("UTF-8")
]
].each do |description, test_string, xpect|
it description do
- r = ext_class.encode_utf8(test_string.force_encoding('UTF-8'))
+ r = ext_class.encode_utf8(test_string)
expect(r).to eq(xpect)
- expect(r.encoding.name).to eq('UTF-8')
+ expect(r.encoding.name).to eq('UTF-8') if xpect
end
end
diff --git a/spec/lib/gitlab/file_detector_spec.rb b/spec/lib/gitlab/file_detector_spec.rb
index 695fd6f8573..8e524f9b05a 100644
--- a/spec/lib/gitlab/file_detector_spec.rb
+++ b/spec/lib/gitlab/file_detector_spec.rb
@@ -18,6 +18,10 @@ describe Gitlab::FileDetector do
expect(described_class.type_of('README.md')).to eq(:readme)
end
+ it 'returns nil for a README file in a directory' do
+ expect(described_class.type_of('foo/README.md')).to be_nil
+ end
+
it 'returns the type of a changelog file' do
%w(CHANGELOG HISTORY CHANGES NEWS).each do |file|
expect(described_class.type_of(file)).to eq(:changelog)
@@ -52,6 +56,14 @@ describe Gitlab::FileDetector do
end
end
+ it 'returns the type of an issue template' do
+ expect(described_class.type_of('.gitlab/issue_templates/foo.md')).to eq(:issue_template)
+ end
+
+ it 'returns the type of a merge request template' do
+ expect(described_class.type_of('.gitlab/merge_request_templates/foo.md')).to eq(:merge_request_template)
+ end
+
it 'returns nil for an unknown file' do
expect(described_class.type_of('foo.txt')).to be_nil
end
diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/git/conflict/parser_spec.rb
index fce606a2bb5..7b035a381f1 100644
--- a/spec/lib/gitlab/conflict/parser_spec.rb
+++ b/spec/lib/gitlab/git/conflict/parser_spec.rb
@@ -1,11 +1,9 @@
require 'spec_helper'
-describe Gitlab::Conflict::Parser do
- let(:parser) { described_class.new }
-
- describe '#parse' do
+describe Gitlab::Git::Conflict::Parser do
+ describe '.parse' do
def parse_text(text)
- parser.parse(text, our_path: 'README.md', their_path: 'README.md')
+ described_class.parse(text, our_path: 'README.md', their_path: 'README.md')
end
context 'when the file has valid conflicts' do
@@ -87,33 +85,37 @@ CONFLICT
end
let(:lines) do
- parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb')
+ described_class.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb')
+ end
+ let(:old_line_numbers) do
+ lines.select { |line| line[:type] != 'new' }.map { |line| line[:line_old] }
end
+ let(:new_line_numbers) do
+ lines.select { |line| line[:type] != 'old' }.map { |line| line[:line_new] }
+ end
+ let(:line_indexes) { lines.map { |line| line[:line_obj_index] } }
it 'sets our lines as new lines' do
- expect(lines[8..13]).to all(have_attributes(type: 'new'))
- expect(lines[26..27]).to all(have_attributes(type: 'new'))
- expect(lines[56..57]).to all(have_attributes(type: 'new'))
+ expect(lines[8..13]).to all(include(type: 'new'))
+ expect(lines[26..27]).to all(include(type: 'new'))
+ expect(lines[56..57]).to all(include(type: 'new'))
end
it 'sets their lines as old lines' do
- expect(lines[14..19]).to all(have_attributes(type: 'old'))
- expect(lines[28..29]).to all(have_attributes(type: 'old'))
- expect(lines[58..59]).to all(have_attributes(type: 'old'))
+ expect(lines[14..19]).to all(include(type: 'old'))
+ expect(lines[28..29]).to all(include(type: 'old'))
+ expect(lines[58..59]).to all(include(type: 'old'))
end
it 'sets non-conflicted lines as both' do
- expect(lines[0..7]).to all(have_attributes(type: nil))
- expect(lines[20..25]).to all(have_attributes(type: nil))
- expect(lines[30..55]).to all(have_attributes(type: nil))
- expect(lines[60..62]).to all(have_attributes(type: nil))
+ expect(lines[0..7]).to all(include(type: nil))
+ expect(lines[20..25]).to all(include(type: nil))
+ expect(lines[30..55]).to all(include(type: nil))
+ expect(lines[60..62]).to all(include(type: nil))
end
- it 'sets consecutive line numbers for index, old_pos, and new_pos' do
- old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos)
- new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos)
-
- expect(lines.map(&:index)).to eq(0.upto(62).to_a)
+ it 'sets consecutive line numbers for line_obj_index, line_old, and line_new' do
+ expect(line_indexes).to eq(0.upto(62).to_a)
expect(old_line_numbers).to eq(1.upto(53).to_a)
expect(new_line_numbers).to eq(1.upto(53).to_a)
end
@@ -123,12 +125,12 @@ CONFLICT
context 'when there is a non-start delimiter first' do
it 'raises UnexpectedDelimiter when there is a middle delimiter first' do
expect { parse_text('=======') }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when there is an end delimiter first' do
expect { parse_text('>>>>>>> README.md') }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when there is an end delimiter for a different path first' do
@@ -143,12 +145,12 @@ CONFLICT
it 'raises UnexpectedDelimiter when it is followed by an end delimiter' do
expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when it is followed by another start delimiter' do
expect { parse_text(start_text + start_text + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when it is followed by a start delimiter for a different path' do
@@ -163,12 +165,12 @@ CONFLICT
it 'raises UnexpectedDelimiter when it is followed by another middle delimiter' do
expect { parse_text(start_text + '=======' + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when it is followed by a start delimiter' do
expect { parse_text(start_text + start_text + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when it is followed by a start delimiter for another path' do
@@ -181,25 +183,25 @@ CONFLICT
start_text = "<<<<<<< README.md\n=======\n"
expect { parse_text(start_text) }
- .to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::MissingEndDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md') }
- .to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::MissingEndDelimiter)
end
end
context 'other file types' do
it 'raises UnmergeableFile when lines is blank, indicating a binary file' do
expect { parse_text('') }
- .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnmergeableFile)
expect { parse_text(nil) }
- .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnmergeableFile)
end
it 'raises UnmergeableFile when the file is over 200 KB' do
expect { parse_text('a' * 204801) }
- .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnmergeableFile)
end
# All text from Rugged has an encoding of ASCII_8BIT, so force that in
@@ -214,7 +216,7 @@ CONFLICT
context 'when the file contains non-UTF-8 characters' do
it 'raises UnsupportedEncoding' do
expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }
- .to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnsupportedEncoding)
end
end
end
diff --git a/spec/lib/gitlab/git/env_spec.rb b/spec/lib/gitlab/git/env_spec.rb
index d9df99bfe05..03836d49518 100644
--- a/spec/lib/gitlab/git/env_spec.rb
+++ b/spec/lib/gitlab/git/env_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::Git::Env do
- describe "#set" do
+ describe ".set" do
context 'with RequestStore.store disabled' do
before do
allow(RequestStore).to receive(:active?).and_return(false)
@@ -34,25 +34,57 @@ describe Gitlab::Git::Env do
end
end
- describe "#all" do
+ describe ".all" do
context 'with RequestStore.store enabled' do
before do
allow(RequestStore).to receive(:active?).and_return(true)
described_class.set(
GIT_OBJECT_DIRECTORY: 'foo',
- GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar')
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: ['bar'])
end
it 'returns an env hash' do
expect(described_class.all).to eq({
'GIT_OBJECT_DIRECTORY' => 'foo',
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar'
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => ['bar']
})
end
end
end
- describe "#[]" do
+ describe ".to_env_hash" do
+ context 'with RequestStore.store enabled' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:key) { 'GIT_OBJECT_DIRECTORY' }
+ subject { described_class.to_env_hash }
+
+ where(:input, :output) do
+ nil | nil
+ 'foo' | 'foo'
+ [] | ''
+ ['foo'] | 'foo'
+ %w[foo bar] | 'foo:bar'
+ end
+
+ with_them do
+ before do
+ allow(RequestStore).to receive(:active?).and_return(true)
+ described_class.set(key.to_sym => input)
+ end
+
+ it 'puts the right value in the hash' do
+ if output
+ expect(subject.fetch(key)).to eq(output)
+ else
+ expect(subject.has_key?(key)).to eq(false)
+ end
+ end
+ end
+ end
+ end
+
+ describe ".[]" do
context 'with RequestStore.store enabled' do
before do
allow(RequestStore).to receive(:active?).and_return(true)
diff --git a/spec/lib/gitlab/git/popen_spec.rb b/spec/lib/gitlab/git/popen_spec.rb
new file mode 100644
index 00000000000..2b65bc1cf15
--- /dev/null
+++ b/spec/lib/gitlab/git/popen_spec.rb
@@ -0,0 +1,132 @@
+require 'spec_helper'
+
+describe 'Gitlab::Git::Popen' do
+ let(:path) { Rails.root.join('tmp').to_s }
+
+ let(:klass) do
+ Class.new(Object) do
+ include Gitlab::Git::Popen
+ end
+ end
+
+ context 'popen' do
+ context 'zero status' do
+ let(:result) { klass.new.popen(%w(ls), path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to be_zero }
+ it { expect(output).to include('tests') }
+ end
+
+ context 'non-zero status' do
+ let(:result) { klass.new.popen(%w(cat NOTHING), path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to eq(1) }
+ it { expect(output).to include('No such file or directory') }
+ end
+
+ context 'unsafe string command' do
+ it 'raises an error when it gets called with a string argument' do
+ expect { klass.new.popen('ls', path) }.to raise_error(RuntimeError)
+ end
+ end
+
+ context 'with custom options' do
+ let(:vars) { { 'foobar' => 123, 'PWD' => path } }
+ let(:options) { { chdir: path } }
+
+ it 'calls popen3 with the provided environment variables' do
+ expect(Open3).to receive(:popen3).with(vars, 'ls', options)
+
+ klass.new.popen(%w(ls), path, { 'foobar' => 123 })
+ end
+ end
+
+ context 'use stdin' do
+ let(:result) { klass.new.popen(%w[cat], path) { |stdin| stdin.write 'hello' } }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to be_zero }
+ it { expect(output).to eq('hello') }
+ end
+ end
+
+ context 'popen_with_timeout' do
+ let(:timeout) { 1.second }
+
+ context 'no timeout' do
+ context 'zero status' do
+ let(:result) { klass.new.popen_with_timeout(%w(ls), timeout, path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to be_zero }
+ it { expect(output).to include('tests') }
+ end
+
+ context 'non-zero status' do
+ let(:result) { klass.new.popen_with_timeout(%w(cat NOTHING), timeout, path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to eq(1) }
+ it { expect(output).to include('No such file or directory') }
+ end
+
+ context 'unsafe string command' do
+ it 'raises an error when it gets called with a string argument' do
+ expect { klass.new.popen_with_timeout('ls', timeout, path) }.to raise_error(RuntimeError)
+ end
+ end
+ end
+
+ context 'timeout' do
+ context 'timeout' do
+ it "raises a Timeout::Error" do
+ expect { klass.new.popen_with_timeout(%w(sleep 1000), timeout, path) }.to raise_error(Timeout::Error)
+ end
+
+ it "handles processes that do not shutdown correctly" do
+ expect { klass.new.popen_with_timeout(['bash', '-c', "trap -- '' SIGTERM; sleep 1000"], timeout, path) }.to raise_error(Timeout::Error)
+ end
+ end
+
+ context 'timeout period' do
+ let(:time_taken) do
+ begin
+ start = Time.now
+ klass.new.popen_with_timeout(%w(sleep 1000), timeout, path)
+ rescue
+ Time.now - start
+ end
+ end
+
+ it { expect(time_taken).to be >= timeout }
+ end
+
+ context 'clean up' do
+ let(:instance) { klass.new }
+
+ it 'kills the child process' do
+ expect(instance).to receive(:kill_process_group_for_pid).and_wrap_original do |m, *args|
+ # is the PID, and it should not be running at this point
+ m.call(*args)
+
+ pid = args.first
+ begin
+ Process.getpgid(pid)
+ raise "The child process should have been killed"
+ rescue Errno::ESRCH
+ end
+ end
+
+ expect { instance.popen_with_timeout(['bash', '-c', "trap -- '' SIGTERM; sleep 1000"], timeout, path) }.to raise_error(Timeout::Error)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 1ee4acfd193..b2d2f770e3d 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -68,31 +68,52 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect { broken_repo.rugged }.to raise_error(Gitlab::Git::Repository::NoRepository)
end
- context 'with no Git env stored' do
- before do
- expect(Gitlab::Git::Env).to receive(:all).and_return({})
- end
+ describe 'alternates keyword argument' do
+ context 'with no Git env stored' do
+ before do
+ allow(Gitlab::Git::Env).to receive(:all).and_return({})
+ end
- it "whitelist some variables and pass them via the alternates keyword argument" do
- expect(Rugged::Repository).to receive(:new).with(repository.path, alternates: [])
+ it "is passed an empty array" do
+ expect(Rugged::Repository).to receive(:new).with(repository.path, alternates: [])
- repository.rugged
+ repository.rugged
+ end
end
- end
- context 'with some Git env stored' do
- before do
- expect(Gitlab::Git::Env).to receive(:all).and_return({
- 'GIT_OBJECT_DIRECTORY' => 'foo',
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar',
- 'GIT_OTHER' => 'another_env'
- })
+ context 'with absolute and relative Git object dir envvars stored' do
+ before do
+ allow(Gitlab::Git::Env).to receive(:all).and_return({
+ 'GIT_OBJECT_DIRECTORY_RELATIVE' => './objects/foo',
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => ['./objects/bar', './objects/baz'],
+ 'GIT_OBJECT_DIRECTORY' => 'ignored',
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => %w[ignored ignored],
+ 'GIT_OTHER' => 'another_env'
+ })
+ end
+
+ it "is passed the relative object dir envvars after being converted to absolute ones" do
+ alternates = %w[foo bar baz].map { |d| File.join(repository.path, './objects', d) }
+ expect(Rugged::Repository).to receive(:new).with(repository.path, alternates: alternates)
+
+ repository.rugged
+ end
end
- it "whitelist some variables and pass them via the alternates keyword argument" do
- expect(Rugged::Repository).to receive(:new).with(repository.path, alternates: %w[foo bar])
+ context 'with only absolute Git object dir envvars stored' do
+ before do
+ allow(Gitlab::Git::Env).to receive(:all).and_return({
+ 'GIT_OBJECT_DIRECTORY' => 'foo',
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => %w[bar baz],
+ 'GIT_OTHER' => 'another_env'
+ })
+ end
+
+ it "is passed the absolute object dir envvars as is" do
+ expect(Rugged::Repository).to receive(:new).with(repository.path, alternates: %w[foo bar baz])
- repository.rugged
+ repository.rugged
+ end
end
end
end
@@ -1489,6 +1510,60 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
+ describe '#fetch' do
+ let(:git_path) { Gitlab.config.git.bin_path }
+ let(:remote_name) { 'my_remote' }
+
+ subject { repository.fetch(remote_name) }
+
+ it 'fetches the remote and returns true if the command was successful' do
+ expect(repository).to receive(:popen)
+ .with(%W(#{git_path} fetch #{remote_name}), repository.path)
+ .and_return(['', 0])
+
+ expect(subject).to be(true)
+ end
+ end
+
+ describe '#merge' do
+ let(:repository) do
+ Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
+ end
+ let(:source_sha) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' }
+ let(:user) { build(:user) }
+ let(:target_branch) { 'test-merge-target-branch' }
+
+ before do
+ repository.create_branch(target_branch, '6d394385cf567f80a8fd85055db1ab4c5295806f')
+ end
+
+ after do
+ FileUtils.rm_rf(TEST_MUTABLE_REPO_PATH)
+ ensure_seeds
+ end
+
+ shared_examples '#merge' do
+ it 'can perform a merge' do
+ merge_commit_id = nil
+ result = repository.merge(user, source_sha, target_branch, 'Test merge') do |commit_id|
+ merge_commit_id = commit_id
+ end
+
+ expect(result.newrev).to eq(merge_commit_id)
+ expect(result.repo_created).to eq(false)
+ expect(result.branch_created).to eq(false)
+ end
+ end
+
+ context 'with gitaly' do
+ it_behaves_like '#merge'
+ end
+
+ context 'without gitaly', :skip_gitaly_mock do
+ it_behaves_like '#merge'
+ end
+ end
+
def create_remote_branch(repository, remote_name, branch_name, source_branch_name)
source_branch = repository.branches.find { |branch| branch.name == source_branch_name }
rugged = repository.rugged
diff --git a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
index 98cf7966dad..c8d532df059 100644
--- a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
+++ b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
@@ -10,18 +10,10 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
# Override test-settings for the circuitbreaker with something more realistic
# for these specs.
stub_storage_settings('default' => {
- 'path' => TestEnv.repos_path,
- 'failure_count_threshold' => 10,
- 'failure_wait_time' => 30,
- 'failure_reset_time' => 1800,
- 'storage_timeout' => 5
+ 'path' => TestEnv.repos_path
},
'broken' => {
- 'path' => 'tmp/tests/non-existent-repositories',
- 'failure_count_threshold' => 10,
- 'failure_wait_time' => 30,
- 'failure_reset_time' => 1800,
- 'storage_timeout' => 5
+ 'path' => 'tmp/tests/non-existent-repositories'
},
'nopath' => { 'path' => nil }
)
@@ -49,6 +41,10 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
expect(key_exists).to be_falsey
end
+
+ it 'does not break when there are no keys in redis' do
+ expect { described_class.reset_all! }.not_to raise_error
+ end
end
describe '.for_storage' do
@@ -75,10 +71,39 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
expect(circuit_breaker.hostname).to eq(hostname)
expect(circuit_breaker.storage).to eq('default')
expect(circuit_breaker.storage_path).to eq(TestEnv.repos_path)
- expect(circuit_breaker.failure_count_threshold).to eq(10)
- expect(circuit_breaker.failure_wait_time).to eq(30)
- expect(circuit_breaker.failure_reset_time).to eq(1800)
- expect(circuit_breaker.storage_timeout).to eq(5)
+ end
+ end
+
+ context 'circuitbreaker settings' do
+ before do
+ stub_application_setting(circuitbreaker_failure_count_threshold: 0,
+ circuitbreaker_failure_wait_time: 1,
+ circuitbreaker_failure_reset_time: 2,
+ circuitbreaker_storage_timeout: 3)
+ end
+
+ describe '#failure_count_threshold' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.failure_count_threshold).to eq(0)
+ end
+ end
+
+ describe '#failure_wait_time' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.failure_wait_time).to eq(1)
+ end
+ end
+
+ describe '#failure_reset_time' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.failure_reset_time).to eq(2)
+ end
+ end
+
+ describe '#storage_timeout' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.storage_timeout).to eq(3)
+ end
end
end
@@ -151,10 +176,7 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
context 'the `failure_wait_time` is set to 0' do
before do
- stub_storage_settings('default' => {
- 'failure_wait_time' => 0,
- 'path' => TestEnv.repos_path
- })
+ stub_application_setting(circuitbreaker_failure_wait_time: 0)
end
it 'is working even when there is a recent failure' do
diff --git a/spec/lib/gitlab/git/storage/health_spec.rb b/spec/lib/gitlab/git/storage/health_spec.rb
index 2d3af387971..4a14a5201d1 100644
--- a/spec/lib/gitlab/git/storage/health_spec.rb
+++ b/spec/lib/gitlab/git/storage/health_spec.rb
@@ -20,36 +20,6 @@ describe Gitlab::Git::Storage::Health, clean_gitlab_redis_shared_state: true, br
end
end
- describe '.load_for_keys' do
- let(:subject) do
- results = Gitlab::Git::Storage.redis.with do |redis|
- fake_future = double
- allow(fake_future).to receive(:value).and_return([host1_key])
- described_class.load_for_keys({ 'broken' => fake_future }, redis)
- end
-
- # Make sure the `Redis#future is loaded
- results.inject({}) do |result, (name, info)|
- info.each { |i| i[:failure_count] = i[:failure_count].value.to_i }
-
- result[name] = info
-
- result
- end
- end
-
- it 'loads when there is no info in redis' do
- expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 0 }])
- end
-
- it 'reads the correct values for a storage from redis' do
- set_in_redis(host1_key, 5)
- set_in_redis(host2_key, 7)
-
- expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 5 }])
- end
- end
-
describe '.for_all_storages' do
it 'loads health status for all configured storages' do
healths = described_class.for_all_storages
diff --git a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
index 0e645008c88..7ee6d2f3709 100644
--- a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
+++ b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
@@ -54,6 +54,10 @@ describe Gitlab::Git::Storage::NullCircuitBreaker do
end
describe '#failure_count_threshold' do
+ before do
+ stub_application_setting(circuitbreaker_failure_count_threshold: 1)
+ end
+
it { expect(breaker.failure_count_threshold).to eq(1) }
end
diff --git a/spec/lib/gitlab/gitaly_client/util_spec.rb b/spec/lib/gitlab/gitaly_client/util_spec.rb
index 498f6886bee..c0c29552494 100644
--- a/spec/lib/gitlab/gitaly_client/util_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/util_spec.rb
@@ -6,16 +6,16 @@ describe Gitlab::GitalyClient::Util do
let(:relative_path) { 'my/repo.git' }
let(:gl_repository) { 'project-1' }
let(:git_object_directory) { '.git/objects' }
- let(:git_alternate_object_directory) { '/dir/one:/dir/two' }
+ let(:git_alternate_object_directory) { ['/dir/one', '/dir/two'] }
subject do
described_class.repository(repository_storage, relative_path, gl_repository)
end
it 'creates a Gitaly::Repository with the given data' do
- expect(Gitlab::Git::Env).to receive(:[]).with('GIT_OBJECT_DIRECTORY')
+ allow(Gitlab::Git::Env).to receive(:[]).with('GIT_OBJECT_DIRECTORY_RELATIVE')
.and_return(git_object_directory)
- expect(Gitlab::Git::Env).to receive(:[]).with('GIT_ALTERNATE_OBJECT_DIRECTORIES')
+ allow(Gitlab::Git::Env).to receive(:[]).with('GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE')
.and_return(git_alternate_object_directory)
expect(subject).to be_a(Gitaly::Repository)
@@ -23,7 +23,7 @@ describe Gitlab::GitalyClient::Util do
expect(subject.relative_path).to eq(relative_path)
expect(subject.gl_repository).to eq(gl_repository)
expect(subject.git_object_directory).to eq(git_object_directory)
- expect(subject.git_alternate_object_directories).to eq([git_alternate_object_directory])
+ expect(subject.git_alternate_object_directories).to eq(git_alternate_object_directory)
end
end
diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb
index 8dc83a6db7f..30686634af4 100644
--- a/spec/lib/gitlab/group_hierarchy_spec.rb
+++ b/spec/lib/gitlab/group_hierarchy_spec.rb
@@ -18,6 +18,12 @@ describe Gitlab::GroupHierarchy, :postgresql do
expect(relation).to include(parent, child1)
end
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1)
+
+ expect(relation).to contain_exactly(child2)
+ end
+
it 'uses ancestors_base #initialize argument' do
relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
@@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do
end
end
+ describe '#descendants' do
+ it 'includes only the descendants' do
+ relation = described_class.new(Group.where(id: parent)).descendants
+
+ expect(relation).to contain_exactly(child1, child2)
+ end
+ end
+
+ describe '#ancestors' do
+ it 'includes only the ancestors' do
+ relation = described_class.new(Group.where(id: child2)).ancestors
+
+ expect(relation).to contain_exactly(child1, parent)
+ end
+
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1)
+
+ expect(relation).to be_empty
+ end
+ end
+
describe '#all_groups' do
let(:relation) do
described_class.new(Group.where(id: child1.id)).all_groups
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index 80d92b2e6a3..121c0ed04ed 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -496,6 +496,7 @@ Timelog:
- merge_request_id
- issue_id
- user_id
+- spent_at
- created_at
- updated_at
ProjectAutoDevops:
diff --git a/spec/lib/gitlab/multi_collection_paginator_spec.rb b/spec/lib/gitlab/multi_collection_paginator_spec.rb
new file mode 100644
index 00000000000..68bd4f93159
--- /dev/null
+++ b/spec/lib/gitlab/multi_collection_paginator_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe Gitlab::MultiCollectionPaginator do
+ subject(:paginator) { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: 3) }
+
+ it 'combines both collections' do
+ project = create(:project)
+ group = create(:group)
+
+ expect(paginator.paginate(1)).to eq([project, group])
+ end
+
+ it 'includes elements second collection if first collection is empty' do
+ group = create(:group)
+
+ expect(paginator.paginate(1)).to eq([group])
+ end
+
+ context 'with a full first page' do
+ let!(:all_groups) { create_list(:group, 4) }
+ let!(:all_projects) { create_list(:project, 4) }
+
+ it 'knows the total count of the collection' do
+ expect(paginator.total_count).to eq(8)
+ end
+
+ it 'fills the first page with elements of the first collection' do
+ expect(paginator.paginate(1)).to eq(all_projects.take(3))
+ end
+
+ it 'fils the second page with a mixture of of the first & second collection' do
+ first_collection_element = all_projects.last
+ second_collection_elements = all_groups.take(2)
+
+ expected_collection = [first_collection_element] + second_collection_elements
+
+ expect(paginator.paginate(2)).to eq(expected_collection)
+ end
+
+ it 'fils the last page with elements from the second collection' do
+ expected_collection = all_groups[-2..-1]
+
+ expect(paginator.paginate(3)).to eq(expected_collection)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 1f1c48ee9b5..f1f188cbfb5 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -213,7 +213,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
it 'is not case sensitive' do
@@ -246,7 +246,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
end
@@ -268,7 +268,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/more/')
expect(subject).to match('group_members/more/')
- expect(subject).to match('subgroups/more/')
+ expect(subject).to match('labels/more/')
end
end
end
@@ -292,7 +292,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do
expect(subject).not_to match('root/activity/')
expect(subject).not_to match('root/group_members/')
- expect(subject).not_to match('root/subgroups/')
+ expect(subject).not_to match('root/labels/')
end
end
@@ -314,7 +314,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do
expect(subject).not_to match('root/activity/more/')
expect(subject).not_to match('root/group_members/more/')
- expect(subject).not_to match('root/subgroups/more/')
+ expect(subject).not_to match('root/labels/more/')
end
end
end
@@ -349,7 +349,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
it 'is not case sensitive' do
@@ -382,7 +382,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('root/activity/')
expect(subject).to match('root/group_members/')
- expect(subject).to match('root/subgroups/')
+ expect(subject).to match('root/labels/')
end
it 'is not case sensitive' do
diff --git a/spec/lib/gitlab/quick_actions/spend_time_and_date_separator_spec.rb b/spec/lib/gitlab/quick_actions/spend_time_and_date_separator_spec.rb
new file mode 100644
index 00000000000..8b58f0b3725
--- /dev/null
+++ b/spec/lib/gitlab/quick_actions/spend_time_and_date_separator_spec.rb
@@ -0,0 +1,81 @@
+require 'spec_helper'
+
+describe Gitlab::QuickActions::SpendTimeAndDateSeparator do
+ subject { described_class }
+
+ shared_examples 'arg line with invalid parameters' do
+ it 'return nil' do
+ expect(subject.new(invalid_arg).execute).to eq(nil)
+ end
+ end
+
+ shared_examples 'arg line with valid parameters' do
+ it 'return time and date array' do
+ expect(subject.new(valid_arg).execute).to eq(expected_response)
+ end
+ end
+
+ describe '#execute' do
+ context 'invalid paramenter in arg line' do
+ context 'empty arg line' do
+ it_behaves_like 'arg line with invalid parameters' do
+ let(:invalid_arg) { '' }
+ end
+ end
+
+ context 'future date in arg line' do
+ it_behaves_like 'arg line with invalid parameters' do
+ let(:invalid_arg) { '10m 6023-02-02' }
+ end
+ end
+
+ context 'unparseable date(invalid mixes of delimiters)' do
+ it_behaves_like 'arg line with invalid parameters' do
+ let(:invalid_arg) { '10m 2017.02-02' }
+ end
+ end
+
+ context 'trash in arg line' do
+ let(:invalid_arg) { 'dfjkghdskjfghdjskfgdfg' }
+
+ it 'return nil as time value' do
+ time_date_response = subject.new(invalid_arg).execute
+
+ expect(time_date_response).to be_an_instance_of(Array)
+ expect(time_date_response.first).to eq(nil)
+ end
+ end
+ end
+
+ context 'only time present in arg line' do
+ it_behaves_like 'arg line with valid parameters' do
+ let(:valid_arg) { '2m 3m 5m 1h' }
+ let(:time) { Gitlab::TimeTrackingFormatter.parse(valid_arg) }
+ let(:date) { DateTime.now.to_date }
+ let(:expected_response) { [time, date] }
+ end
+ end
+
+ context 'simple time with date in arg line' do
+ it_behaves_like 'arg line with valid parameters' do
+ let(:raw_time) { '10m' }
+ let(:raw_date) { '2016-02-02' }
+ let(:valid_arg) { "#{raw_time} #{raw_date}" }
+ let(:date) { Date.parse(raw_date) }
+ let(:time) { Gitlab::TimeTrackingFormatter.parse(raw_time) }
+ let(:expected_response) { [time, date] }
+ end
+ end
+
+ context 'composite time with date in arg line' do
+ it_behaves_like 'arg line with valid parameters' do
+ let(:raw_time) { '2m 10m 1h 3d' }
+ let(:raw_date) { '2016/02/02' }
+ let(:valid_arg) { "#{raw_time} #{raw_date}" }
+ let(:date) { Date.parse(raw_date) }
+ let(:time) { Gitlab::TimeTrackingFormatter.parse(raw_time) }
+ let(:expected_response) { [time, date] }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/saml/auth_hash_spec.rb b/spec/lib/gitlab/saml/auth_hash_spec.rb
new file mode 100644
index 00000000000..a555935aea3
--- /dev/null
+++ b/spec/lib/gitlab/saml/auth_hash_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Gitlab::Saml::AuthHash do
+ include LoginHelpers
+
+ let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers) } }
+ subject(:saml_auth_hash) { described_class.new(omniauth_auth_hash) }
+
+ let(:info_hash) do
+ {
+ name: 'John',
+ email: 'john@mail.com'
+ }
+ end
+
+ let(:omniauth_auth_hash) do
+ OmniAuth::AuthHash.new(uid: 'my-uid',
+ provider: 'saml',
+ info: info_hash,
+ extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) } )
+ end
+
+ before do
+ stub_saml_group_config(%w(Developers Freelancers Designers))
+ end
+
+ describe '#groups' do
+ it 'returns array of groups' do
+ expect(saml_auth_hash.groups).to eq(%w(Developers Freelancers))
+ end
+
+ context 'raw info hash attributes empty' do
+ let(:raw_info_attr) { {} }
+
+ it 'returns an empty array' do
+ expect(saml_auth_hash.groups).to be_a(Array)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/saml/user_spec.rb b/spec/lib/gitlab/saml/user_spec.rb
index 59923bfb14d..1c23fb5f285 100644
--- a/spec/lib/gitlab/saml/user_spec.rb
+++ b/spec/lib/gitlab/saml/user_spec.rb
@@ -2,13 +2,15 @@ require 'spec_helper'
describe Gitlab::Saml::User do
include LdapHelpers
+ include LoginHelpers
let(:saml_user) { described_class.new(auth_hash) }
let(:gl_user) { saml_user.gl_user }
let(:uid) { 'my-uid' }
let(:dn) { 'uid=user1,ou=People,dc=example' }
let(:provider) { 'saml' }
- let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash, extra: { raw_info: OneLogin::RubySaml::Attributes.new({ 'groups' => %w(Developers Freelancers Designers) }) }) }
+ let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers Designers) } }
+ let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash, extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) }) }
let(:info_hash) do
{
name: 'John',
@@ -18,22 +20,6 @@ describe Gitlab::Saml::User do
let(:ldap_user) { Gitlab::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
describe '#save' do
- def stub_omniauth_config(messages)
- allow(Gitlab.config.omniauth).to receive_messages(messages)
- end
-
- def stub_ldap_config(messages)
- allow(Gitlab::LDAP::Config).to receive_messages(messages)
- end
-
- def stub_basic_saml_config
- allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', args: {} } })
- end
-
- def stub_saml_group_config(groups)
- allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', groups_attribute: 'groups', external_groups: groups, args: {} } })
- end
-
before do
stub_basic_saml_config
end
@@ -402,4 +388,16 @@ describe Gitlab::Saml::User do
end
end
end
+
+ describe '#find_user' do
+ context 'raw info hash attributes empty' do
+ let(:raw_info_attr) { {} }
+
+ it 'does not mark user as external' do
+ stub_saml_group_config(%w(Freelancers))
+
+ expect(saml_user.find_user.external).to be_falsy
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/sidekiq_status_spec.rb b/spec/lib/gitlab/sidekiq_status_spec.rb
index c2e77ef6b6c..884f27b212c 100644
--- a/spec/lib/gitlab/sidekiq_status_spec.rb
+++ b/spec/lib/gitlab/sidekiq_status_spec.rb
@@ -39,6 +39,18 @@ describe Gitlab::SidekiqStatus do
end
end
+ describe '.running?', :clean_gitlab_redis_shared_state do
+ it 'returns true if job is running' do
+ described_class.set('123')
+
+ expect(described_class.running?('123')).to be(true)
+ end
+
+ it 'returns false if job is not found' do
+ expect(described_class.running?('123')).to be(false)
+ end
+ end
+
describe '.num_running', :clean_gitlab_redis_shared_state do
it 'returns 0 if all jobs have been completed' do
expect(described_class.num_running(%w(123))).to eq(0)
diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb
index 8026fba9f0a..fe6422c32b6 100644
--- a/spec/lib/gitlab/sql/union_spec.rb
+++ b/spec/lib/gitlab/sql/union_spec.rb
@@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do
expect(union.to_sql).to include('UNION ALL')
end
+
+ it 'returns `NULL` if all relations are empty' do
+ empty_relation = User.none
+ union = described_class.new([empty_relation, empty_relation])
+
+ expect(union.to_sql).to eq('NULL')
+ end
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 777e9c8e21d..a7b65e94706 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -61,6 +61,8 @@ describe Gitlab::UsageData do
deployments
environments
gcp_clusters
+ gcp_clusters_enabled
+ gcp_clusters_disabled
in_review_folder
groups
issues
diff --git a/spec/lib/gitlab/utils/merge_hash_spec.rb b/spec/lib/gitlab/utils/merge_hash_spec.rb
new file mode 100644
index 00000000000..4fa7bb31301
--- /dev/null
+++ b/spec/lib/gitlab/utils/merge_hash_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+describe Gitlab::Utils::MergeHash do
+ describe '.crush' do
+ it 'can flatten a hash to each element' do
+ input = { hello: "world", this: { crushes: ["an entire", "hash"] } }
+ expected_result = [:hello, "world", :this, :crushes, "an entire", "hash"]
+
+ expect(described_class.crush(input)).to eq(expected_result)
+ end
+ end
+
+ describe '.elements' do
+ it 'deep merges an array of elements' do
+ input = [{ hello: ["world"] },
+ { hello: "Everyone" },
+ { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] } },
+ "Goodbye", "Hallo"]
+ expected_output = [
+ {
+ hello:
+ [
+ "world",
+ "Everyone",
+ { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] }
+ ]
+ },
+ "Goodbye"
+ ]
+
+ expect(described_class.merge(input)).to eq(expected_output)
+ end
+ end
+end
diff --git a/spec/migrations/migrate_user_project_view_spec.rb b/spec/migrations/migrate_user_project_view_spec.rb
index afaa5d836a7..5e16769d63a 100644
--- a/spec/migrations/migrate_user_project_view_spec.rb
+++ b/spec/migrations/migrate_user_project_view_spec.rb
@@ -5,12 +5,7 @@ require Rails.root.join('db', 'post_migrate', '20170406142253_migrate_user_proje
describe MigrateUserProjectView, :truncate do
let(:migration) { described_class.new }
- let!(:user) { create(:user) }
-
- before do
- # 0 is the numeric value for the old 'readme' option
- user.update_column(:project_view, 0)
- end
+ let!(:user) { create(:user, project_view: 'readme') }
describe '#up' do
it 'updates project view setting with new value' do
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 78cacf9ff5d..6945c90cb9b 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -114,6 +114,19 @@ describe ApplicationSetting do
it { expect(setting.repository_storages).to eq(['default']) }
end
+ context 'circuitbreaker settings' do
+ [:circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_wait_time,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_storage_timeout].each do |field|
+ it "Validates #{field} as number" do
+ is_expected.to validate_numericality_of(field)
+ .only_integer
+ .is_greater_than_or_equal_to(0)
+ end
+ end
+ end
+
context 'repository storages' do
before do
storages = {
@@ -209,6 +222,16 @@ describe ApplicationSetting do
end
end
+ context 'restrict creating duplicates' do
+ before do
+ described_class.create_from_defaults
+ end
+
+ it 'raises an record creation violation if already created' do
+ expect { described_class.create_from_defaults }.to raise_error(ActiveRecord::RecordNotUnique)
+ end
+ end
+
context 'restricted signup domains' do
it 'sets single domain' do
setting.domain_whitelist_raw = 'example.com'
diff --git a/spec/models/blob_viewer/readme_spec.rb b/spec/models/blob_viewer/readme_spec.rb
index 926df21ffda..b9946c0315a 100644
--- a/spec/models/blob_viewer/readme_spec.rb
+++ b/spec/models/blob_viewer/readme_spec.rb
@@ -37,7 +37,7 @@ describe BlobViewer::Readme do
context 'when the wiki is not empty' do
before do
- WikiPages::CreateService.new(project, project.owner, title: 'home', content: 'Home page').execute
+ create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: 'Home page' })
end
it 'returns nil' do
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 06f76b5501e..41ecdb604f1 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1743,19 +1743,34 @@ describe Ci::Build do
end
describe 'state transition when build fails' do
+ let(:service) { MergeRequests::AddTodoWhenBuildFailsService.new(project, user) }
+
+ before do
+ allow(MergeRequests::AddTodoWhenBuildFailsService).to receive(:new).and_return(service)
+ allow(service).to receive(:close)
+ end
+
context 'when build is configured to be retried' do
- subject { create(:ci_build, :running, options: { retry: 3 }) }
+ subject { create(:ci_build, :running, options: { retry: 3 }, project: project, user: user) }
- it 'retries builds and assigns a same user to it' do
+ it 'retries build and assigns the same user to it' do
expect(described_class).to receive(:retry)
- .with(subject, subject.user)
+ .with(subject, user)
+
+ subject.drop!
+ end
+
+ it 'does not try to create a todo' do
+ project.add_developer(user)
+
+ expect(service).not_to receive(:commit_status_merge_requests)
subject.drop!
end
end
context 'when build is not configured to be retried' do
- subject { create(:ci_build, :running) }
+ subject { create(:ci_build, :running, project: project, user: user) }
it 'does not retry build' do
expect(described_class).not_to receive(:retry)
@@ -1770,6 +1785,14 @@ describe Ci::Build do
subject.drop!
end
+
+ it 'creates a todo' do
+ project.add_developer(user)
+
+ expect(service).to receive(:commit_status_merge_requests)
+
+ subject.drop!
+ end
end
end
end
diff --git a/spec/models/concerns/group_descendant_spec.rb b/spec/models/concerns/group_descendant_spec.rb
new file mode 100644
index 00000000000..c163fb01a81
--- /dev/null
+++ b/spec/models/concerns/group_descendant_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendant, :nested_groups do
+ let(:parent) { create(:group) }
+ let(:subgroup) { create(:group, parent: parent) }
+ let(:subsub_group) { create(:group, parent: subgroup) }
+
+ def all_preloaded_groups(*groups)
+ groups + [parent, subgroup, subsub_group]
+ end
+
+ context 'for a group' do
+ describe '#hierarchy' do
+ it 'only queries once for the ancestors' do
+ # make sure the subsub_group does not have anything cached
+ test_group = create(:group, parent: subsub_group).reload
+
+ query_count = ActiveRecord::QueryRecorder.new { test_group.hierarchy }.count
+
+ expect(query_count).to eq(1)
+ end
+
+ it 'only queries once for the ancestors when a top is given' do
+ test_group = create(:group, parent: subsub_group).reload
+
+ recorder = ActiveRecord::QueryRecorder.new { test_group.hierarchy(subgroup) }
+ expect(recorder.count).to eq(1)
+ end
+
+ it 'builds a hierarchy for a group' do
+ expected_hierarchy = { parent => { subgroup => subsub_group } }
+
+ expect(subsub_group.hierarchy).to eq(expected_hierarchy)
+ end
+
+ it 'builds a hierarchy upto a specified parent' do
+ expected_hierarchy = { subgroup => subsub_group }
+
+ expect(subsub_group.hierarchy(parent)).to eq(expected_hierarchy)
+ end
+
+ it 'raises an error if specifying a base that is not part of the tree' do
+ expect { subsub_group.hierarchy(build_stubbed(:group)) }
+ .to raise_error('specified top is not part of the tree')
+ end
+ end
+
+ describe '.build_hierarchy' do
+ it 'combines hierarchies until the top' do
+ other_subgroup = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: subgroup)
+
+ groups = all_preloaded_groups(other_subgroup, subsub_group, other_subsub_group)
+
+ expected_hierarchy = { parent => [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }] }
+
+ expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+ end
+
+ it 'combines upto a given parent' do
+ other_subgroup = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: subgroup)
+
+ groups = [other_subgroup, subsub_group, other_subsub_group]
+ groups << subgroup # Add the parent as if it was preloaded
+
+ expected_hierarchy = [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }]
+ expect(described_class.build_hierarchy(groups, parent)).to eq(expected_hierarchy)
+ end
+
+ it 'handles building a tree out of order' do
+ other_subgroup = create(:group, parent: parent)
+ other_subgroup2 = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: other_subgroup)
+
+ groups = all_preloaded_groups(subsub_group, other_subgroup2, other_subsub_group, other_subgroup)
+ expected_hierarchy = { parent => [{ subgroup => subsub_group }, other_subgroup2, { other_subgroup => other_subsub_group }] }
+
+ expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+ end
+
+ it 'raises an error if not all elements were preloaded' do
+ expect { described_class.build_hierarchy([subsub_group]) }
+ .to raise_error('parent was not preloaded')
+ end
+ end
+ end
+
+ context 'for a project' do
+ let(:project) { create(:project, namespace: subsub_group) }
+
+ describe '#hierarchy' do
+ it 'builds a hierarchy for a project' do
+ expected_hierarchy = { parent => { subgroup => { subsub_group => project } } }
+
+ expect(project.hierarchy).to eq(expected_hierarchy)
+ end
+
+ it 'builds a hierarchy upto a specified parent' do
+ expected_hierarchy = { subsub_group => project }
+
+ expect(project.hierarchy(subgroup)).to eq(expected_hierarchy)
+ end
+ end
+
+ describe '.build_hierarchy' do
+ it 'combines hierarchies until the top' do
+ other_project = create(:project, namespace: parent)
+ other_subgroup_project = create(:project, namespace: subgroup)
+
+ elements = all_preloaded_groups(other_project, subsub_group, other_subgroup_project)
+
+ expected_hierarchy = { parent => [other_project, { subgroup => [subsub_group, other_subgroup_project] }] }
+
+ expect(described_class.build_hierarchy(elements)).to eq(expected_hierarchy)
+ end
+
+ it 'combines upto a given parent' do
+ other_project = create(:project, namespace: parent)
+ other_subgroup_project = create(:project, namespace: subgroup)
+
+ elements = [other_project, subsub_group, other_subgroup_project]
+ elements << subgroup # Added as if it was preloaded
+
+ expected_hierarchy = [other_project, { subgroup => [subsub_group, other_subgroup_project] }]
+
+ expect(described_class.build_hierarchy(elements, parent)).to eq(expected_hierarchy)
+ end
+
+ it 'merges to elements in the same hierarchy' do
+ expected_hierarchy = { parent => subgroup }
+
+ expect(described_class.build_hierarchy([parent, subgroup])).to eq(expected_hierarchy)
+ end
+
+ it 'merges complex hierarchies' do
+ project = create(:project, namespace: parent)
+ sub_project = create(:project, namespace: subgroup)
+ subsubsub_group = create(:group, parent: subsub_group)
+ subsub_project = create(:project, namespace: subsub_group)
+ subsubsub_project = create(:project, namespace: subsubsub_group)
+ other_subgroup = create(:group, parent: parent)
+ other_subproject = create(:project, namespace: other_subgroup)
+
+ elements = [project, subsubsub_project, sub_project, other_subproject, subsub_project]
+ # Add parent groups as if they were preloaded
+ elements += [other_subgroup, subsubsub_group, subsub_group, subgroup]
+
+ expected_hierarchy = [
+ project,
+ {
+ subgroup => [
+ { subsub_group => [{ subsubsub_group => subsubsub_project }, subsub_project] },
+ sub_project
+ ]
+ },
+ { other_subgroup => other_subproject }
+ ]
+
+ actual_hierarchy = described_class.build_hierarchy(elements, parent)
+
+ expect(actual_hierarchy).to eq(expected_hierarchy)
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/loaded_in_group_list_spec.rb b/spec/models/concerns/loaded_in_group_list_spec.rb
new file mode 100644
index 00000000000..7a279547a3a
--- /dev/null
+++ b/spec/models/concerns/loaded_in_group_list_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe LoadedInGroupList do
+ let(:parent) { create(:group) }
+ subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) }
+
+ describe '.with_selects_for_list' do
+ it 'includes the preloaded counts for groups' do
+ create(:group, parent: parent)
+ create(:project, namespace: parent)
+ parent.add_developer(create(:user))
+
+ found_group = Group.with_selects_for_list.find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(1)
+ expect(found_group.preloaded_subgroup_count).to eq(1)
+ expect(found_group.preloaded_member_count).to eq(1)
+ end
+
+ context 'with archived projects' do
+ it 'counts including archived projects when `true` is passed' do
+ create(:project, namespace: parent, archived: true)
+ create(:project, namespace: parent)
+
+ found_group = Group.with_selects_for_list(archived: 'true').find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(2)
+ end
+
+ it 'counts only archived projects when `only` is passed' do
+ create_list(:project, 2, namespace: parent, archived: true)
+ create(:project, namespace: parent)
+
+ found_group = Group.with_selects_for_list(archived: 'only').find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(2)
+ end
+ end
+ end
+
+ describe '#children_count' do
+ it 'counts groups and projects' do
+ create(:group, parent: parent)
+ create(:project, namespace: parent)
+
+ expect(found_group.children_count).to eq(2)
+ end
+ end
+end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index eb0a3e9e0d3..da972d2d86a 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -105,7 +105,7 @@ describe DiffNote do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(position.file_path, position.formatter.new_line, 15)
+ line_code = Gitlab::Git.diff_line_code(position.file_path, position.formatter.new_line, 15)
expect(subject.line_code).to eq(line_code)
end
diff --git a/spec/models/gcp/cluster_spec.rb b/spec/models/gcp/cluster_spec.rb
index 350fbc257d9..8f39fff6394 100644
--- a/spec/models/gcp/cluster_spec.rb
+++ b/spec/models/gcp/cluster_spec.rb
@@ -7,6 +7,30 @@ describe Gcp::Cluster do
it { is_expected.to validate_presence_of(:gcp_cluster_zone) }
+ describe '.enabled' do
+ subject { described_class.enabled }
+
+ let!(:cluster) { create(:gcp_cluster, enabled: true) }
+
+ before do
+ create(:gcp_cluster, enabled: false)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
+ describe '.disabled' do
+ subject { described_class.disabled }
+
+ let!(:cluster) { create(:gcp_cluster, enabled: false) }
+
+ before do
+ create(:gcp_cluster, enabled: true)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
describe '#default_value_for' do
let(:cluster) { described_class.new }
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 17c9f15b021..73e038b61ed 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1460,11 +1460,31 @@ describe MergeRequest do
end
describe '#merge_ongoing?' do
- it 'returns true when merge_id is present and MR is not merged' do
+ it 'returns true when merge_id, MR is not merged and it has no running job' do
merge_request = build_stubbed(:merge_request, state: :open, merge_jid: 'foo')
+ allow(Gitlab::SidekiqStatus).to receive(:running?).with('foo') { true }
expect(merge_request.merge_ongoing?).to be(true)
end
+
+ it 'returns false when merge_jid is nil' do
+ merge_request = build_stubbed(:merge_request, state: :open, merge_jid: nil)
+
+ expect(merge_request.merge_ongoing?).to be(false)
+ end
+
+ it 'returns false if MR is merged' do
+ merge_request = build_stubbed(:merge_request, state: :merged, merge_jid: 'foo')
+
+ expect(merge_request.merge_ongoing?).to be(false)
+ end
+
+ it 'returns false if there is no merge job running' do
+ merge_request = build_stubbed(:merge_request, state: :open, merge_jid: 'foo')
+ allow(Gitlab::SidekiqStatus).to receive(:running?).with('foo') { false }
+
+ expect(merge_request.merge_ongoing?).to be(false)
+ end
end
describe "#closed_without_fork?" do
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 2ebf6acd42a..90b768f595e 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -4,6 +4,7 @@ describe Namespace do
include ProjectForksHelper
let!(:namespace) { create(:namespace) }
+ let(:gitlab_shell) { Gitlab::Shell.new }
describe 'associations' do
it { is_expected.to have_many :projects }
@@ -153,25 +154,32 @@ describe Namespace do
end
end
- describe '#move_dir' do
- let(:namespace) { create(:namespace) }
- let!(:project) { create(:project_empty_repo, namespace: namespace) }
+ describe '#ancestors_upto', :nested_groups do
+ let(:parent) { create(:group) }
+ let(:child) { create(:group, parent: parent) }
+ let(:child2) { create(:group, parent: child) }
- before do
- allow(namespace).to receive(:path_changed?).and_return(true)
+ it 'returns all ancestors when no namespace is given' do
+ expect(child2.ancestors_upto).to contain_exactly(child, parent)
+ end
+
+ it 'includes ancestors upto but excluding the given ancestor' do
+ expect(child2.ancestors_upto(parent)).to contain_exactly(child)
end
+ end
+
+ describe '#move_dir', :request_store do
+ let(:namespace) { create(:namespace) }
+ let!(:project) { create(:project_empty_repo, namespace: namespace) }
it "raises error when directory exists" do
expect { namespace.move_dir }.to raise_error("namespace directory cannot be moved")
end
it "moves dir if path changed" do
- new_path = namespace.full_path + "_new"
+ namespace.update_attributes(path: namespace.full_path + '_new')
- allow(namespace).to receive(:full_path_was).and_return(namespace.full_path)
- allow(namespace).to receive(:full_path).and_return(new_path)
- expect(namespace).to receive(:remove_exports!)
- expect(namespace.move_dir).to be_truthy
+ expect(gitlab_shell.exists?(project.repository_storage_path, "#{namespace.path}/#{project.path}.git")).to be_truthy
end
context "when any project has container images" do
diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb
index f89be20ad78..6a5d0decfec 100644
--- a/spec/models/project_services/microsoft_teams_service_spec.rb
+++ b/spec/models/project_services/microsoft_teams_service_spec.rb
@@ -108,12 +108,8 @@ describe MicrosoftTeamsService do
message: "user created page: Awesome wiki_page"
}
end
-
- let(:wiki_page_sample_data) do
- service = WikiPages::CreateService.new(project, user, opts)
- wiki_page = service.execute
- Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create')
- end
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: opts) }
+ let(:wiki_page_sample_data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') }
it "calls Microsoft Teams API" do
chat_service.execute(wiki_page_sample_data)
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index cf26dbfea49..74eba7e33f6 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1761,6 +1761,21 @@ describe Project do
it { expect(project.gitea_import?).to be true }
end
+ describe '#ancestors_upto', :nested_groups do
+ let(:parent) { create(:group) }
+ let(:child) { create(:group, parent: parent) }
+ let(:child2) { create(:group, parent: child) }
+ let(:project) { create(:project, namespace: child2) }
+
+ it 'returns all ancestors when no namespace is given' do
+ expect(project.ancestors_upto).to contain_exactly(child2, child, parent)
+ end
+
+ it 'includes ancestors upto but excluding the given ancestor' do
+ expect(project.ancestors_upto(parent)).to contain_exactly(child2, child)
+ end
+ end
+
describe '#lfs_enabled?' do
let(:project) { create(:project) }
@@ -2178,6 +2193,12 @@ describe Project do
it { expect(project.parent).to eq(project.namespace) }
end
+ describe '#parent_id' do
+ let(:project) { create(:project) }
+
+ it { expect(project.parent_id).to eq(project.namespace_id) }
+ end
+
describe '#parent_changed?' do
let(:project) { create(:project) }
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index 78fb2df884a..f10d9383ae2 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -180,37 +180,47 @@ describe ProjectWiki do
end
describe "#create_page" do
- after do
- destroy_page(subject.pages.first.page)
- end
+ shared_examples 'creating a wiki page' do
+ after do
+ destroy_page(subject.pages.first.page)
+ end
- it "creates a new wiki page" do
- expect(subject.create_page("test page", "this is content")).not_to eq(false)
- expect(subject.pages.count).to eq(1)
- end
+ it "creates a new wiki page" do
+ expect(subject.create_page("test page", "this is content")).not_to eq(false)
+ expect(subject.pages.count).to eq(1)
+ end
- it "returns false when a duplicate page exists" do
- subject.create_page("test page", "content")
- expect(subject.create_page("test page", "content")).to eq(false)
- end
+ it "returns false when a duplicate page exists" do
+ subject.create_page("test page", "content")
+ expect(subject.create_page("test page", "content")).to eq(false)
+ end
- it "stores an error message when a duplicate page exists" do
- 2.times { subject.create_page("test page", "content") }
- expect(subject.error_message).to match(/Duplicate page:/)
- end
+ it "stores an error message when a duplicate page exists" do
+ 2.times { subject.create_page("test page", "content") }
+ expect(subject.error_message).to match(/Duplicate page:/)
+ end
- it "sets the correct commit message" do
- subject.create_page("test page", "some content", :markdown, "commit message")
- expect(subject.pages.first.page.version.message).to eq("commit message")
- end
+ it "sets the correct commit message" do
+ subject.create_page("test page", "some content", :markdown, "commit message")
+ expect(subject.pages.first.page.version.message).to eq("commit message")
+ end
- it 'updates project activity' do
- subject.create_page('Test Page', 'This is content')
+ it 'updates project activity' do
+ subject.create_page('Test Page', 'This is content')
- project.reload
+ project.reload
- expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
- expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
+ expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
+ expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
+ end
+ end
+
+ context 'when Gitaly wiki_write_page is enabled' do
+ it_behaves_like 'creating a wiki page'
+ end
+
+ context 'when Gitaly wiki_write_page is disabled', :skip_gitaly_mock do
+ it_behaves_like 'creating a wiki page'
end
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 5d78aed5b4f..39d188f18af 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -1286,21 +1286,31 @@ describe Repository do
let(:message) { 'Test \r\n\r\n message' }
- it 'merges the code and returns the commit id' do
- expect(merge_commit).to be_present
- expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present
- end
+ shared_examples '#merge' do
+ it 'merges the code and returns the commit id' do
+ expect(merge_commit).to be_present
+ expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present
+ end
- it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do
- merge_commit_id = merge(repository, user, merge_request, message)
+ it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do
+ merge_commit_id = merge(repository, user, merge_request, message)
- expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id)
+ expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id)
+ end
+
+ it 'removes carriage returns from commit message' do
+ merge_commit_id = merge(repository, user, merge_request, message)
+
+ expect(repository.commit(merge_commit_id).message).to eq(message.delete("\r"))
+ end
end
- it 'removes carriage returns from commit message' do
- merge_commit_id = merge(repository, user, merge_request, message)
+ context 'with gitaly' do
+ it_behaves_like '#merge'
+ end
- expect(repository.commit(merge_commit_id).message).to eq(message.delete("\r"))
+ context 'without gitaly', :skip_gitaly_mock do
+ it_behaves_like '#merge'
end
def merge(repository, user, merge_request, message)
@@ -1509,7 +1519,9 @@ describe Repository do
:gitignore,
:koding,
:gitlab_ci,
- :avatar
+ :avatar,
+ :issue_template,
+ :merge_request_template
])
repository.after_change_head
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 16b12446ed4..e433597f58b 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -110,6 +110,15 @@ describe API::Branches do
end
end
+ context 'when the branch refname is invalid' do
+ let(:branch_name) { 'branch*' }
+ let(:message) { 'The branch refname is invalid' }
+
+ it_behaves_like '400 response' do
+ let(:request) { get api(route, current_user) }
+ end
+ end
+
context 'when repository is disabled' do
include_context 'disabled repository'
@@ -234,6 +243,15 @@ describe API::Branches do
end
end
+ context 'when the branch refname is invalid' do
+ let(:branch_name) { 'branch*' }
+ let(:message) { 'The branch refname is invalid' }
+
+ it_behaves_like '400 response' do
+ let(:request) { put api(route, current_user) }
+ end
+ end
+
context 'when repository is disabled' do
include_context 'disabled repository'
@@ -359,6 +377,15 @@ describe API::Branches do
end
end
+ context 'when the branch refname is invalid' do
+ let(:branch_name) { 'branch*' }
+ let(:message) { 'The branch refname is invalid' }
+
+ it_behaves_like '400 response' do
+ let(:request) { put api(route, current_user) }
+ end
+ end
+
context 'when repository is disabled' do
include_context 'disabled repository'
@@ -520,6 +547,15 @@ describe API::Branches do
expect(response).to have_gitlab_http_status(404)
end
+ context 'when the branch refname is invalid' do
+ let(:branch_name) { 'branch*' }
+ let(:message) { 'The branch refname is invalid' }
+
+ it_behaves_like '400 response' do
+ let(:request) { delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user) }
+ end
+ end
+
it_behaves_like '412 response' do
let(:request) { api("/projects/#{project.id}/repository/branches/#{branch_name}", user) }
end
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 862920ad7c3..9f3b5a809d7 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -222,13 +222,6 @@ describe API::Helpers do
expect { current_user }.to raise_error /401/
end
- it "returns a 401 response for a token without the appropriate scope" do
- personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user'])
- env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token
-
- expect { current_user }.to raise_error /401/
- end
-
it "leaves user as is when sudo not specified" do
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect(current_user).to eq(user)
@@ -238,18 +231,25 @@ describe API::Helpers do
expect(current_user).to eq(user)
end
+ it "does not allow tokens without the appropriate scope" do
+ personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user'])
+ env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token
+
+ expect { current_user }.to raise_error API::APIGuard::InsufficientScopeError
+ end
+
it 'does not allow revoked tokens' do
personal_access_token.revoke!
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token
- expect { current_user }.to raise_error /401/
+ expect { current_user }.to raise_error API::APIGuard::RevokedError
end
it 'does not allow expired tokens' do
personal_access_token.update_attributes!(expires_at: 1.day.ago)
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token
- expect { current_user }.to raise_error /401/
+ expect { current_user }.to raise_error API::APIGuard::ExpiredError
end
end
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 0b9a4b5c3db..c24de58ee9d 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -23,6 +23,7 @@ describe API::Settings, 'Settings' do
expect(json_response['dsa_key_restriction']).to eq(0)
expect(json_response['ecdsa_key_restriction']).to eq(0)
expect(json_response['ed25519_key_restriction']).to eq(0)
+ expect(json_response['circuitbreaker_failure_count_threshold']).not_to be_nil
end
end
@@ -52,7 +53,8 @@ describe API::Settings, 'Settings' do
rsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE,
dsa_key_restriction: 2048,
ecdsa_key_restriction: 384,
- ed25519_key_restriction: 256
+ ed25519_key_restriction: 256,
+ circuitbreaker_failure_wait_time: 2
expect(response).to have_http_status(200)
expect(json_response['default_projects_limit']).to eq(3)
@@ -73,6 +75,7 @@ describe API::Settings, 'Settings' do
expect(json_response['dsa_key_restriction']).to eq(2048)
expect(json_response['ecdsa_key_restriction']).to eq(384)
expect(json_response['ed25519_key_restriction']).to eq(256)
+ expect(json_response['circuitbreaker_failure_wait_time']).to eq(2)
end
end
diff --git a/spec/requests/api/v3/repositories_spec.rb b/spec/requests/api/v3/repositories_spec.rb
index 1a55e2a71cd..67624a0bbea 100644
--- a/spec/requests/api/v3/repositories_spec.rb
+++ b/spec/requests/api/v3/repositories_spec.rb
@@ -97,10 +97,11 @@ describe API::V3::Repositories do
end
end
- {
- 'blobs/:sha' => 'blobs/master',
- 'commits/:sha/blob' => 'commits/master/blob'
- }.each do |desc_path, example_path|
+ [
+ ['blobs/:sha', 'blobs/master'],
+ ['blobs/:sha', 'blobs/v1.1.0'],
+ ['commits/:sha/blob', 'commits/master/blob']
+ ].each do |desc_path, example_path|
describe "GET /projects/:id/repository/#{desc_path}" do
let(:route) { "/projects/#{project.id}/repository/#{example_path}?filepath=README.md" }
shared_examples_for 'repository blob' do
@@ -110,7 +111,7 @@ describe API::V3::Repositories do
end
context 'when sha does not exist' do
it_behaves_like '404 response' do
- let(:request) { get v3_api(route.sub('master', 'invalid_branch_name'), current_user) }
+ let(:request) { get v3_api("/projects/#{project.id}/repository/#{desc_path.sub(':sha', 'invalid_branch_name')}?filepath=README.md", current_user) }
let(:message) { '404 Commit Not Found' }
end
end
diff --git a/spec/rubocop/cop/rspec/env_assignment_spec.rb b/spec/rubocop/cop/rspec/env_assignment_spec.rb
new file mode 100644
index 00000000000..4e859b6f6fa
--- /dev/null
+++ b/spec/rubocop/cop/rspec/env_assignment_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/rspec/env_assignment'
+
+describe RuboCop::Cop::RSpec::EnvAssignment do
+ include CopHelper
+
+ OFFENSE_CALL_SINGLE_QUOTES_KEY = %(ENV['FOO'] = 'bar').freeze
+ OFFENSE_CALL_DOUBLE_QUOTES_KEY = %(ENV["FOO"] = 'bar').freeze
+
+ let(:source_file) { 'spec/foo_spec.rb' }
+
+ subject(:cop) { described_class.new }
+
+ shared_examples 'an offensive ENV#[]= call' do |content|
+ it "registers an offense for `#{content}`" do
+ inspect_source(cop, content, source_file)
+
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ expect(cop.highlights).to eq([content])
+ end
+ end
+
+ shared_examples 'an autocorrected ENV#[]= call' do |content, autocorrected_content|
+ it "registers an offense for `#{content}` and autocorrects it to `#{autocorrected_content}`" do
+ autocorrected = autocorrect_source(cop, content, source_file)
+
+ expect(autocorrected).to eql(autocorrected_content)
+ end
+ end
+
+ context 'in a spec file' do
+ before do
+ allow(cop).to receive(:in_spec?).and_return(true)
+ end
+
+ context 'with a key using single quotes' do
+ it_behaves_like 'an offensive ENV#[]= call', OFFENSE_CALL_SINGLE_QUOTES_KEY
+ it_behaves_like 'an autocorrected ENV#[]= call', OFFENSE_CALL_SINGLE_QUOTES_KEY, %(stub_env('FOO', 'bar'))
+ end
+
+ context 'with a key using double quotes' do
+ it_behaves_like 'an offensive ENV#[]= call', OFFENSE_CALL_DOUBLE_QUOTES_KEY
+ it_behaves_like 'an autocorrected ENV#[]= call', OFFENSE_CALL_DOUBLE_QUOTES_KEY, %(stub_env("FOO", 'bar'))
+ end
+ end
+
+ context 'outside of a spec file' do
+ it "does not register an offense for `#{OFFENSE_CALL_SINGLE_QUOTES_KEY}` in a non-spec file" do
+ inspect_source(cop, OFFENSE_CALL_SINGLE_QUOTES_KEY)
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/serializers/container_tag_entity_spec.rb b/spec/serializers/container_tag_entity_spec.rb
index 6dcc5204516..4beb50c70f8 100644
--- a/spec/serializers/container_tag_entity_spec.rb
+++ b/spec/serializers/container_tag_entity_spec.rb
@@ -22,7 +22,7 @@ describe ContainerTagEntity do
end
it 'exposes required informations' do
- expect(subject).to include(:name, :location, :revision, :total_size, :created_at)
+ expect(subject).to include(:name, :location, :revision, :short_revision, :total_size, :created_at)
end
context 'when user can manage repositories' do
diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb
new file mode 100644
index 00000000000..452754d7a79
--- /dev/null
+++ b/spec/serializers/group_child_entity_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe GroupChildEntity do
+ include Gitlab::Routing.url_helpers
+
+ let(:user) { create(:user) }
+ let(:request) { double('request') }
+ let(:entity) { described_class.new(object, request: request) }
+ subject(:json) { entity.as_json }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ shared_examples 'group child json' do
+ it 'renders json' do
+ is_expected.not_to be_nil
+ end
+
+ %w[id
+ full_name
+ avatar_url
+ name
+ description
+ visibility
+ type
+ can_edit
+ visibility
+ permission
+ relative_path].each do |attribute|
+ it "includes #{attribute}" do
+ expect(json[attribute.to_sym]).to be_present
+ end
+ end
+ end
+
+ describe 'for a project' do
+ let(:object) do
+ create(:project, :with_avatar,
+ description: 'Awesomeness')
+ end
+
+ before do
+ object.add_master(user)
+ end
+
+ it 'has the correct type' do
+ expect(json[:type]).to eq('project')
+ end
+
+ it 'includes the star count' do
+ expect(json[:star_count]).to be_present
+ end
+
+ it 'has the correct edit path' do
+ expect(json[:edit_path]).to eq(edit_project_path(object))
+ end
+
+ it_behaves_like 'group child json'
+ end
+
+ describe 'for a group', :nested_groups do
+ let(:object) do
+ create(:group, :nested, :with_avatar,
+ description: 'Awesomeness')
+ end
+
+ before do
+ object.add_owner(user)
+ end
+
+ it 'has the correct type' do
+ expect(json[:type]).to eq('group')
+ end
+
+ it 'counts projects and subgroups as children' do
+ create(:project, namespace: object)
+ create(:group, parent: object)
+
+ expect(json[:children_count]).to eq(2)
+ end
+
+ %w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute|
+ it "includes #{attribute}" do
+ expect(json[attribute.to_sym]).to be_present
+ end
+ end
+
+ it 'allows an owner to leave when there is another one' do
+ object.add_owner(create(:user))
+
+ expect(json[:can_leave]).to be_truthy
+ end
+
+ it 'has the correct edit path' do
+ expect(json[:edit_path]).to eq(edit_group_path(object))
+ end
+
+ it_behaves_like 'group child json'
+ end
+end
diff --git a/spec/serializers/group_child_serializer_spec.rb b/spec/serializers/group_child_serializer_spec.rb
new file mode 100644
index 00000000000..5541ada3750
--- /dev/null
+++ b/spec/serializers/group_child_serializer_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe GroupChildSerializer do
+ let(:request) { double('request') }
+ let(:user) { create(:user) }
+ subject(:serializer) { described_class.new(current_user: user) }
+
+ describe '#represent' do
+ context 'for groups' do
+ it 'can render a single group' do
+ expect(serializer.represent(build(:group))).to be_kind_of(Hash)
+ end
+
+ it 'can render a collection of groups' do
+ expect(serializer.represent(build_list(:group, 2))).to be_kind_of(Array)
+ end
+ end
+
+ context 'with a hierarchy', :nested_groups do
+ let(:parent) { create(:group) }
+
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy(parent)
+ end
+
+ it 'expands the subgroups' do
+ subgroup = create(:group, parent: parent)
+ subsub_group = create(:group, parent: subgroup)
+
+ json = serializer.represent([subgroup, subsub_group]).first
+ subsub_group_json = json[:children].first
+
+ expect(json[:id]).to eq(subgroup.id)
+ expect(subsub_group_json).not_to be_nil
+ expect(subsub_group_json[:id]).to eq(subsub_group.id)
+ end
+
+ it 'can render a nested tree' do
+ subgroup1 = create(:group, parent: parent)
+ subsub_group1 = create(:group, parent: subgroup1)
+ subgroup2 = create(:group, parent: parent)
+
+ json = serializer.represent([subgroup1, subsub_group1, subgroup1, subgroup2])
+ subgroup1_json = json.first
+ subsub_group1_json = subgroup1_json[:children].first
+
+ expect(json.size).to eq(2)
+ expect(subgroup1_json[:id]).to eq(subgroup1.id)
+ expect(subsub_group1_json[:id]).to eq(subsub_group1.id)
+ end
+
+ context 'without a specified parent' do
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy
+ end
+
+ it 'can render a tree' do
+ subgroup = create(:group, parent: parent)
+
+ json = serializer.represent([parent, subgroup])
+ parent_json = json.first
+
+ expect(parent_json[:id]).to eq(parent.id)
+ expect(parent_json[:children].first[:id]).to eq(subgroup.id)
+ end
+ end
+ end
+
+ context 'for projects' do
+ it 'can render a single project' do
+ expect(serializer.represent(build(:project))).to be_kind_of(Hash)
+ end
+
+ it 'can render a collection of projects' do
+ expect(serializer.represent(build_list(:project, 2))).to be_kind_of(Array)
+ end
+
+ context 'with a hierarchy', :nested_groups do
+ let(:parent) { create(:group) }
+
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy(parent)
+ end
+
+ it 'can render a nested tree' do
+ subgroup1 = create(:group, parent: parent)
+ project1 = create(:project, namespace: subgroup1)
+ subgroup2 = create(:group, parent: parent)
+ project2 = create(:project, namespace: subgroup2)
+
+ json = serializer.represent([project1, project2, subgroup1, subgroup2])
+ project1_json, project2_json = json.map { |group_json| group_json[:children].first }
+
+ expect(json.size).to eq(2)
+ expect(project1_json[:id]).to eq(project1.id)
+ expect(project2_json[:id]).to eq(project2.id)
+ end
+
+ it 'returns an array when an array of a single instance was given' do
+ project = create(:project, namespace: parent)
+
+ json = serializer.represent([project])
+
+ expect(json).to be_kind_of(Array)
+ expect(json.size).to eq(1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
index 1c2d0b3e0dc..9128280eb5a 100644
--- a/spec/services/auth/container_registry_authentication_service_spec.rb
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -43,6 +43,21 @@ describe Auth::ContainerRegistryAuthenticationService do
end
end
+ shared_examples 'a browsable' do
+ let(:access) do
+ [{ 'type' => 'registry',
+ 'name' => 'catalog',
+ 'actions' => ['*'] }]
+ end
+
+ it_behaves_like 'a valid token'
+ it_behaves_like 'not a container repository factory'
+
+ it 'has the correct scope' do
+ expect(payload).to include('access' => access)
+ end
+ end
+
shared_examples 'an accessible' do
let(:access) do
[{ 'type' => 'repository',
@@ -51,7 +66,10 @@ describe Auth::ContainerRegistryAuthenticationService do
end
it_behaves_like 'a valid token'
- it { expect(payload).to include('access' => access) }
+
+ it 'has the correct scope' do
+ expect(payload).to include('access' => access)
+ end
end
shared_examples 'an inaccessible' do
@@ -117,6 +135,17 @@ describe Auth::ContainerRegistryAuthenticationService do
context 'user authorization' do
let(:current_user) { create(:user) }
+ context 'for registry catalog' do
+ let(:current_params) do
+ { scope: "registry:catalog:*" }
+ end
+
+ context 'disallow browsing for users without Gitlab admin rights' do
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
+ end
+ end
+
context 'for private project' do
let(:project) { create(:project) }
@@ -490,6 +519,16 @@ describe Auth::ContainerRegistryAuthenticationService do
end
end
+ context 'registry catalog browsing authorized as admin' do
+ let(:current_user) { create(:user, :admin) }
+
+ let(:current_params) do
+ { scope: "registry:catalog:*" }
+ end
+
+ it_behaves_like 'a browsable'
+ end
+
context 'unauthorized' do
context 'disallow to use scope-less authentication' do
it_behaves_like 'a forbidden'
@@ -536,5 +575,14 @@ describe Auth::ContainerRegistryAuthenticationService do
it_behaves_like 'not a container repository factory'
end
end
+
+ context 'for registry catalog' do
+ let(:current_params) do
+ { scope: "registry:catalog:*" }
+ end
+
+ it_behaves_like 'a forbidden'
+ it_behaves_like 'not a container repository factory'
+ end
end
end
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 9db3568abee..b61d1cb765e 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -160,8 +160,9 @@ describe Ci::RetryBuildService do
expect(new_build).to be_created
end
- it 'does mark old build as retried' do
+ it 'does mark old build as retried in the database and on the instance' do
expect(new_build).to be_latest
+ expect(build).to be_retried
expect(build.reload).to be_retried
end
end
diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb
index 23982b9e6e1..0b32c51a16f 100644
--- a/spec/services/merge_requests/conflicts/list_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/list_service_spec.rb
@@ -35,7 +35,7 @@ describe MergeRequests::Conflicts::ListService do
it 'returns a falsey value when the MR has a missing ref after a force push' do
merge_request = create_merge_request('conflict-resolvable')
service = conflicts_service(merge_request)
- allow(service.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError)
+ allow_any_instance_of(Rugged::Repository).to receive(:merge_commits).and_raise(Rugged::OdbError)
expect(service.can_be_resolved_in_ui?).to be_falsey
end
diff --git a/spec/services/merge_requests/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
index 9c9b0c4c4a1..5376083e7f5 100644
--- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
@@ -6,11 +6,7 @@ describe MergeRequests::Conflicts::ResolveService do
let(:project) { create(:project, :public, :repository) }
let(:forked_project) do
- forked_project = fork_project(project, user)
- TestEnv.copy_repo(forked_project,
- bare_repo: TestEnv.forked_repo_path_bare,
- refs: TestEnv::FORKED_BRANCH_SHA)
- forked_project
+ fork_project_with_submodules(project, user)
end
let(:merge_request) do
@@ -111,25 +107,27 @@ describe MergeRequests::Conflicts::ResolveService do
branch_name: 'conflict-start')
end
- def resolve_conflicts
+ subject do
described_class.new(merge_request_from_fork).execute(user, params)
end
it 'gets conflicts from the source project' do
+ # REFACTOR NOTE: We used to test that `project.repository.rugged` wasn't
+ # used in this case, but since the refactor, for simplification,
+ # we always use that repository for read only operations.
expect(forked_project.repository.rugged).to receive(:merge_commits).and_call_original
- expect(project.repository.rugged).not_to receive(:merge_commits)
- resolve_conflicts
+ subject
end
it 'creates a commit with the message' do
- resolve_conflicts
+ subject
expect(merge_request_from_fork.source_branch_head.message).to eq(params[:commit_message])
end
it 'creates a commit with the correct parents' do
- resolve_conflicts
+ subject
expect(merge_request_from_fork.source_branch_head.parents.map(&:id))
.to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813', target_head])
@@ -204,14 +202,19 @@ describe MergeRequests::Conflicts::ResolveService do
}
end
- it 'raises a MissingResolution error' do
+ it 'raises a ResolutionError error' do
expect { service.execute(user, invalid_params) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
context 'when the content of a file is unchanged' do
- let(:list_service) { MergeRequests::Conflicts::ListService.new(merge_request) }
+ let(:resolver) do
+ MergeRequests::Conflicts::ListService.new(merge_request).conflicts.resolver
+ end
+ let(:regex_conflict) do
+ resolver.conflict_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb')
+ end
let(:invalid_params) do
{
@@ -223,16 +226,16 @@ describe MergeRequests::Conflicts::ResolveService do
}, {
old_path: 'files/ruby/regex.rb',
new_path: 'files/ruby/regex.rb',
- content: list_service.conflicts.file_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb').content
+ content: regex_conflict.content
}
],
commit_message: 'This is a commit message!'
}
end
- it 'raises a MissingResolution error' do
+ it 'raises a ResolutionError error' do
expect { service.execute(user, invalid_params) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
@@ -250,9 +253,9 @@ describe MergeRequests::Conflicts::ResolveService do
}
end
- it 'raises a MissingFiles error' do
+ it 'raises a ResolutionError error' do
expect { service.execute(user, invalid_params) }
- .to raise_error(described_class::MissingFiles)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
end
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index 80213d093f1..d1043f99b5a 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -185,7 +185,7 @@ describe MergeRequests::MergeService do
context 'source branch removal' do
context 'when the source branch is protected' do
let(:service) do
- described_class.new(project, user, should_remove_source_branch: '1')
+ described_class.new(project, user, 'should_remove_source_branch' => true)
end
before do
@@ -200,7 +200,7 @@ describe MergeRequests::MergeService do
context 'when the source branch is the default branch' do
let(:service) do
- described_class.new(project, user, should_remove_source_branch: '1')
+ described_class.new(project, user, 'should_remove_source_branch' => true)
end
before do
@@ -215,10 +215,10 @@ describe MergeRequests::MergeService do
context 'when the source branch can be removed' do
context 'when MR author set the source branch to be removed' do
- let(:service) do
- merge_request.merge_params['force_remove_source_branch'] = '1'
- merge_request.save!
- described_class.new(project, user, commit_message: 'Awesome message')
+ let(:service) { described_class.new(project, user, commit_message: 'Awesome message') }
+
+ before do
+ merge_request.update_attribute(:merge_params, { 'force_remove_source_branch' => '1' })
end
it 'removes the source branch using the author user' do
@@ -227,11 +227,20 @@ describe MergeRequests::MergeService do
.and_call_original
service.execute(merge_request)
end
+
+ context 'when the merger set the source branch not to be removed' do
+ let(:service) { described_class.new(project, user, commit_message: 'Awesome message', 'should_remove_source_branch' => false) }
+
+ it 'does not delete the source branch' do
+ expect(DeleteBranchService).not_to receive(:new)
+ service.execute(merge_request)
+ end
+ end
end
context 'when MR merger set the source branch to be removed' do
let(:service) do
- described_class.new(project, user, commit_message: 'Awesome message', should_remove_source_branch: '1')
+ described_class.new(project, user, commit_message: 'Awesome message', 'should_remove_source_branch' => true)
end
it 'removes the source branch using the current user' do
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index b64ca5be8fc..b13e12e7c94 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -731,6 +731,18 @@ describe NotificationService, :mailer do
should_not_email(@u_participating)
end
+ it "doesn't send multiple email when a user is subscribed to multiple given labels" do
+ subscriber_to_both = create(:user) do |user|
+ [label_1, label_2].each { |label| label.toggle_subscription(user, project) }
+ end
+
+ notification.relabeled_issue(issue, [label_1, label_2], @u_disabled)
+
+ should_email(subscriber_to_label_1)
+ should_email(subscriber_to_label_2)
+ should_email(subscriber_to_both)
+ end
+
context 'confidential issues' do
let(:author) { create(:user) }
let(:assignee) { create(:user) }
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index c90bad46295..0bec2054f50 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe Projects::DestroyService do
+ include ProjectForksHelper
+
let!(:user) { create(:user) }
let!(:project) { create(:project, :repository, namespace: user.namespace) }
let!(:path) { project.repository.path_to_repo }
@@ -212,6 +214,21 @@ describe Projects::DestroyService do
end
end
+ context 'for a forked project with LFS objects' do
+ let(:forked_project) { fork_project(project, user) }
+
+ before do
+ project.lfs_objects << create(:lfs_object)
+ forked_project.forked_project_link.destroy
+ forked_project.reload
+ end
+
+ it 'destroys the fork' do
+ expect { destroy_project(forked_project, user) }
+ .not_to raise_error
+ end
+ end
+
context 'as the root of a fork network' do
let!(:fork_network) { create(:fork_network, root_project: project) }
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 031366d1825..d4ac1f6ad81 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -52,6 +52,11 @@ describe Projects::UpdatePagesService do
expect(project.pages_deployed?).to be_falsey
expect(execute).to eq(:success)
expect(project.pages_deployed?).to be_truthy
+
+ # Check that all expected files are extracted
+ %w[index.html zero .hidden/file].each do |filename|
+ expect(File.exist?(File.join(project.public_pages_path, filename))).to be_truthy
+ end
end
it 'limits pages size' do
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 6926ac85de3..c35177f6ebc 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -207,7 +207,11 @@ describe QuickActions::InterpretService do
it 'populates spend_time: 3600 if content contains /spend 1h' do
_, updates = service.execute(content, issuable)
- expect(updates).to eq(spend_time: { duration: 3600, user: developer })
+ expect(updates).to eq(spend_time: {
+ duration: 3600,
+ user: developer,
+ spent_at: DateTime.now.to_date
+ })
end
end
@@ -215,7 +219,39 @@ describe QuickActions::InterpretService do
it 'populates spend_time: -1800 if content contains /spend -30m' do
_, updates = service.execute(content, issuable)
- expect(updates).to eq(spend_time: { duration: -1800, user: developer })
+ expect(updates).to eq(spend_time: {
+ duration: -1800,
+ user: developer,
+ spent_at: DateTime.now.to_date
+ })
+ end
+ end
+
+ shared_examples 'spend command with valid date' do
+ it 'populates spend time: 1800 with date in date type format' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(spend_time: {
+ duration: 1800,
+ user: developer,
+ spent_at: Date.parse(date)
+ })
+ end
+ end
+
+ shared_examples 'spend command with invalid date' do
+ it 'will not create any note and timelog' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq({})
+ end
+ end
+
+ shared_examples 'spend command with future date' do
+ it 'will not create any note and timelog' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq({})
end
end
@@ -669,6 +705,22 @@ describe QuickActions::InterpretService do
let(:issuable) { issue }
end
+ it_behaves_like 'spend command with valid date' do
+ let(:date) { '2016-02-02' }
+ let(:content) { "/spend 30m #{date}" }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'spend command with invalid date' do
+ let(:content) { '/spend 30m 17-99-99' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'spend command with future date' do
+ let(:content) { '/spend 30m 6017-10-10' }
+ let(:issuable) { issue }
+ end
+
it_behaves_like 'empty command' do
let(:content) { '/spend' }
let(:issuable) { issue }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index cd473c1f388..0a6ab455abe 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -502,20 +502,6 @@ describe SystemNoteService do
end
end
- describe '.cross_reference?' do
- it 'is truthy when text begins with expected text' do
- expect(described_class.cross_reference?('mentioned in something')).to be_truthy
- end
-
- it 'is truthy when text begins with legacy capitalized expected text' do
- expect(described_class.cross_reference?('mentioned in something')).to be_truthy
- end
-
- it 'is falsey when text does not begin with expected text' do
- expect(described_class.cross_reference?('this is a note')).to be_falsey
- end
- end
-
describe '.cross_reference_disallowed?' do
context 'when mentioner is not a MergeRequest' do
it 'is falsey' do
diff --git a/spec/support/api/scopes/read_user_shared_examples.rb b/spec/support/api/scopes/read_user_shared_examples.rb
index 57e28e040d7..111534f2f26 100644
--- a/spec/support/api/scopes/read_user_shared_examples.rb
+++ b/spec/support/api/scopes/read_user_shared_examples.rb
@@ -27,10 +27,10 @@ shared_examples_for 'allows the "read_user" scope' do
stub_container_registry_config(enabled: true)
end
- it 'returns a "401" response' do
+ it 'returns a "403" response' do
get api_call.call(path, user, personal_access_token: token)
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(403)
end
end
end
@@ -74,10 +74,10 @@ shared_examples_for 'does not allow the "read_user" scope' do
context 'when the requesting token has the "read_user" scope' do
let(:token) { create(:personal_access_token, scopes: ['read_user'], user: user) }
- it 'returns a "401" response' do
+ it 'returns a "403" response' do
post api_call.call(path, user, personal_access_token: token), attributes_for(:user, projects_limit: 3)
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(403)
end
end
end
diff --git a/spec/support/board_helpers.rb b/spec/support/board_helpers.rb
new file mode 100644
index 00000000000..507d0432d7f
--- /dev/null
+++ b/spec/support/board_helpers.rb
@@ -0,0 +1,16 @@
+module BoardHelpers
+ def click_card(card)
+ within card do
+ first('.card-number').click
+ end
+
+ wait_for_sidebar
+ end
+
+ def wait_for_sidebar
+ # loop until the CSS transition is complete
+ Timeout.timeout(0.5) do
+ loop until evaluate_script('$(".right-sidebar").outerWidth()') == 290
+ end
+ end
+end
diff --git a/spec/support/email_helpers.rb b/spec/support/email_helpers.rb
index 3e979f2f470..b39052923dd 100644
--- a/spec/support/email_helpers.rb
+++ b/spec/support/email_helpers.rb
@@ -1,6 +1,6 @@
module EmailHelpers
- def sent_to_user?(user, recipients = email_recipients)
- recipients.include?(user.notification_email)
+ def sent_to_user(user, recipients: email_recipients)
+ recipients.count { |to| to == user.notification_email }
end
def reset_delivered_emails!
@@ -10,17 +10,17 @@ module EmailHelpers
def should_only_email(*users, kind: :to)
recipients = email_recipients(kind: kind)
- users.each { |user| should_email(user, recipients) }
+ users.each { |user| should_email(user, recipients: recipients) }
expect(recipients.count).to eq(users.count)
end
- def should_email(user, recipients = email_recipients)
- expect(sent_to_user?(user, recipients)).to be_truthy
+ def should_email(user, times: 1, recipients: email_recipients)
+ expect(sent_to_user(user, recipients: recipients)).to eq(times)
end
- def should_not_email(user, recipients = email_recipients)
- expect(sent_to_user?(user, recipients)).to be_falsey
+ def should_not_email(user, recipients: email_recipients)
+ should_email(user, times: 0, recipients: recipients)
end
def should_not_email_anyone
diff --git a/spec/support/ldap_helpers.rb b/spec/support/ldap_helpers.rb
index 079f244475c..28d39a32f02 100644
--- a/spec/support/ldap_helpers.rb
+++ b/spec/support/ldap_helpers.rb
@@ -15,10 +15,7 @@ module LdapHelpers
# admin_group: 'my-admin-group'
# )
def stub_ldap_config(messages)
- messages.each do |config, value|
- allow_any_instance_of(::Gitlab::LDAP::Config)
- .to receive(config.to_sym).and_return(value)
- end
+ allow_any_instance_of(::Gitlab::LDAP::Config).to receive_messages(messages)
end
# Stub an LDAP person search and provide the return entry. Specify `nil` for
diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb
index 3e117530151..4aed40bf22d 100644
--- a/spec/support/login_helpers.rb
+++ b/spec/support/login_helpers.rb
@@ -120,4 +120,16 @@ module LoginHelpers
allow_any_instance_of(Object).to receive(:user_saml_omniauth_authorize_path).and_return('/users/auth/saml')
allow_any_instance_of(Object).to receive(:omniauth_authorize_path).with(:user, "saml").and_return('/users/auth/saml')
end
+
+ def stub_omniauth_config(messages)
+ allow(Gitlab.config.omniauth).to receive_messages(messages)
+ end
+
+ def stub_basic_saml_config
+ allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', args: {} } })
+ end
+
+ def stub_saml_group_config(groups)
+ allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', groups_attribute: 'groups', external_groups: groups, args: {} } })
+ end
end
diff --git a/spec/support/project_forks_helper.rb b/spec/support/project_forks_helper.rb
index 0d1c6792d13..d6680735aa1 100644
--- a/spec/support/project_forks_helper.rb
+++ b/spec/support/project_forks_helper.rb
@@ -52,7 +52,7 @@ module ProjectForksHelper
TestEnv.copy_repo(forked_project,
bare_repo: TestEnv.forked_repo_path_bare,
refs: TestEnv::FORKED_BRANCH_SHA)
-
+ forked_project.repository.after_import
forked_project
end
end
diff --git a/spec/support/redis_without_keys.rb b/spec/support/redis_without_keys.rb
new file mode 100644
index 00000000000..6220167dee6
--- /dev/null
+++ b/spec/support/redis_without_keys.rb
@@ -0,0 +1,8 @@
+class Redis
+ ForbiddenCommand = Class.new(StandardError)
+
+ def keys(*args)
+ raise ForbiddenCommand.new("Don't use `Redis#keys` as it iterates over all "\
+ "keys in redis. Use `Redis#scan_each` instead.")
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/status_shared_examples.rb b/spec/support/shared_examples/requests/api/status_shared_examples.rb
index 7d7f66adeab..0ed917e448a 100644
--- a/spec/support/shared_examples/requests/api/status_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/status_shared_examples.rb
@@ -3,6 +3,8 @@
# Requires an API request:
# let(:request) { get api("/projects/#{project.id}/repository/branches", user) }
shared_examples_for '400 response' do
+ let(:message) { nil }
+
before do
# Fires the request
request
@@ -10,6 +12,10 @@ shared_examples_for '400 response' do
it 'returns 400' do
expect(response).to have_gitlab_http_status(400)
+
+ if message.present?
+ expect(json_response['message']).to eq(message)
+ end
end
end
@@ -26,6 +32,7 @@ end
shared_examples_for '404 response' do
let(:message) { nil }
+
before do
# Fires the request
request
diff --git a/spec/support/slack_mattermost_notifications_shared_examples.rb b/spec/support/slack_mattermost_notifications_shared_examples.rb
index 6accf16bea4..17f3a861ba8 100644
--- a/spec/support/slack_mattermost_notifications_shared_examples.rb
+++ b/spec/support/slack_mattermost_notifications_shared_examples.rb
@@ -76,8 +76,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do
message: "user created page: Awesome wiki_page"
}
- wiki_page_service = WikiPages::CreateService.new(project, user, opts)
- @wiki_page = wiki_page_service.execute
+ @wiki_page = create(:wiki_page, wiki: project.wiki, attrs: opts)
@wiki_page_sample_data = Gitlab::DataBuilder::WikiPage.build(@wiki_page, user, 'create')
end
diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb
index 2dfb4d4a07f..4d448a55978 100644
--- a/spec/support/stub_configuration.rb
+++ b/spec/support/stub_configuration.rb
@@ -43,10 +43,6 @@ module StubConfiguration
messages['default'] ||= Gitlab.config.repositories.storages.default
messages.each do |storage_name, storage_settings|
storage_settings['path'] = TestEnv.repos_path unless storage_settings.key?('path')
- storage_settings['failure_count_threshold'] ||= 10
- storage_settings['failure_wait_time'] ||= 30
- storage_settings['failure_reset_time'] ||= 1800
- storage_settings['storage_timeout'] ||= 5
end
allow(Gitlab.config.repositories).to receive(:storages).and_return(Settingslogic.new(messages))
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index 886052d7848..bf2e11bc360 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -4,7 +4,15 @@ require 'rake'
describe 'gitlab:app namespace rake task' do
let(:enable_registry) { true }
- before :all do
+ def tars_glob
+ Dir.glob(File.join(Gitlab.config.backup.path, '*_gitlab_backup.tar'))
+ end
+
+ def backup_tar
+ tars_glob.first
+ end
+
+ before(:all) do
Rake.application.rake_require 'tasks/gitlab/helpers'
Rake.application.rake_require 'tasks/gitlab/backup'
Rake.application.rake_require 'tasks/gitlab/shell'
@@ -19,9 +27,16 @@ describe 'gitlab:app namespace rake task' do
end
before do
+ stub_env('force', 'yes')
+ FileUtils.rm(tars_glob, force: true)
+ reenable_backup_sub_tasks
stub_container_registry_config(enabled: enable_registry)
end
+ after do
+ FileUtils.rm(tars_glob, force: true)
+ end
+
def run_rake_task(task_name)
Rake::Task[task_name].reenable
Rake.application.invoke_task task_name
@@ -34,22 +49,15 @@ describe 'gitlab:app namespace rake task' do
end
describe 'backup_restore' do
- before do
- # avoid writing task output to spec progress
- allow($stdout).to receive :write
- end
-
context 'gitlab version' do
before do
allow(Dir).to receive(:glob).and_return(['1_gitlab_backup.tar'])
- allow(Dir).to receive(:chdir)
allow(File).to receive(:exist?).and_return(true)
allow(Kernel).to receive(:system).and_return(true)
allow(FileUtils).to receive(:cp_r).and_return(true)
allow(FileUtils).to receive(:mv).and_return(true)
allow(Rake::Task["gitlab:shell:setup"])
.to receive(:invoke).and_return(true)
- ENV['force'] = 'yes'
end
let(:gitlab_version) { Gitlab::VERSION }
@@ -58,8 +66,9 @@ describe 'gitlab:app namespace rake task' do
allow(YAML).to receive(:load_file)
.and_return({ gitlab_version: "not #{gitlab_version}" })
- expect { run_rake_task('gitlab:backup:restore') }
- .to raise_error(SystemExit)
+ expect do
+ expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
+ end.to raise_error(SystemExit)
end
it 'invokes restoration on match' do
@@ -75,44 +84,15 @@ describe 'gitlab:app namespace rake task' do
expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke)
expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke)
expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke)
- expect { run_rake_task('gitlab:backup:restore') }.not_to raise_error
+ expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
end
end
end # backup_restore task
describe 'backup' do
- before(:all) do
- ENV['force'] = 'yes'
- end
-
- def tars_glob
- Dir.glob(File.join(Gitlab.config.backup.path, '*_gitlab_backup.tar'))
- end
-
- def create_backup
- FileUtils.rm tars_glob
-
+ before do
# This reconnect makes our project fixture disappear, breaking the restore. Stub it out.
allow(ActiveRecord::Base.connection).to receive(:reconnect!)
-
- # Redirect STDOUT and run the rake task
- orig_stdout = $stdout
- $stdout = StringIO.new
- reenable_backup_sub_tasks
- run_rake_task('gitlab:backup:create')
- reenable_backup_sub_tasks
- $stdout = orig_stdout
-
- @backup_tar = tars_glob.first
- end
-
- def restore_backup
- orig_stdout = $stdout
- $stdout = StringIO.new
- reenable_backup_sub_tasks
- run_rake_task('gitlab:backup:restore')
- reenable_backup_sub_tasks
- $stdout = orig_stdout
end
describe 'backup creation and deletion using custom_hooks' do
@@ -120,27 +100,17 @@ describe 'gitlab:app namespace rake task' do
let(:user_backup_path) { "repositories/#{project.disk_path}" }
before do
- @origin_cd = Dir.pwd
-
- path = File.join(project.repository.path_to_repo, filename)
+ stub_env('SKIP', 'db')
+ path = File.join(project.repository.path_to_repo, 'custom_hooks')
FileUtils.mkdir_p(path)
FileUtils.touch(File.join(path, "dummy.txt"))
-
- ENV["SKIP"] = "db"
- create_backup
- end
-
- after do
- ENV["SKIP"] = ""
- FileUtils.rm(@backup_tar)
- Dir.chdir(@origin_cd)
end
context 'project uses custom_hooks and successfully creates backup' do
- let(:filename) { "custom_hooks" }
-
it 'creates custom_hooks.tar and project bundle' do
- tar_contents, exit_status = Gitlab::Popen.popen(%W{tar -tvf #{@backup_tar}})
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
+ tar_contents, exit_status = Gitlab::Popen.popen(%W{tar -tvf #{backup_tar}})
expect(exit_status).to eq(0)
expect(tar_contents).to match(user_backup_path)
@@ -149,47 +119,43 @@ describe 'gitlab:app namespace rake task' do
end
it 'restores files correctly' do
- restore_backup
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+ expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
- expect(Dir.entries(File.join(project.repository.path, "custom_hooks"))).to include("dummy.txt")
+ expect(Dir.entries(File.join(project.repository.path, 'custom_hooks'))).to include("dummy.txt")
end
end
end
context 'tar creation' do
- before do
- create_backup
- end
-
- after do
- FileUtils.rm(@backup_tar)
- end
-
context 'archive file permissions' do
it 'sets correct permissions on the tar file' do
- expect(File.exist?(@backup_tar)).to be_truthy
- expect(File::Stat.new(@backup_tar).mode.to_s(8)).to eq('100600')
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
+ expect(File.exist?(backup_tar)).to be_truthy
+ expect(File::Stat.new(backup_tar).mode.to_s(8)).to eq('100600')
end
context 'with custom archive_permissions' do
before do
allow(Gitlab.config.backup).to receive(:archive_permissions).and_return(0651)
- # We created a backup in a before(:all) so it got the default permissions.
- # We now need to do some work to create a _new_ backup file using our stub.
- FileUtils.rm(@backup_tar)
- create_backup
end
it 'uses the custom permissions' do
- expect(File::Stat.new(@backup_tar).mode.to_s(8)).to eq('100651')
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
+ expect(File::Stat.new(backup_tar).mode.to_s(8)).to eq('100651')
end
end
end
it 'sets correct permissions on the tar contents' do
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
tar_contents, exit_status = Gitlab::Popen.popen(
- %W{tar -tvf #{@backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz registry.tar.gz}
+ %W{tar -tvf #{backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz registry.tar.gz}
)
+
expect(exit_status).to eq(0)
expect(tar_contents).to match('db/')
expect(tar_contents).to match('uploads.tar.gz')
@@ -203,6 +169,8 @@ describe 'gitlab:app namespace rake task' do
end
it 'deletes temp directories' do
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
temp_dirs = Dir.glob(
File.join(Gitlab.config.backup.path, '{db,repositories,uploads,builds,artifacts,pages,lfs,registry}')
)
@@ -214,9 +182,12 @@ describe 'gitlab:app namespace rake task' do
let(:enable_registry) { false }
it 'does not create registry.tar.gz' do
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
tar_contents, exit_status = Gitlab::Popen.popen(
- %W{tar -tvf #{@backup_tar}}
+ %W{tar -tvf #{backup_tar}}
)
+
expect(exit_status).to eq(0)
expect(tar_contents).not_to match('registry.tar.gz')
end
@@ -232,37 +203,33 @@ describe 'gitlab:app namespace rake task' do
}
end
- let(:project_a) { create(:project, :repository, repository_storage: 'default') }
- let(:project_b) { create(:project, :repository, repository_storage: 'test_second_storage') }
-
before do
- FileUtils.mkdir('tmp/tests/default_storage')
- FileUtils.mkdir('tmp/tests/custom_storage')
+ # We only need a backup of the repositories for this test
+ stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,registry')
+ FileUtils.mkdir(Settings.absolute('tmp/tests/default_storage'))
+ FileUtils.mkdir(Settings.absolute('tmp/tests/custom_storage'))
allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
- # Create the projects now, after mocking the settings but before doing the backup
- project_a
- project_b
-
# Avoid asking gitaly about the root ref (which will fail beacuse of the
# mocked storages)
allow_any_instance_of(Repository).to receive(:empty_repo?).and_return(false)
-
- # We only need a backup of the repositories for this test
- ENV["SKIP"] = "db,uploads,builds,artifacts,lfs,registry"
- create_backup
end
after do
- FileUtils.rm_rf('tmp/tests/default_storage')
- FileUtils.rm_rf('tmp/tests/custom_storage')
- FileUtils.rm(@backup_tar) if @backup_tar
+ FileUtils.rm_rf(Settings.absolute('tmp/tests/default_storage'))
+ FileUtils.rm_rf(Settings.absolute('tmp/tests/custom_storage'))
end
it 'includes repositories in all repository storages' do
+ project_a = create(:project, :repository, repository_storage: 'default')
+ project_b = create(:project, :repository, repository_storage: 'test_second_storage')
+
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
tar_contents, exit_status = Gitlab::Popen.popen(
- %W{tar -tvf #{@backup_tar} repositories}
+ %W{tar -tvf #{backup_tar} repositories}
)
+
expect(exit_status).to eq(0)
expect(tar_contents).to match("repositories/#{project_a.disk_path}.bundle")
expect(tar_contents).to match("repositories/#{project_b.disk_path}.bundle")
@@ -271,35 +238,15 @@ describe 'gitlab:app namespace rake task' do
end # backup_create task
describe "Skipping items" do
- def tars_glob
- Dir.glob(File.join(Gitlab.config.backup.path, '*_gitlab_backup.tar'))
- end
-
- before :all do
- @origin_cd = Dir.pwd
-
- reenable_backup_sub_tasks
-
- FileUtils.rm tars_glob
-
- # Redirect STDOUT and run the rake task
- orig_stdout = $stdout
- $stdout = StringIO.new
- ENV["SKIP"] = "repositories,uploads"
- run_rake_task('gitlab:backup:create')
- $stdout = orig_stdout
-
- @backup_tar = tars_glob.first
- end
-
- after :all do
- FileUtils.rm(@backup_tar)
- Dir.chdir @origin_cd
+ before do
+ stub_env('SKIP', 'repositories,uploads')
end
it "does not contain skipped item" do
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
tar_contents, _exit_status = Gitlab::Popen.popen(
- %W{tar -tvf #{@backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz registry.tar.gz}
+ %W{tar -tvf #{backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz registry.tar.gz}
)
expect(tar_contents).to match('db/')
@@ -313,9 +260,10 @@ describe 'gitlab:app namespace rake task' do
end
it 'does not invoke repositories restore' do
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
allow(Rake::Task['gitlab:shell:setup'])
.to receive(:invoke).and_return(true)
- allow($stdout).to receive :write
expect(Rake::Task['gitlab:db:drop_tables']).to receive :invoke
expect(Rake::Task['gitlab:backup:db:restore']).to receive :invoke
@@ -327,38 +275,15 @@ describe 'gitlab:app namespace rake task' do
expect(Rake::Task['gitlab:backup:lfs:restore']).to receive :invoke
expect(Rake::Task['gitlab:backup:registry:restore']).to receive :invoke
expect(Rake::Task['gitlab:shell:setup']).to receive :invoke
- expect { run_rake_task('gitlab:backup:restore') }.not_to raise_error
+ expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
end
end
describe "Human Readable Backup Name" do
- def tars_glob
- Dir.glob(File.join(Gitlab.config.backup.path, '*_gitlab_backup.tar'))
- end
-
- before :all do
- @origin_cd = Dir.pwd
-
- reenable_backup_sub_tasks
-
- FileUtils.rm tars_glob
-
- # Redirect STDOUT and run the rake task
- orig_stdout = $stdout
- $stdout = StringIO.new
- run_rake_task('gitlab:backup:create')
- $stdout = orig_stdout
-
- @backup_tar = tars_glob.first
- end
-
- after :all do
- FileUtils.rm(@backup_tar)
- Dir.chdir @origin_cd
- end
-
it 'name has human readable time' do
- expect(@backup_tar).to match(/\d+_\d{4}_\d{2}_\d{2}_\d+\.\d+\.\d+.*_gitlab_backup.tar$/)
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
+
+ expect(backup_tar).to match(/\d+_\d{4}_\d{2}_\d{2}_\d+\.\d+\.\d+.*_gitlab_backup.tar$/)
end
end
end # gitlab:app namespace
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index 1e9b20435ec..5dd8fe8eaa5 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -43,15 +43,8 @@ describe 'gitlab:gitaly namespace rake task' do
describe 'gmake/make' do
let(:command_preamble) { %w[/usr/bin/env -u RUBYOPT -u BUNDLE_GEMFILE] }
- before(:all) do
- @old_env_ci = ENV.delete('CI')
- end
-
- after(:all) do
- ENV['CI'] = @old_env_ci if @old_env_ci
- end
-
before do
+ stub_env('CI', false)
FileUtils.mkdir_p(clone_path)
expect(Dir).to receive(:chdir).with(clone_path).and_call_original
allow(Bundler).to receive(:bundle_path).and_return('/fake/bundle_path')
diff --git a/spec/tasks/gitlab/ldap_rake_spec.rb b/spec/tasks/gitlab/ldap_rake_spec.rb
index 12d442b9820..279234f2887 100644
--- a/spec/tasks/gitlab/ldap_rake_spec.rb
+++ b/spec/tasks/gitlab/ldap_rake_spec.rb
@@ -4,7 +4,7 @@ describe 'gitlab:ldap:rename_provider rake task' do
it 'completes without error' do
Rake.application.rake_require 'tasks/gitlab/ldap'
stub_warn_user_is_not_gitlab
- ENV['force'] = 'yes'
+ stub_env('force', 'yes')
create(:identity) # Necessary to prevent `exit 1` from the task.
diff --git a/vendor/gitignore/Android.gitignore b/vendor/gitignore/Android.gitignore
index 520a86352f7..c79ba5080a3 100644
--- a/vendor/gitignore/Android.gitignore
+++ b/vendor/gitignore/Android.gitignore
@@ -41,7 +41,8 @@ captures/
.idea/libraries
# Keystore files
-*.jks
+# Uncomment the following line if you do not want to check your keystore files in.
+#*.jks
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
diff --git a/vendor/gitignore/Autotools.gitignore b/vendor/gitignore/Autotools.gitignore
index e3923f96fce..ffa6ecc3f9b 100644
--- a/vendor/gitignore/Autotools.gitignore
+++ b/vendor/gitignore/Autotools.gitignore
@@ -31,3 +31,12 @@ Makefile.in
# http://www.gnu.org/software/texinfo
/texinfo.tex
+
+# http://www.gnu.org/software/m4/
+
+m4/libtool.m4
+m4/ltoptions.m4
+m4/ltsugar.m4
+m4/ltversion.m4
+m4/lt~obsolete.m4
+autom4te.cache
diff --git a/vendor/gitignore/Elixir.gitignore b/vendor/gitignore/Elixir.gitignore
index ac67aaf3243..b6d65867dac 100644
--- a/vendor/gitignore/Elixir.gitignore
+++ b/vendor/gitignore/Elixir.gitignore
@@ -1,6 +1,8 @@
/_build
/cover
/deps
+/doc
+/.fetch
erl_crash.dump
*.ez
*.beam
diff --git a/vendor/gitignore/ExtJs.gitignore b/vendor/gitignore/ExtJs.gitignore
index c92aea0fe0c..ab97a8cc3e1 100644
--- a/vendor/gitignore/ExtJs.gitignore
+++ b/vendor/gitignore/ExtJs.gitignore
@@ -10,3 +10,5 @@ ext/
modern.json
modern.jsonp
resources/sass/.sass-cache/
+resources/.arch-internal-preview.css
+.arch-internal-preview.css
diff --git a/vendor/gitignore/Global/Matlab.gitignore b/vendor/gitignore/Global/Matlab.gitignore
index 09dfde64b5f..cca150a88dd 100644
--- a/vendor/gitignore/Global/Matlab.gitignore
+++ b/vendor/gitignore/Global/Matlab.gitignore
@@ -19,4 +19,4 @@ slprj/
octave-workspace
# Simulink autosave extension
-.autosave
+*.autosave
diff --git a/vendor/gitignore/Global/Xcode.gitignore b/vendor/gitignore/Global/Xcode.gitignore
index 37de8bb4793..cd0c7d3e45a 100644
--- a/vendor/gitignore/Global/Xcode.gitignore
+++ b/vendor/gitignore/Global/Xcode.gitignore
@@ -2,11 +2,17 @@
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
-## Build generated
+## User settings
+xcuserdata/
+
+## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
+*.xcscmblueprint
+*.xccheckout
+
+## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
-
-## Various settings
+*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
@@ -15,9 +21,3 @@ DerivedData/
!default.mode2v3
*.perspectivev3
!default.perspectivev3
-xcuserdata/
-
-## Other
-*.moved-aside
-*.xccheckout
-*.xcscmblueprint
diff --git a/vendor/gitignore/Global/macOS.gitignore b/vendor/gitignore/Global/macOS.gitignore
index 9d1061e8bc4..135767fc075 100644
--- a/vendor/gitignore/Global/macOS.gitignore
+++ b/vendor/gitignore/Global/macOS.gitignore
@@ -1,5 +1,5 @@
# General
-*.DS_Store
+.DS_Store
.AppleDouble
.LSOverride
diff --git a/vendor/gitignore/Joomla.gitignore b/vendor/gitignore/Joomla.gitignore
index 53a74e74657..b6bf3a9c96a 100644
--- a/vendor/gitignore/Joomla.gitignore
+++ b/vendor/gitignore/Joomla.gitignore
@@ -251,7 +251,7 @@
/administrator/language/en-GB/en-GB.tpl_hathor.sys.ini
/administrator/language/en-GB/en-GB.xml
/administrator/language/overrides/*
-/administrator/logs/index.html
+/administrator/logs/*
/administrator/manifests/*
/administrator/modules/mod_custom/*
/administrator/modules/mod_feed/*
diff --git a/vendor/gitignore/OCaml.gitignore b/vendor/gitignore/OCaml.gitignore
index f7817ae5c36..da0b20424a0 100644
--- a/vendor/gitignore/OCaml.gitignore
+++ b/vendor/gitignore/OCaml.gitignore
@@ -18,3 +18,6 @@ _build/
# oasis generated files
setup.data
setup.log
+
+# Merlin configuring file for Vim and Emacs
+.merlin
diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore
index 113294a5f18..af2f537516d 100644
--- a/vendor/gitignore/Python.gitignore
+++ b/vendor/gitignore/Python.gitignore
@@ -23,6 +23,7 @@ wheels/
*.egg-info/
.installed.cfg
*.egg
+MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
@@ -51,6 +52,8 @@ coverage.xml
# Django stuff:
*.log
+.static_storage/
+.media/
local_settings.py
# Flask stuff:
@@ -84,6 +87,8 @@ celerybeat-schedule
env/
venv/
ENV/
+env.bak/
+venv.bak/
# Spyder project settings
.spyderproject
diff --git a/vendor/gitignore/Qt.gitignore b/vendor/gitignore/Qt.gitignore
index fe67fdf1ee6..037a1e75790 100644
--- a/vendor/gitignore/Qt.gitignore
+++ b/vendor/gitignore/Qt.gitignore
@@ -31,11 +31,9 @@ ui_*.h
Makefile*
*build-*
-
# Qt unit tests
target_wrapper.*
-
# QtCreator
*.autosave
diff --git a/vendor/gitignore/TeX.gitignore b/vendor/gitignore/TeX.gitignore
index a0322dbd35a..b6418e51766 100644
--- a/vendor/gitignore/TeX.gitignore
+++ b/vendor/gitignore/TeX.gitignore
@@ -13,6 +13,7 @@
## Intermediate documents:
*.dvi
+*.xdv
*-converted-to.*
# these rules might exclude image files for figures etc.
# *.ps
diff --git a/vendor/gitignore/Terraform.gitignore b/vendor/gitignore/Terraform.gitignore
index f20453be963..9b5aebb1b35 100644
--- a/vendor/gitignore/Terraform.gitignore
+++ b/vendor/gitignore/Terraform.gitignore
@@ -5,3 +5,6 @@
# Module directory
.terraform/
+
+# Variable values for development
+terraform.tfvars
diff --git a/vendor/gitignore/Umbraco.gitignore b/vendor/gitignore/Umbraco.gitignore
index ea05e1fb2a9..b6b0743f62a 100644
--- a/vendor/gitignore/Umbraco.gitignore
+++ b/vendor/gitignore/Umbraco.gitignore
@@ -1,3 +1,7 @@
+## Ignore Umbraco files/folders generated for each instance
+##
+## Get latest from https://github.com/github/gitignore/blob/master/Umbraco.gitignore
+
# Note: VisualStudio gitignore rules may also be relevant
# Umbraco
diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore
index f652b45c2ee..0867ec5a7ee 100644
--- a/vendor/gitignore/VisualStudio.gitignore
+++ b/vendor/gitignore/VisualStudio.gitignore
@@ -96,6 +96,9 @@ ipch/
*.vspx
*.sap
+# Visual Studio Trace Files
+*.e2e
+
# TFS 2012 Local Workspace
$tf/
@@ -297,3 +300,6 @@ __pycache__/
*.btm.cs
*.odx.cs
*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
diff --git a/vendor/gitignore/ZendFramework.gitignore b/vendor/gitignore/ZendFramework.gitignore
index 80adb154900..f0b7d8585b7 100644
--- a/vendor/gitignore/ZendFramework.gitignore
+++ b/vendor/gitignore/ZendFramework.gitignore
@@ -19,7 +19,6 @@ temp/
data/DoctrineORMModule/Proxy/
data/DoctrineORMModule/cache/
-
# Legacy ZF1
demos/
extras/documentation
diff --git a/vendor/gitlab-ci-yml/Go.gitlab-ci.yml b/vendor/gitlab-ci-yml/Go.gitlab-ci.yml
index 8a214352d2a..86e4985d8d2 100644
--- a/vendor/gitlab-ci-yml/Go.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Go.gitlab-ci.yml
@@ -29,7 +29,7 @@ format:
compile:
stage: build
script:
- - go build -race -ldflags "-extldflags '-static'" -o mybinary
+ - go build -race -ldflags "-extldflags '-static'" -o $CI_PROJECT_DIR/mybinary
artifacts:
paths:
- mybinary
diff --git a/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml b/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml
index 91b096654d1..ba2efbd03a0 100644
--- a/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml
@@ -7,8 +7,8 @@
# This template will build and test your projects as well as create the documentation.
#
# * Caches downloaded dependencies and plugins between invocation.
-# * Does only verify merge requests but deploy built artifacts of the
-# master branch.
+# * Verify but don't deploy merge requests.
+# * Deploy built artifacts from master branch only.
# * Shows how to use multiple jobs in test stage for verifying functionality
# with multiple JDKs.
# * Uses site:stage to collect the documentation for multi-module projects.
@@ -20,7 +20,7 @@ variables:
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"
# 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.
+ # `installAtEnd` and `deployAtEnd` are only effective with recent version of the corresponding plugins.
MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version -DinstallAtEnd=true -DdeployAtEnd=true"
# Cache downloaded dependencies and plugins between builds.
@@ -100,4 +100,3 @@ pages:
- public
only:
- master
-
diff --git a/vendor/gitlab-ci-yml/Python.gitlab-ci.yml b/vendor/gitlab-ci-yml/Python.gitlab-ci.yml
new file mode 100644
index 00000000000..a2882a5407d
--- /dev/null
+++ b/vendor/gitlab-ci-yml/Python.gitlab-ci.yml
@@ -0,0 +1,32 @@
+# This file is a template, and might need editing before it works on your project.
+image: python:latest
+
+before_script:
+ - python -V # Print out python version for debugging
+
+test:
+ script:
+ - python setup.py test
+ - pip install tox flake8 # you can also use tox
+ - tox -e py36,flake8
+
+run:
+ script:
+ - python setup.py bdist_wheel
+ # an alternative approach is to install and run:
+ - pip install dist/*
+ # run the command here
+ artifacts:
+ paths:
+ - dist/*.whl
+
+pages:
+ script:
+ - pip install sphinx sphinx-rtd-theme
+ - cd doc ; make html
+ - mv build/html/ ../public/
+ artifacts:
+ paths:
+ - public
+ only:
+ - master
diff --git a/vendor/licenses.csv b/vendor/licenses.csv
index 24623ff4c1f..9f78059986d 100644
--- a/vendor/licenses.csv
+++ b/vendor/licenses.csv
@@ -13,9 +13,9 @@ activemodel,4.2.8,MIT
activerecord,4.2.8,MIT
activesupport,4.2.8,MIT
acts-as-taggable-on,4.0.0,MIT
-addressable,2.3.8,Apache 2.0
+addressable,2.5.2,Apache 2.0
after,0.8.2,MIT
-ajv,5.2.0,MIT
+ajv,5.2.2,MIT
ajv-keywords,2.1.0,MIT
akismet,2.0.0,MIT
align-text,0.1.4,MIT
@@ -28,8 +28,6 @@ ansi-regex,2.1.1,MIT
ansi-styles,2.2.1,MIT
anymatch,1.3.2,ISC
append-transform,0.4.0,MIT
-aproba,1.1.1,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
@@ -46,21 +44,15 @@ arrify,1.0.1,MIT
asana,0.6.0,MIT
asciidoctor,1.5.3,MIT
asciidoctor-plantuml,0.0.7,MIT
-asn1,0.2.3,MIT
asn1.js,4.9.1,MIT
assert,1.4.1,MIT
-assert-plus,0.2.0,MIT
async,2.4.1,MIT
async-each,1.0.1,MIT
-asynckit,0.4.0,MIT
atomic,1.1.99,Apache 2.0
attr_encrypted,3.0.3,MIT
attr_required,1.0.0,MIT
-autoparse,0.3.3,Apache 2.0
autoprefixer,6.7.7,MIT
autoprefixer-rails,6.2.3,MIT
-aws-sign2,0.6.0,Apache 2.0
-aws4,1.6.0,MIT
axiom-types,0.1.1,MIT
axios,0.16.2,MIT
babel-code-frame,6.22.0,MIT
@@ -145,19 +137,16 @@ base64-js,1.2.0,MIT
base64id,1.0.0,MIT
batch,0.6.1,MIT
bcrypt,3.1.11,MIT
-bcrypt-pbkdf,1.0.1,New BSD
bcrypt_pbkdf,1.0.0,MIT
better-assert,1.0.2,MIT
big.js,3.1.3,MIT
binary-extensions,1.10.0,MIT
-bindata,2.3.5,ruby
+bindata,2.4.1,ruby
blob,0.0.4,unknown
-block-stream,0.0.9,ISC
bluebird,2.11.0,MIT
bn.js,4.11.6,MIT
body-parser,1.17.2,MIT
bonjour,3.5.0,MIT
-boom,2.10.1,New BSD
bootstrap-sass,3.3.6,MIT
bootstrap_form,2.7.0,MIT
brace-expansion,1.1.8,MIT
@@ -187,7 +176,6 @@ camelcase-keys,2.1.0,MIT
caniuse-api,1.6.1,MIT
caniuse-db,1.0.30000649,CC-BY-4.0
carrierwave,1.1.0,MIT
-caseless,0.12.0,Apache 2.0
cause,0.1,MIT
center-align,0.1.3,MIT
chalk,1.1.3,MIT
@@ -216,7 +204,6 @@ 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.5,MIT
commander,2.9.0,MIT
commondir,1.0.1,MIT
component-bind,1.0.0,unknown
@@ -233,8 +220,7 @@ configstore,1.4.0,Simplified BSD
connect,3.6.3,MIT
connect-history-api-fallback,1.3.0,MIT
connection_pool,2.2.1,MIT
-console-browserify,1.1.0,MIT
-console-control-strings,1.1.0,ISC
+console-browserify,1.1.0,[Circular]
consolidate,0.14.5,MIT
constants-browserify,1.0.0,MIT
contains-path,0.1.0,MIT
@@ -254,7 +240,6 @@ create-hmac,1.1.4,MIT
creole,0.5.0,ruby
cropper,2.3.0,MIT
cross-spawn,5.1.0,MIT
-cryptiles,2.0.5,New BSD
crypto-browserify,3.11.0,MIT
css-color-names,0.0.4,MIT
css-loader,0.28.0,MIT
@@ -268,13 +253,14 @@ custom-event,1.0.1,MIT
d,1.0.0,MIT
d3,3.5.11,New BSD
d3_rails,3.5.11,MIT
-dashdash,1.14.1,MIT
date-now,0.1.4,MIT
de-indent,1.0.2,MIT
debug,2.6.8,MIT
debugger-ruby_core_source,1.3.8,MIT
decamelize,1.2.0,MIT
deckar01-task_list,2.0.0,MIT
+declarative,0.0.10,MIT
+declarative-option,0.1.0,MIT
decompress-response,3.3.0,MIT
deep-equal,1.0.1,MIT
deep-extend,0.4.2,MIT
@@ -283,9 +269,7 @@ default-require-extensions,1.0.0,MIT
default_value_for,3.0.2,MIT
defined,1.0.0,MIT
del,2.2.2,MIT
-delayed-stream,1.0.0,MIT
delegate,3.1.2,MIT
-delegates,1.0.0,MIT
depd,1.1.1,MIT
des.js,1.0.0,MIT
descendants_tracker,0.0.4,MIT
@@ -310,14 +294,13 @@ domain_name,0.5.20161021,"Simplified BSD,New BSD,Mozilla Public License 2.0"
domelementtype,1.3.0,unknown
domhandler,2.3.0,unknown
domutils,1.5.1,unknown
-doorkeeper,4.2.0,MIT
-doorkeeper-openid_connect,1.1.2,MIT
+doorkeeper,4.2.6,MIT
+doorkeeper-openid_connect,1.2.0,MIT
dropzone,4.2.0,MIT
dropzonejs-rails,0.7.2,MIT
-duplexer,0.1.1,MIT
+duplexer,0.1.1,[Circular]
duplexer3,0.1.4,New BSD
duplexify,3.5.1,MIT
-ecc-jsbn,0.1.1,MIT
editorconfig,0.13.2,MIT
ee-first,1.1.1,MIT
ejs,2.5.6,Apache 2.0
@@ -362,7 +345,7 @@ eslint-plugin-import,2.2.0,MIT
eslint-plugin-jasmine,2.2.0,MIT
eslint-plugin-promise,3.5.0,ISC
espree,3.5.0,Simplified BSD
-esprima,4.0.0,Simplified BSD
+esprima,2.7.3,Simplified BSD
esquery,1.0.0,BSD
esrecurse,4.1.0,Simplified BSD
estraverse,4.1.1,Simplified BSD
@@ -388,12 +371,10 @@ express,4.15.4,MIT
expression_parser,0.9.0,MIT
extend,3.0.1,MIT
extglob,0.3.2,MIT
-extlib,0.9.16,MIT
-extsprintf,1.0.2,MIT
-faraday,0.12.1,MIT
+faraday,0.12.2,MIT
faraday_middleware,0.11.0.1,MIT
faraday_middleware-multi_json,0.0.6,MIT
-fast-deep-equal,0.1.0,MIT
+fast-deep-equal,1.0.0,MIT
fast-levenshtein,2.0.6,MIT
fast_gettext,1.4.0,"MIT,ruby"
fastparse,1.1.1,MIT
@@ -428,8 +409,6 @@ follow-redirects,1.2.3,MIT
font-awesome-rails,4.7.0.1,"MIT,SIL Open Font License"
for-in,0.1.6,MIT
for-own,0.1.4,MIT
-forever-agent,0.6.1,Apache 2.0
-form-data,2.1.4,MIT
formatador,0.2.5,MIT
forwarded,0.1.0,MIT
fresh,0.5.0,MIT
@@ -437,11 +416,8 @@ from,0.1.7,MIT
fs-access,1.0.1,MIT
fs-extra,0.26.7,MIT
fs.realpath,1.0.0,ISC
-fsevents,1.1.2,MIT
-fstream,1.0.11,ISC
-fstream-ignore,1.0.5,ISC
+fsevents,,unknown
function-bind,1.1.0,MIT
-gauge,2.7.4,ISC
gemnasium-gitlab-service,0.2.6,MIT
gemojione,3.3.0,MIT
generate-function,2.0.0,MIT
@@ -450,15 +426,15 @@ get-caller-file,1.0.2,ISC
get-stdin,4.0.1,MIT
get-stream,3.0.0,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.2.0,MIT
-gitaly-proto,0.33.0,MIT
+gitaly-proto,0.41.0,MIT
github-linguist,4.7.6,MIT
github-markup,1.6.1,MIT
gitlab-flowdock-git-hook,1.0.1,MIT
-gitlab-grit,2.8.1,MIT
-gitlab-markup,1.5.1,MIT
+gitlab-grit,2.8.2,MIT
+gitlab-markup,1.6.2,MIT
+gitlab-svgs,1.0.4,unknown
gitlab_omniauth-ldap,2.0.4,MIT
glob,6.0.4,ISC
glob-base,0.3.0,MIT
@@ -471,9 +447,9 @@ gollum-lib,4.2.7,MIT
gollum-rugged_adapter,0.4.4,MIT
gon,6.1.0,MIT
good-listener,1.2.2,MIT
-google-api-client,0.8.7,Apache 2.0
+google-api-client,0.13.6,Apache 2.0
google-protobuf,3.4.0.2,New BSD
-googleauth,0.5.1,Apache 2.0
+googleauth,0.5.3,Apache 2.0
got,7.1.0,MIT
gpgme,2.0.13,LGPL-2.1+
graceful-fs,4.1.11,ISC
@@ -481,14 +457,12 @@ graceful-readlink,1.0.1,MIT
grape,1.0.0,MIT
grape-entity,0.6.0,MIT
grape-route-helpers,2.1.0,MIT
-grape_logging,1.6.0,MIT
-grpc,1.4.5,New BSD
+grape_logging,1.7.0,MIT
+grpc,1.6.0,Apache 2.0
gzip-size,3.0.0,MIT
hamlit,2.6.1,MIT
handle-thing,1.2.5,MIT
handlebars,4.0.6,MIT
-har-schema,1.0.5,ISC
-har-validator,4.2.1,ISC
has,1.0.1,MIT
has-ansi,2.0.0,MIT
has-binary,0.1.7,MIT
@@ -496,16 +470,13 @@ has-cors,1.1.0,MIT
has-flag,2.0.0,MIT
has-symbol-support-x,1.3.0,MIT
has-to-string-tag-x,1.3.0,MIT
-has-unicode,2.0.1,ISC
hash-sum,1.0.2,MIT
hash.js,1.0.3,MIT
hashie,3.5.6,MIT
hashie-forbidden_attributes,0.1.1,MIT
-hawk,3.1.3,New BSD
he,1.1.1,MIT
health_check,2.6.0,MIT
hipchat,1.5.2,MIT
-hoek,2.16.3,New BSD
home-or-tmp,2.0.0,MIT
hosted-git-info,2.2.0,ISC
hpack.js,2.1.6,MIT
@@ -522,7 +493,6 @@ http-errors,1.6.2,MIT
http-form_data,1.0.1,MIT
http-proxy,1.16.2,MIT
http-proxy-middleware,0.17.4,MIT
-http-signature,1.1.1,MIT
http_parser.rb,0.6.0,MIT
httparty,0.13.7,MIT
httpclient,2.8.2,ruby
@@ -583,15 +553,13 @@ 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-typedarray,1.0.0,MIT
is-unc-path,0.1.2,MIT
is-utf8,0.2.1,MIT
is-windows,0.2.0,MIT
isarray,1.0.0,MIT
isbinaryfile,3.0.2,MIT
-isexe,1.1.2,ISC
+isexe,2.0.0,ISC
isobject,2.1.0,MIT
-isstream,0.1.2,MIT
istanbul,0.4.5,New BSD
istanbul-api,1.1.1,New BSD
istanbul-lib-coverage,1.0.1,New BSD
@@ -605,7 +573,6 @@ jasmine-core,2.6.3,MIT
jasmine-jquery,2.1.1,MIT
jed,1.1.1,MIT
jira-ruby,1.4.1,MIT
-jodid25519,1.0.2,MIT
jquery,2.2.1,MIT
jquery-atwho-rails,1.3.2,MIT
jquery-rails,4.1.1,MIT
@@ -615,21 +582,18 @@ js-beautify,1.6.12,MIT
js-cookie,2.1.3,MIT
js-tokens,3.0.1,MIT
js-yaml,3.7.0,MIT
-jsbn,0.1.1,MIT
jsesc,1.3.0,MIT
json,1.8.6,ruby
-json-jwt,1.7.1,MIT
+json-jwt,1.7.2,MIT
json-loader,0.5.7,MIT
-json-schema,0.2.3,"AFLv2.1,BSD"
json-schema-traverse,0.3.1,MIT
json-stable-stringify,1.0.1,MIT
json-stringify-safe,5.0.1,ISC
-json3,3.3.2,MIT
+json3,3.3.2,[Circular]
json5,0.5.1,MIT
jsonfile,2.4.0,MIT
jsonify,0.0.0,Public Domain
jsonpointer,4.0.1,MIT
-jsprim,1.4.0,MIT
jszip,3.1.3,(MIT OR GPL-3.0)
jszip-utils,0.0.2,MIT or GPLv3
jwt,1.5.6,MIT
@@ -649,7 +613,6 @@ kind-of,3.1.0,MIT
klaw,1.3.1,MIT
kubeclient,2.2.0,MIT
latest-version,1.0.1,MIT
-launchy,2.4.3,ISC
lazy-cache,1.0.4,MIT
lcid,1.0.0,MIT
levn,0.3.0,MIT
@@ -706,7 +669,7 @@ marked,0.3.6,MIT
math-expression-evaluator,1.2.16,MIT
media-typer,0.3.0,MIT
mem,1.1.0,MIT
-memoist,0.15.0,MIT
+memoist,0.16.0,MIT
memory-fs,0.4.1,MIT
meow,3.7.0,MIT
merge-descriptors,1.0.1,MIT
@@ -714,13 +677,14 @@ method_source,0.8.2,MIT
methods,1.1.2,MIT
micromatch,2.3.11,MIT
miller-rabin,4.0.0,MIT
-mime,1.3.4,MIT
-mime-db,1.27.0,MIT
-mime-types,2.99.3,"MIT,Artistic-2.0,GPL-2.0"
+mime,1.3.4,[Circular]
+mime-db,1.29.0,MIT
+mime-types,3.1,MIT
+mime-types-data,3.2016.0521,MIT
mimemagic,0.3.0,MIT
mimic-fn,1.1.0,MIT
mimic-response,1.0.0,MIT
-mini_portile2,2.2.0,MIT
+mini_portile2,2.3.0,MIT
minimalistic-assert,1.0.0,ISC
minimatch,3.0.3,ISC
minimist,0.0.8,MIT
@@ -731,7 +695,7 @@ monaco-editor,0.8.3,MIT
mousetrap,1.4.6,Apache 2.0
mousetrap-rails,1.4.6,"MIT,Apache"
ms,2.0.0,MIT
-multi_json,1.12.1,MIT
+multi_json,1.12.2,MIT
multi_xml,0.6.0,MIT
multicast-dns,6.1.1,MIT
multicast-dns-service-types,1.1.0,MIT
@@ -739,9 +703,7 @@ multipart-post,2.0.0,MIT
mustermann,1.0.0,MIT
mustermann-grape,1.0.0,MIT
mute-stream,0.0.5,ISC
-mysql2,0.4.5,MIT
name-all-modules-plugin,1.0.1,MIT
-nan,2.6.2,MIT
natural-compare,1.4.0,MIT
negotiator,0.6.1,MIT
nested-error-stacks,1.0.2,MIT
@@ -751,22 +713,19 @@ netrc,0.11.0,MIT
node-dir,0.1.17,MIT
node-forge,0.6.33,BSD
node-libs-browser,2.0.0,MIT
-node-pre-gyp,0.6.36,New BSD
nodemon,1.11.0,MIT
-nokogiri,1.8.0,MIT
+nokogiri,1.8.1,MIT
nopt,3.0.6,ISC
-normalize-package-data,2.4.0,Simplified BSD
+normalize-package-data,2.3.5,Simplified BSD
normalize-path,2.1.1,MIT
normalize-range,0.1.2,MIT
normalize-url,1.9.1,MIT
npm-run-path,2.0.2,MIT
-npmlog,4.1.0,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.1,MIT
-oauth-sign,0.8.2,Apache 2.0
oauth2,1.4.0,MIT
object-assign,4.1.1,MIT
object-component,0.0.3,unknown
@@ -777,7 +736,7 @@ oj,2.17.5,MIT
omniauth,1.4.2,MIT
omniauth-auth0,1.4.1,MIT
omniauth-authentiq,0.3.1,MIT
-omniauth-azure-oauth2,0.0.6,MIT
+omniauth-azure-oauth2,0.0.9,MIT
omniauth-cas3,1.1.4,MIT
omniauth-facebook,4.0.0,MIT
omniauth-github,1.1.2,MIT
@@ -786,7 +745,7 @@ omniauth-google-oauth2,0.5.2,MIT
omniauth-kerberos,0.3.0,MIT
omniauth-multipassword,0.4.2,MIT
omniauth-oauth,1.1.0,MIT
-omniauth-oauth2,1.3.1,MIT
+omniauth-oauth2,1.4.0,MIT
omniauth-oauth2-generic,0.2.2,MIT
omniauth-saml,1.7.0,MIT
omniauth-shibboleth,1.2.1,MIT
@@ -839,13 +798,11 @@ pbkdf2,3.0.9,MIT
peek,1.0.1,MIT
peek-gc,0.0.2,MIT
peek-host,1.0.0,MIT
-peek-mysql2,1.1.0,MIT
peek-performance_bar,1.3.0,MIT
peek-pg,1.3.0,MIT
peek-rblineprof,0.2.0,MIT
peek-redis,1.2.0,MIT
peek-sidekiq,1.0.3,MIT
-performance-now,0.2.0,MIT
pg,0.18.4,"BSD,ruby,GPL"
pify,2.3.0,MIT
pikaday,1.5.1,"BSD,MIT"
@@ -910,6 +867,7 @@ prr,0.0.0,MIT
ps-tree,1.1.0,MIT
pseudomap,1.0.2,ISC
public-encrypt,4.0.0,MIT
+public_suffix,3.0.0,MIT
punycode,1.4.1,MIT
pyu-ruby-sasl,0.0.3.3,MIT
q,1.5.0,MIT
@@ -917,7 +875,7 @@ qjobs,1.1.5,MIT
qs,6.5.0,New BSD
query-string,4.3.2,MIT
querystring,0.2.0,MIT
-querystring-es3,0.2.1,MIT
+querystring-es3,0.2.1,[Circular]
querystringify,0.0.4,MIT
rack,1.6.8,MIT
rack-accept,0.4.5,MIT
@@ -935,7 +893,7 @@ rails-i18n,4.0.9,MIT
railties,4.2.8,MIT
rainbow,2.2.2,MIT
raindrops,0.18.0,LGPL-2.1+
-rake,12.0.0,MIT
+rake,12.1.0,MIT
randomatic,1.1.6,MIT
randombytes,2.0.3,MIT
range-parser,1.2.0,MIT
@@ -982,7 +940,7 @@ remove-trailing-separator,1.1.0,ISC
repeat-element,1.1.2,MIT
repeat-string,1.6.1,MIT
repeating,2.0.1,MIT
-request,2.81.0,Apache 2.0
+representable,3.0.4,MIT
request_store,1.3.1,MIT
require-directory,2.1.1,MIT
require-from-string,1.2.1,MIT
@@ -994,7 +952,7 @@ resolve-from,1.0.1,MIT
responders,2.3.0,MIT
rest-client,2.0.0,MIT
restore-cursor,1.0.1,MIT
-retriable,1.4.1,MIT
+retriable,3.1.1,MIT
right-align,0.1.3,MIT
rimraf,2.6.1,ISC
rinku,2.0.0,ISC
@@ -1013,7 +971,7 @@ rufus-scheduler,3.4.0,MIT
rugged,0.26.0,MIT
run-async,0.1.0,MIT
rx-lite,3.1.2,Apache 2.0
-safe-buffer,5.1.1,MIT
+safe-buffer,5.0.1,MIT
safe_yaml,1.0.4,MIT
sanitize,2.1.0,MIT
sass,3.4.22,MIT
@@ -1053,7 +1011,6 @@ slack-notifier,1.5.1,MIT
slash,1.0.0,MIT
slice-ansi,0.0.4,MIT
slide,1.1.6,ISC
-sntp,1.0.9,BSD
socket.io,1.7.3,MIT
socket.io-adapter,0.5.0,MIT
socket.io-client,1.7.3,MIT
@@ -1074,7 +1031,6 @@ sprintf-js,1.0.3,New BSD
sprockets,3.7.1,MIT
sprockets-rails,3.2.0,MIT
sql.js,0.4.0,MIT
-sshpk,1.13.0,MIT
state_machines,0.4.0,MIT
state_machines-activemodel,0.4.0,MIT
state_machines-activerecord,0.4.0,MIT
@@ -1085,22 +1041,20 @@ stream-http,2.6.3,MIT
stream-shift,1.0.0,MIT
strict-uri-encode,1.1.0,MIT
string-length,1.0.1,MIT
-string-width,2.0.0,MIT
-string_decoder,1.0.3,MIT
+string-width,1.0.2,MIT
+string_decoder,0.10.31,MIT
stringex,2.7.1,MIT
-stringstream,0.0.5,MIT
strip-ansi,3.0.1,MIT
strip-bom,3.0.0,MIT
strip-eof,1.0.0,MIT
strip-indent,1.0.1,MIT
strip-json-comments,2.0.1,MIT
-supports-color,4.2.1,MIT
+supports-color,3.2.3,MIT
+svg4everybody,2.1.9,CC0-1.0
svgo,0.7.2,MIT
sys-filesystem,1.1.6,Artistic 2.0
table,3.8.3,New BSD
tapable,0.2.8,MIT
-tar,2.2.1,ISC
-tar-pack,3.4.0,Simplified BSD
temple,0.7.7,MIT
test-exclude,4.0.0,ISC
text,1.3.1,MIT
@@ -1113,6 +1067,7 @@ three-stl-loader,1.0.4,MIT
through,2.3.8,MIT
thunky,0.1.0,unknown
tilt,2.0.6,MIT
+time-stamp,2.0.0,MIT
timeago.js,2.0.5,MIT
timed-out,4.0.1,MIT
timers-browserify,2.0.4,MIT
@@ -1124,31 +1079,28 @@ to-arraybuffer,1.0.1,MIT
to-fast-properties,1.0.2,MIT
toml-rb,0.3.15,MIT
touch,1.0.0,ISC
-tough-cookie,2.3.2,New BSD
traverse,0.6.6,MIT
trim-newlines,1.0.0,MIT
trim-right,1.0.1,MIT
truncato,0.7.10,MIT
tryit,1.0.3,MIT
tty-browserify,0.0.0,MIT
-tunnel-agent,0.6.0,Apache 2.0
-tweetnacl,0.14.5,Unlicense
type-check,0.3.2,MIT
type-is,1.6.15,MIT
typedarray,0.0.6,MIT
tzinfo,1.2.3,MIT
u2f,0.2.1,MIT
+uber,0.1.0,MIT
uglifier,2.7.2,MIT
uglify-js,2.8.29,Simplified BSD
uglify-to-browserify,1.0.2,MIT
uglifyjs-webpack-plugin,0.4.6,MIT
-uid-number,0.0.6,ISC
ultron,1.1.0,MIT
unc-path-regex,0.1.2,MIT
undefsafe,0.0.3,MIT / http://rem.mit-license.org
underscore,1.8.3,MIT
unf,0.1.4,BSD
-unf_ext,0.0.7.2,MIT
+unf_ext,0.0.7.4,MIT
unicorn,5.1.0,ruby
unicorn-worker-killer,0.4.4,ruby
uniq,1.0.1,MIT
@@ -1166,13 +1118,12 @@ user-home,2.0.0,MIT
useragent,2.2.1,MIT
util,0.10.3,MIT
util-deprecate,1.0.2,MIT
-utils-merge,1.0.0,MIT
-uuid,3.0.1,MIT
+utils-merge,1.0.0,[Circular]
+uuid,2.0.3,MIT
validate-npm-package-license,3.0.1,Apache 2.0
validates_hostname,1.0.6,MIT
vary,1.1.1,MIT
vendors,1.0.1,MIT
-verror,1.3.6,MIT
version_sorter,2.1.0,MIT
virtus,1.0.5,MIT
visibilityjs,1.2.4,MIT
@@ -1200,12 +1151,11 @@ webpack-stats-plugin,0.1.5,MIT
websocket-driver,0.6.5,MIT
websocket-extensions,0.1.1,MIT
whet.extend,0.9.9,MIT
-which,1.2.12,ISC
+which,1.3.0,ISC
which-module,2.0.0,ISC
-wide-align,1.1.2,ISC
wikicloth,0.8.1,MIT
window-size,0.1.0,MIT
-wordwrap,0.0.2,MIT/X11
+wordwrap,1.0.0,MIT
wrap-ansi,2.1.0,MIT
wrappy,1.0.2,ISC
write,0.2.1,MIT
diff --git a/yarn.lock b/yarn.lock
index 57644482b32..91ffbe5d4b0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4148,9 +4148,9 @@ moment@2.x:
version "2.17.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82"
-monaco-editor@0.8.3:
- version "0.8.3"
- resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.8.3.tgz#523bdf2d1524db2c2dfc3cae0a7b6edc48d6dea6"
+monaco-editor@0.10.0:
+ version "0.10.0"
+ resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.10.0.tgz#6604932585fe9c1f993f000a503d0d20fbe5896a"
mousetrap@^1.4.6:
version "1.4.6"
@@ -4677,9 +4677,9 @@ pify@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
-pikaday@^1.5.1:
- version "1.5.1"
- resolved "https://registry.yarnpkg.com/pikaday/-/pikaday-1.5.1.tgz#0a48549bc1a14ea1d08c44074d761bc2f2bfcfd3"
+pikaday@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/pikaday/-/pikaday-1.6.1.tgz#b91bcb9b8539cedd8d6d08e4e7465e12095671b0"
optionalDependencies:
moment "2.x"
@@ -6405,9 +6405,9 @@ vue@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.6.tgz#451714b394dd6d4eae7b773c40c2034a59621aed"
-vuex@^2.3.1:
- version "2.3.1"
- resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.3.1.tgz#cde8e997c1f9957719bc7dea154f9aa691d981a6"
+vuex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.0.0.tgz#98b4b5c4954b1c1c1f5b29fa0476a23580315814"
watchpack@^1.4.0:
version "1.4.0"