summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml1
-rw-r--r--.rubocop.yml9
-rw-r--r--.rubocop_todo.yml7
-rw-r--r--CHANGELOG.md48
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--GITLAB_PAGES_VERSION2
-rw-r--r--Gemfile6
-rw-r--r--Gemfile.lock14
-rw-r--r--app/assets/images/i2p-step.svg4
-rw-r--r--app/assets/javascripts/activities.js5
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js1
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js3
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js4
-rw-r--r--app/assets/javascripts/boards/components/board.js23
-rw-r--r--app/assets/javascripts/boards/components/board_list.js13
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js2
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js1
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.js2
-rw-r--r--app/assets/javascripts/boards/models/list.js12
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js9
-rw-r--r--app/assets/javascripts/build.js54
-rw-r--r--app/assets/javascripts/commits.js37
-rw-r--r--app/assets/javascripts/commons/polyfills.js1
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue16
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue32
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue14
-rw-r--r--app/assets/javascripts/dispatcher.js30
-rw-r--r--app/assets/javascripts/dropzone_input.js7
-rw-r--r--app/assets/javascripts/environments/components/environment.vue4
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue65
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue109
-rw-r--r--app/assets/javascripts/filterable_list.js82
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js81
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js15
-rw-r--r--app/assets/javascripts/gl_dropdown.js18
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue27
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue220
-rw-r--r--app/assets/javascripts/groups/components/groups.vue39
-rw-r--r--app/assets/javascripts/groups/event_hub.js3
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js87
-rw-r--r--app/assets/javascripts/groups/index.js190
-rw-r--r--app/assets/javascripts/groups/services/groups_service.js38
-rw-r--r--app/assets/javascripts/groups/stores/groups_store.js151
-rw-r--r--app/assets/javascripts/importer_status.js2
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js159
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js165
-rw-r--r--app/assets/javascripts/issuable_index.js (renamed from app/assets/javascripts/issuable.js)81
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue28
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue12
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue56
-rw-r--r--app/assets/javascripts/issue_show/index.js3
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js9
-rw-r--r--app/assets/javascripts/issues_bulk_assignment.js166
-rw-r--r--app/assets/javascripts/labels_select.js15
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js4
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js4
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js64
-rw-r--r--app/assets/javascripts/locale/bg/app.js1
-rw-r--r--app/assets/javascripts/locale/de/app.js2
-rw-r--r--app/assets/javascripts/locale/en/app.js2
-rw-r--r--app/assets/javascripts/locale/es/app.js2
-rw-r--r--app/assets/javascripts/locale/pt_BR/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_CN/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_HK/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_TW/app.js1
-rw-r--r--app/assets/javascripts/main.js3
-rw-r--r--app/assets/javascripts/notes.js105
-rw-r--r--app/assets/javascripts/pager.js5
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue8
-rw-r--r--app/assets/javascripts/pipelines/pipelines.js2
-rw-r--r--app/assets/javascripts/project_new.js63
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js4
-rw-r--r--app/assets/javascripts/settings_panels.js27
-rw-r--r--app/assets/javascripts/user_callout.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.js6
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/awards.scss8
-rw-r--r--app/assets/stylesheets/framework/common.scss6
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss14
-rw-r--r--app/assets/stylesheets/framework/filters.scss28
-rw-r--r--app/assets/stylesheets/framework/lists.scss100
-rw-r--r--app/assets/stylesheets/framework/mobile.scss9
-rw-r--r--app/assets/stylesheets/framework/nav.scss27
-rw-r--r--app/assets/stylesheets/framework/responsive-tables.scss91
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss36
-rw-r--r--app/assets/stylesheets/framework/variables.scss8
-rw-r--r--app/assets/stylesheets/pages/boards.scss83
-rw-r--r--app/assets/stylesheets/pages/builds.scss28
-rw-r--r--app/assets/stylesheets/pages/commits.scss2
-rw-r--r--app/assets/stylesheets/pages/convdev_index.scss255
-rw-r--r--app/assets/stylesheets/pages/environments.scss82
-rw-r--r--app/assets/stylesheets/pages/issuable.scss32
-rw-r--r--app/assets/stylesheets/pages/issues.scss19
-rw-r--r--app/assets/stylesheets/pages/members.scss35
-rw-r--r--app/assets/stylesheets/pages/note_form.scss20
-rw-r--r--app/assets/stylesheets/pages/notes.scss52
-rw-r--r--app/assets/stylesheets/pages/profile.scss25
-rw-r--r--app/assets/stylesheets/pages/projects.scss20
-rw-r--r--app/assets/stylesheets/pages/settings.scss87
-rw-r--r--app/controllers/admin/application_settings_controller.rb1
-rw-r--r--app/controllers/admin/applications_controller.rb2
-rw-r--r--app/controllers/admin/conversational_development_index_controller.rb5
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb26
-rw-r--r--app/controllers/admin/groups_controller.rb11
-rw-r--r--app/controllers/admin/hooks_controller.rb2
-rw-r--r--app/controllers/admin/identities_controller.rb4
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/admin/keys_controller.rb4
-rw-r--r--app/controllers/admin/labels_controller.rb2
-rw-r--r--app/controllers/admin/runner_projects_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb2
-rw-r--r--app/controllers/admin/spam_logs_controller.rb4
-rw-r--r--app/controllers/admin/users_controller.rb4
-rw-r--r--app/controllers/autocomplete_controller.rb2
-rw-r--r--app/controllers/concerns/lfs_request.rb4
-rw-r--r--app/controllers/concerns/membership_actions.rb16
-rw-r--r--app/controllers/concerns/milestone_actions.rb4
-rw-r--r--app/controllers/dashboard/groups_controller.rb28
-rw-r--r--app/controllers/dashboard/milestones_controller.rb4
-rw-r--r--app/controllers/dashboard/todos_controller.rb8
-rw-r--r--app/controllers/groups/avatars_controller.rb2
-rw-r--r--app/controllers/groups/labels_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb4
-rw-r--r--app/controllers/health_controller.rb17
-rw-r--r--app/controllers/jwt_controller.rb8
-rw-r--r--app/controllers/metrics_controller.rb21
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb4
-rw-r--r--app/controllers/profiles/avatars_controller.rb2
-rw-r--r--app/controllers/profiles/chat_names_controller.rb2
-rw-r--r--app/controllers/profiles/emails_controller.rb2
-rw-r--r--app/controllers/profiles/keys_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb2
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb4
-rw-r--r--app/controllers/projects/avatars_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb14
-rw-r--r--app/controllers/projects/boards/lists_controller.rb4
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb29
-rw-r--r--app/controllers/projects/git_http_client_controller.rb26
-rw-r--r--app/controllers/projects/git_http_controller.rb75
-rw-r--r--app/controllers/projects/group_links_controller.rb2
-rw-r--r--app/controllers/projects/hooks_controller.rb2
-rw-r--r--app/controllers/projects/imports_controller.rb9
-rw-r--r--app/controllers/projects/issues_controller.rb13
-rw-r--r--app/controllers/projects/jobs_controller.rb11
-rw-r--r--app/controllers/projects/labels_controller.rb10
-rw-r--r--app/controllers/projects/milestones_controller.rb2
-rw-r--r--app/controllers/projects/pages_controller.rb5
-rw-r--r--app/controllers/projects/pages_domains_controller.rb5
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb8
-rw-r--r--app/controllers/projects/pipelines_controller.rb2
-rw-r--r--app/controllers/projects/protected_branches_controller.rb4
-rw-r--r--app/controllers/projects/protected_refs_controller.rb6
-rw-r--r--app/controllers/projects/protected_tags_controller.rb2
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb2
-rw-r--r--app/controllers/projects/registry/tags_controller.rb2
-rw-r--r--app/controllers/projects/runner_projects_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb2
-rw-r--r--app/controllers/projects/snippets_controller.rb4
-rw-r--r--app/controllers/projects/triggers_controller.rb2
-rw-r--r--app/controllers/projects/variables_controller.rb4
-rw-r--r--app/controllers/projects/wikis_controller.rb7
-rw-r--r--app/controllers/projects_controller.rb30
-rw-r--r--app/controllers/registrations_controller.rb4
-rw-r--r--app/controllers/sessions_controller.rb11
-rw-r--r--app/controllers/sherlock/transactions_controller.rb2
-rw-r--r--app/controllers/snippets_controller.rb12
-rw-r--r--app/controllers/uploads_controller.rb13
-rw-r--r--app/finders/events_finder.rb62
-rw-r--r--app/finders/todos_finder.rb2
-rw-r--r--app/helpers/application_helper.rb6
-rw-r--r--app/helpers/button_helper.rb4
-rw-r--r--app/helpers/ci_status_helper.rb39
-rw-r--r--app/helpers/conversational_development_index_helper.rb16
-rw-r--r--app/helpers/dropdowns_helper.rb16
-rw-r--r--app/helpers/gitlab_routing_helper.rb2
-rw-r--r--app/helpers/groups_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb20
-rw-r--r--app/helpers/milestones_helper.rb6
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb8
-rw-r--r--app/helpers/notifications_helper.rb39
-rw-r--r--app/helpers/profiles_helper.rb7
-rw-r--r--app/helpers/projects_helper.rb43
-rw-r--r--app/helpers/submodule_helper.rb11
-rw-r--r--app/helpers/visibility_level_helper.rb10
-rw-r--r--app/models/abuse_report.rb3
-rw-r--r--app/models/application_setting.rb15
-rw-r--r--app/models/award_emoji.rb2
-rw-r--r--app/models/blob.rb17
-rw-r--r--app/models/blob_viewer/base.rb16
-rw-r--r--app/models/blob_viewer/empty.rb1
-rw-r--r--app/models/blob_viewer/server_side.rb4
-rw-r--r--app/models/board.rb4
-rw-r--r--app/models/ci/build.rb60
-rw-r--r--app/models/ci/legacy_stage.rb64
-rw-r--r--app/models/ci/pipeline.rb39
-rw-r--r--app/models/ci/stage.rb65
-rw-r--r--app/models/commit.rb31
-rw-r--r--app/models/commit_status.rb14
-rw-r--r--app/models/concerns/editable.rb7
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/concerns/protected_ref.rb28
-rw-r--r--app/models/conversational_development_index/card.rb26
-rw-r--r--app/models/conversational_development_index/idea_to_production_step.rb19
-rw-r--r--app/models/conversational_development_index/metric.rb21
-rw-r--r--app/models/deployment.rb2
-rw-r--r--app/models/event.rb34
-rw-r--r--app/models/group.rb10
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/key.rb9
-rw-r--r--app/models/label.rb2
-rw-r--r--app/models/label_link.rb2
-rw-r--r--app/models/lfs_objects_project.rb3
-rw-r--r--app/models/list.rb4
-rw-r--r--app/models/member.rb6
-rw-r--r--app/models/members/group_member.rb4
-rw-r--r--app/models/members/project_member.rb4
-rw-r--r--app/models/namespace.rb5
-rw-r--r--app/models/note.rb3
-rw-r--r--app/models/notification_setting.rb2
-rw-r--r--app/models/pages_domain.rb4
-rw-r--r--app/models/personal_access_token.rb11
-rw-r--r--app/models/project.rb69
-rw-r--r--app/models/protected_branch.rb9
-rw-r--r--app/models/protected_tag.rb6
-rw-r--r--app/models/redirect_route.rb2
-rw-r--r--app/models/repository.rb4
-rw-r--r--app/models/route.rb2
-rw-r--r--app/models/sent_notification.rb2
-rw-r--r--app/models/snippet.rb2
-rw-r--r--app/models/spam_log.rb3
-rw-r--r--app/models/subscription.rb2
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/upload.rb2
-rw-r--r--app/models/user.rb7
-rw-r--r--app/models/user_agent_detail.rb2
-rw-r--r--app/policies/deploy_key_policy.rb11
-rw-r--r--app/policies/group_policy.rb17
-rw-r--r--app/policies/project_snippet_policy.rb5
-rw-r--r--app/presenters/conversational_development_index/metric_presenter.rb144
-rw-r--r--app/presenters/merge_request_presenter.rb16
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb14
-rw-r--r--app/serializers/build_artifact_entity.rb37
-rw-r--r--app/serializers/build_details_entity.rb50
-rw-r--r--app/serializers/build_entity.rb2
-rw-r--r--app/serializers/deploy_key_entity.rb7
-rw-r--r--app/serializers/group_entity.rb46
-rw-r--r--app/serializers/group_serializer.rb19
-rw-r--r--app/serializers/merge_request_entity.rb2
-rw-r--r--app/serializers/pipeline_details_entity.rb7
-rw-r--r--app/serializers/pipeline_entity.rb20
-rw-r--r--app/serializers/pipeline_serializer.rb9
-rw-r--r--app/serializers/runner_entity.rb18
-rw-r--r--app/services/boards/create_service.rb1
-rw-r--r--app/services/boards/issues/list_service.rb2
-rw-r--r--app/services/boards/lists/list_service.rb2
-rw-r--r--app/services/ci/create_pipeline_builds_service.rb51
-rw-r--r--app/services/ci/create_pipeline_service.rb12
-rw-r--r--app/services/ci/create_pipeline_stages_service.rb20
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/compare_service.rb6
-rw-r--r--app/services/create_deployment_service.rb76
-rw-r--r--app/services/issuable_base_service.rb2
-rw-r--r--app/services/members/create_service.rb22
-rw-r--r--app/services/metrics_service.rb33
-rw-r--r--app/services/projects/create_service.rb5
-rw-r--r--app/services/projects/destroy_service.rb14
-rw-r--r--app/services/slash_commands/interpret_service.rb24
-rw-r--r--app/services/submit_usage_ping_service.rb41
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/destroy_service.rb17
-rw-r--r--app/uploaders/file_mover.rb63
-rw-r--r--app/uploaders/file_uploader.rb7
-rw-r--r--app/uploaders/gitlab_uploader.rb20
-rw-r--r--app/uploaders/lfs_object_uploader.rb16
-rw-r--r--app/uploaders/personal_file_uploader.rb6
-rw-r--r--app/uploaders/records_uploads.rb7
-rw-r--r--app/views/admin/application_settings/_form.html.haml17
-rw-r--r--app/views/admin/background_jobs/show.html.haml2
-rw-r--r--app/views/admin/conversational_development_index/_callout.html.haml13
-rw-r--r--app/views/admin/conversational_development_index/_card.html.haml25
-rw-r--r--app/views/admin/conversational_development_index/_disabled.html.haml9
-rw-r--r--app/views/admin/conversational_development_index/_no_data.html.haml7
-rw-r--r--app/views/admin/conversational_development_index/show.html.haml35
-rw-r--r--app/views/admin/deploy_keys/edit.html.haml10
-rw-r--r--app/views/admin/deploy_keys/index.html.haml4
-rw-r--r--app/views/admin/deploy_keys/new.html.haml29
-rw-r--r--app/views/admin/health_check/show.html.haml11
-rw-r--r--app/views/admin/logs/show.html.haml2
-rw-r--r--app/views/admin/monitoring/_head.html.haml (renamed from app/views/admin/background_jobs/_head.html.haml)4
-rw-r--r--app/views/admin/requests_profiles/index.html.haml2
-rw-r--r--app/views/admin/runners/index.html.haml8
-rw-r--r--app/views/admin/system_info/show.html.haml2
-rw-r--r--app/views/admin/users/_user.html.haml14
-rw-r--r--app/views/admin/users/show.html.haml21
-rw-r--r--app/views/dashboard/groups/_groups.html.haml13
-rw-r--r--app/views/dashboard/groups/index.html.haml5
-rw-r--r--app/views/dashboard/projects/index.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/header/_default.html.haml11
-rw-r--r--app/views/layouts/header/_new_dropdown.haml45
-rw-r--r--app/views/layouts/nav/_admin.html.haml4
-rw-r--r--app/views/layouts/snippets.html.haml5
-rw-r--r--app/views/profiles/show.html.haml4
-rw-r--r--app/views/projects/_find_file_link.html.haml2
-rw-r--r--app/views/projects/_head.html.haml15
-rw-r--r--app/views/projects/_home_panel.html.haml2
-rw-r--r--app/views/projects/_last_push.html.haml2
-rw-r--r--app/views/projects/_md_preview.html.haml6
-rw-r--r--app/views/projects/blob/_new_dir.html.haml8
-rw-r--r--app/views/projects/blob/_viewer.html.haml14
-rw-r--r--app/views/projects/boards/_show.html.haml1
-rw-r--r--app/views/projects/boards/components/_board.html.haml11
-rw-r--r--app/views/projects/buttons/_download.html.haml19
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml18
-rw-r--r--app/views/projects/buttons/_fork.html.haml10
-rw-r--r--app/views/projects/buttons/_koding.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml8
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/commits/_commit.html.haml4
-rw-r--r--app/views/projects/commits/_commits.html.haml7
-rw-r--r--app/views/projects/commits/_head.html.haml16
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml1
-rw-r--r--app/views/projects/deploy_keys/_deploy_key.html.haml30
-rw-r--r--app/views/projects/deploy_keys/_form.html.haml2
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml14
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml10
-rw-r--r--app/views/projects/deploy_keys/new.html.haml5
-rw-r--r--app/views/projects/deployments/_commit.html.haml31
-rw-r--r--app/views/projects/deployments/_deployment.html.haml24
-rw-r--r--app/views/projects/diffs/_content.html.haml2
-rw-r--r--app/views/projects/diffs/viewers/_text.html.haml2
-rw-r--r--app/views/projects/edit.html.haml6
-rw-r--r--app/views/projects/environments/show.html.haml16
-rw-r--r--app/views/projects/find_file/show.html.haml2
-rw-r--r--app/views/projects/group_links/_index.html.haml53
-rw-r--r--app/views/projects/issues/_issue.html.haml4
-rw-r--r--app/views/projects/issues/index.html.haml7
-rw-r--r--app/views/projects/issues/show.html.haml10
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml2
-rw-r--r--app/views/projects/jobs/show.html.haml27
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml4
-rw-r--r--app/views/projects/merge_requests/index.html.haml7
-rw-r--r--app/views/projects/new.html.haml2
-rw-r--r--app/views/projects/no_repo.html.haml12
-rw-r--r--app/views/projects/notes/_actions.html.haml6
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml14
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml20
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml12
-rw-r--r--app/views/projects/pipeline_schedules/_table.html.haml10
-rw-r--r--app/views/projects/pipeline_schedules/_tabs.html.haml6
-rw-r--r--app/views/projects/pipeline_schedules/edit.html.haml4
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml4
-rw-r--r--app/views/projects/pipeline_schedules/new.html.haml4
-rw-r--r--app/views/projects/pipelines/_head.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/project_members/_index.html.haml20
-rw-r--r--app/views/projects/project_members/_new_project_member.html.haml37
-rw-r--r--app/views/projects/project_members/_new_shared_group.html.haml20
-rw-r--r--app/views/projects/protected_branches/_index.html.haml19
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml4
-rw-r--r--app/views/projects/protected_tags/_index.html.haml22
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/_tags_list.html.haml4
-rw-r--r--app/views/projects/protected_tags/show.html.haml2
-rw-r--r--app/views/projects/settings/members/show.html.haml3
-rw-r--r--app/views/projects/settings/repository/show.html.haml3
-rw-r--r--app/views/projects/show.html.haml32
-rw-r--r--app/views/projects/tree/_tree_content.html.haml8
-rw-r--r--app/views/projects/tree/_tree_header.html.haml20
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml10
-rw-r--r--app/views/shared/_clone_panel.html.haml2
-rw-r--r--app/views/shared/_label.html.haml2
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml2
-rw-r--r--app/views/shared/_no_password.html.haml8
-rw-r--r--app/views/shared/_no_ssh.html.haml9
-rw-r--r--app/views/shared/_ref_switcher.html.haml6
-rw-r--r--app/views/shared/_user_callout.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml30
-rw-r--r--app/views/shared/form_elements/_description.html.haml (renamed from app/views/shared/issuable/form/_description.html.haml)7
-rw-r--r--app/views/shared/groups/_dropdown.html.haml12
-rw-r--r--app/views/shared/icons/_convdev_no_data.svg40
-rw-r--r--app/views/shared/icons/_convdev_no_index.svg67
-rw-r--r--app/views/shared/icons/_convdev_overview.svg64
-rw-r--r--app/views/shared/icons/_i2p_step_1.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_10.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_2.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_3.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_4.svg6
-rw-r--r--app/views/shared/icons/_i2p_step_5.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_6.svg15
-rw-r--r--app/views/shared/icons/_i2p_step_7.svg7
-rw-r--r--app/views/shared/icons/_i2p_step_8.svg4
-rw-r--r--app/views/shared/icons/_i2p_step_9.svg4
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml53
-rw-r--r--app/views/shared/issuable/_filter.html.haml33
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml7
-rw-r--r--app/views/shared/issuable/_nav.html.haml19
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml51
-rw-r--r--app/views/shared/members/_access_request_buttons.html.haml7
-rw-r--r--app/views/shared/notifications/_button.html.haml4
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml11
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--app/views/shared/snippets/_form.html.haml7
-rw-r--r--app/views/shared/snippets/_header.html.haml6
-rw-r--r--app/views/snippets/notes/_actions.html.haml7
-rw-r--r--app/views/users/show.html.haml2
-rw-r--r--app/workers/build_success_worker.rb11
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb18
-rw-r--r--app/workers/post_receive.rb38
-rw-r--r--app/workers/repository_fork_worker.rb38
-rw-r--r--app/workers/repository_import_worker.rb24
-rw-r--r--changelogs/unreleased/12614-fix-long-message.yml4
-rw-r--r--changelogs/unreleased/12910-snippets-description.yml4
-rw-r--r--changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml4
-rw-r--r--changelogs/unreleased/23603-add-extra-functionality-for-the-top-right-button.yml4
-rw-r--r--changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml4
-rw-r--r--changelogs/unreleased/25426-group-dashboard-ui.yml4
-rw-r--r--changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml4
-rw-r--r--changelogs/unreleased/27148-limit-bulk-create-memberships.yml4
-rw-r--r--changelogs/unreleased/27614-improve-instant-comments-exp.yml4
-rw-r--r--changelogs/unreleased/28607-forking-and-configuring-project-via-api-works-very-unreliable.yml4
-rw-r--r--changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml4
-rw-r--r--changelogs/unreleased/28694-hard-delete-user-from-api.yml4
-rw-r--r--changelogs/unreleased/29118-add-prometheus-instrumenting-to-gitlab-webapp.yml4
-rw-r--r--changelogs/unreleased/29690-rotate-otp-key-base.yml4
-rw-r--r--changelogs/unreleased/30378-simplified-repository-settings-page.yml4
-rw-r--r--changelogs/unreleased/30469-convdev-index.yml4
-rw-r--r--changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml4
-rw-r--r--changelogs/unreleased/31633-animate-issue.yml4
-rw-r--r--changelogs/unreleased/31757-single-click-on-filter-in-search-bar-to-activate-dropdown.yml4
-rw-r--r--changelogs/unreleased/31840-add-a-rubocop-that-forbids-redirect_to-inside-a-controller-destroy-action-without-an-explicit-status.yml4
-rw-r--r--changelogs/unreleased/3191-deploy-keys-update.yml4
-rw-r--r--changelogs/unreleased/31943-document-go-183.yml3
-rw-r--r--changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml4
-rw-r--r--changelogs/unreleased/32118-new-environment-btn-copy.yml4
-rw-r--r--changelogs/unreleased/32642_last_commit_id_in_file_api.yml4
-rw-r--r--changelogs/unreleased/32715-fix-note-padding.yml4
-rw-r--r--changelogs/unreleased/32720-emoji-spacing.yml4
-rw-r--r--changelogs/unreleased/32832-confidential-issue-overflow.yml5
-rw-r--r--changelogs/unreleased/32955-special-keywords.yml4
-rw-r--r--changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml4
-rw-r--r--changelogs/unreleased/32995-issue-contents-dynamically-replaced-with-stale-version-after-saving-or-refreshing-relative-external_url-only.yml4
-rw-r--r--changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml4
-rw-r--r--changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml4
-rw-r--r--changelogs/unreleased/33308-use-pre-wrap-for-commit-messages.yml4
-rw-r--r--changelogs/unreleased/33334-portuguese_brazil_translation_of_cycle_analytics_page.yml4
-rw-r--r--changelogs/unreleased/33381-display-issue-state-in-mr-widget-issue-links.yml4
-rw-r--r--changelogs/unreleased/33383-bulgarian_translation_of_cycle_analytics_page.yml4
-rw-r--r--changelogs/unreleased/allow-reporters-to-promote-group-labels.yml4
-rw-r--r--changelogs/unreleased/allow_numeric_pages_domain.yml4
-rw-r--r--changelogs/unreleased/artifacts-keyboard-shortcuts.yml4
-rw-r--r--changelogs/unreleased/auto-search-when-state-changed.yml4
-rw-r--r--changelogs/unreleased/bvl-translate-project-pages.yml4
-rw-r--r--changelogs/unreleased/ce-31853-projects-shared-groups.yml4
-rw-r--r--changelogs/unreleased/dashboard-milestone-tabs-loading-async.yml4
-rw-r--r--changelogs/unreleased/dm-blob-binaryness-change.yml5
-rw-r--r--changelogs/unreleased/dz-fix-submodule-subgroup.yml4
-rw-r--r--changelogs/unreleased/environment-detail-view.yml4
-rw-r--r--changelogs/unreleased/expand-backlog-closed-lists-issue-boards.yml4
-rw-r--r--changelogs/unreleased/feature-gb-persist-pipeline-stages.yml4
-rw-r--r--changelogs/unreleased/fix-backup-restore-resume.yml4
-rw-r--r--changelogs/unreleased/fix-encoding-binary-issue.yml4
-rw-r--r--changelogs/unreleased/fix_commits_page.yml4
-rw-r--r--changelogs/unreleased/fixed-confidential-issue-bar.yml4
-rw-r--r--changelogs/unreleased/issuable-sidebar-edit-button-field-focus.yml4
-rw-r--r--changelogs/unreleased/issue-23254.yml4
-rw-r--r--changelogs/unreleased/issue_27166_2.yml4
-rw-r--r--changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml5
-rw-r--r--changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml4
-rw-r--r--changelogs/unreleased/mk-fix-git-over-http-rejections.yml4
-rw-r--r--changelogs/unreleased/pat-msg-on-auth-failure.yml4
-rw-r--r--changelogs/unreleased/projects-api-import-status.yml4
-rw-r--r--changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml4
-rw-r--r--changelogs/unreleased/sync-email-from-omniauth.yml4
-rw-r--r--changelogs/unreleased/zj-fix-pipeline-etag.yml4
-rw-r--r--changelogs/unreleased/zj-i18n-pipeline-schedules.yml4
-rw-r--r--changelogs/unreleased/zj-job-view-goes-real-time.yml4
-rw-r--r--changelogs/unreleased/zj-prom-pipeline-count.yml4
-rw-r--r--changelogs/unreleased/zj-read-registry-pat.yml4
-rw-r--r--config.ru3
-rw-r--r--config/boot.rb12
-rw-r--r--config/gitlab.yml.example4
-rw-r--r--config/initializers/1_settings.rb1
-rw-r--r--config/initializers/forbid_sidekiq_in_transactions.rb49
-rw-r--r--config/locales/de.yml37
-rw-r--r--config/locales/es.yml2
-rw-r--r--config/routes.rb8
-rw-r--r--config/routes/admin.rb4
-rw-r--r--config/routes/dashboard.rb8
-rw-r--r--config/routes/project.rb2
-rw-r--r--config/routes/snippets.rb3
-rw-r--r--config/routes/uploads.rb11
-rw-r--r--config/webpack.config.js26
-rw-r--r--db/fixtures/development/04_project.rb4
-rw-r--r--db/fixtures/development/11_keys.rb11
-rw-r--r--db/fixtures/development/14_pipelines.rb4
-rw-r--r--db/fixtures/development/17_cycle_analytics.rb9
-rw-r--r--db/fixtures/development/21_conversational_development_index_metrics.rb40
-rw-r--r--db/fixtures/production/010_settings.rb24
-rw-r--r--db/migrate/20170316163800_rename_system_namespaces.rb231
-rw-r--r--db/migrate/20170316163845_move_uploads_to_system_dir.rb59
-rw-r--r--db/migrate/20170503114228_add_description_to_snippets.rb12
-rw-r--r--db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb16
-rw-r--r--db/migrate/20170523121229_create_conversational_development_index_metrics.rb39
-rw-r--r--db/migrate/20170525132202_create_pipeline_stages.rb25
-rw-r--r--db/migrate/20170526185602_add_stage_id_to_ci_builds.rb21
-rw-r--r--db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb15
-rw-r--r--db/migrate/20170603200744_add_email_provider_to_users.rb9
-rw-r--r--db/post_migrate/20170317162059_update_upload_paths_to_system.rb55
-rw-r--r--db/post_migrate/20170406111121_clean_upload_symlinks.rb52
-rw-r--r--db/post_migrate/20170526185842_migrate_pipeline_stages.rb22
-rw-r--r--db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb15
-rw-r--r--db/post_migrate/20170526185921_migrate_build_stage_reference.rb25
-rw-r--r--db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb15
-rw-r--r--db/post_migrate/20170606202615_move_appearance_to_system_dir.rb57
-rw-r--r--db/schema.rb53
-rw-r--r--doc/administration/container_registry.md30
-rw-r--r--doc/administration/job_artifacts.md36
-rw-r--r--doc/api/README.md15
-rw-r--r--doc/api/environments.md (renamed from doc/api/enviroments.md)0
-rw-r--r--doc/api/events.md347
-rw-r--r--doc/api/project_snippets.md3
-rw-r--r--doc/api/projects.md151
-rw-r--r--doc/api/repository_files.md1
-rw-r--r--doc/api/snippets.md33
-rw-r--r--doc/api/users.md144
-rw-r--r--doc/ci/docker/using_docker_images.md70
-rw-r--r--doc/ci/environments.md6
-rw-r--r--doc/ci/examples/code_climate.md12
-rw-r--r--doc/ci/runners/README.md214
-rw-r--r--doc/ci/runners/img/shared_runners_admin.pngbin0 -> 29192 bytes
-rw-r--r--doc/ci/runners/project_specific.pngbin30196 -> 0 bytes
-rw-r--r--doc/ci/runners/shared_runner.pngbin17797 -> 0 bytes
-rw-r--r--doc/ci/triggers/README.md160
-rw-r--r--doc/ci/triggers/img/triggers_page.pngbin110560 -> 20857 bytes
-rw-r--r--doc/ci/variables/README.md43
-rw-r--r--doc/ci/yaml/README.md29
-rw-r--r--doc/development/README.md2
-rw-r--r--doc/development/architecture.md8
-rw-r--r--doc/development/fe_guide/testing.md2
-rw-r--r--doc/development/polymorphic_associations.md146
-rw-r--r--doc/development/single_table_inheritance.md18
-rw-r--r--doc/install/installation.md15
-rw-r--r--doc/install/kubernetes/gitlab_chart.md4
-rw-r--r--doc/install/kubernetes/gitlab_runner_chart.md4
-rw-r--r--doc/install/requirements.md3
-rw-r--r--doc/integration/saml.md3
-rw-r--r--doc/raketasks/user_management.md79
-rw-r--r--doc/update/9.2-to-9.3.md36
-rw-r--r--doc/user/permissions.md2
-rw-r--r--doc/user/profile/account/delete_account.md22
-rw-r--r--doc/user/project/container_registry.md13
-rw-r--r--doc/user/project/img/project_settings_list.pngbin5919 -> 0 bytes
-rw-r--r--doc/user/project/img/protected_branches_delete.pngbin0 -> 21510 bytes
-rw-r--r--doc/user/project/integrations/img/accessing_integrations.pngbin8941 -> 0 bytes
-rw-r--r--doc/user/project/integrations/index.md8
-rw-r--r--doc/user/project/integrations/project_services.md15
-rw-r--r--doc/user/project/integrations/webhooks.md7
-rw-r--r--doc/user/project/issues/index.md8
-rw-r--r--doc/user/project/pipelines/job_artifacts.md30
-rw-r--r--doc/user/project/pipelines/settings.md9
-rw-r--r--doc/user/project/protected_branches.md34
-rw-r--r--features/project/issues/issues.feature2
-rw-r--r--features/project/merge_requests.feature2
-rw-r--r--features/steps/dashboard/new_project.rb8
-rw-r--r--features/steps/groups.rb2
-rw-r--r--features/steps/profile/profile.rb2
-rw-r--r--features/steps/project/create.rb4
-rw-r--r--features/steps/project/fork.rb6
-rw-r--r--features/steps/project/forked_merge_requests.rb4
-rw-r--r--features/steps/project/issues/issues.rb8
-rw-r--r--features/steps/project/merge_requests.rb16
-rw-r--r--features/steps/project/project.rb2
-rw-r--r--features/steps/project/project_group_links.rb7
-rw-r--r--features/steps/project/snippets.rb4
-rw-r--r--features/steps/shared/note.rb16
-rw-r--r--lib/api/api.rb2
-rw-r--r--lib/api/deploy_keys.rb21
-rw-r--r--lib/api/entities.rb6
-rw-r--r--lib/api/events.rb86
-rw-r--r--lib/api/files.rb13
-rw-r--r--lib/api/groups.rb4
-rw-r--r--lib/api/helpers.rb2
-rw-r--r--lib/api/helpers/internal_helpers.rb16
-rw-r--r--lib/api/internal.rb34
-rw-r--r--lib/api/project_snippets.rb2
-rw-r--r--lib/api/projects.rb14
-rw-r--r--lib/api/runner.rb2
-rw-r--r--lib/api/settings.rb1
-rw-r--r--lib/api/snippets.rb2
-rw-r--r--lib/api/time_tracking_endpoints.rb2
-rw-r--r--lib/api/users.rb28
-rw-r--r--lib/api/v3/files.rb2
-rw-r--r--lib/api/v3/projects.rb2
-rw-r--r--lib/api/v3/time_tracking_endpoints.rb2
-rw-r--r--lib/backup/repository.rb75
-rw-r--r--lib/banzai/reference_parser/base_parser.rb4
-rw-r--r--lib/banzai/reference_parser/commit_parser.rb2
-rw-r--r--lib/banzai/reference_parser/commit_range_parser.rb2
-rw-r--r--lib/banzai/reference_parser/external_issue_parser.rb2
-rw-r--r--lib/banzai/reference_parser/label_parser.rb2
-rw-r--r--lib/banzai/reference_parser/merge_request_parser.rb4
-rw-r--r--lib/banzai/reference_parser/milestone_parser.rb2
-rw-r--r--lib/banzai/reference_parser/snippet_parser.rb4
-rw-r--r--lib/banzai/reference_parser/user_parser.rb2
-rw-r--r--lib/bitbucket/representation/pull_request_comment.rb4
-rw-r--r--lib/ci/api/builds.rb2
-rw-r--r--lib/ci/gitlab_ci_yaml_processor.rb65
-rw-r--r--lib/feature.rb12
-rw-r--r--lib/gitlab/auth.rb41
-rw-r--r--lib/gitlab/auth/result.rb4
-rw-r--r--lib/gitlab/blame.rb2
-rw-r--r--lib/gitlab/chat_commands/presenters/base.rb4
-rw-r--r--lib/gitlab/checks/change_access.rb48
-rw-r--r--lib/gitlab/ci/stage/seed.rb49
-rw-r--r--lib/gitlab/ci/status/canceled.rb4
-rw-r--r--lib/gitlab/ci/status/created.rb4
-rw-r--r--lib/gitlab/ci/status/failed.rb4
-rw-r--r--lib/gitlab/ci/status/manual.rb4
-rw-r--r--lib/gitlab/ci/status/pending.rb4
-rw-r--r--lib/gitlab/ci/status/pipeline/blocked.rb4
-rw-r--r--lib/gitlab/ci/status/running.rb4
-rw-r--r--lib/gitlab/ci/status/skipped.rb4
-rw-r--r--lib/gitlab/ci/status/success.rb4
-rw-r--r--lib/gitlab/ci/status/success_warning.rb4
-rw-r--r--lib/gitlab/ci_access.rb9
-rw-r--r--lib/gitlab/contributions_calendar.rb2
-rw-r--r--lib/gitlab/current_settings.rb54
-rw-r--r--lib/gitlab/data_builder/pipeline.rb2
-rw-r--r--lib/gitlab/diff/diff_refs.rb10
-rw-r--r--lib/gitlab/diff/file.rb50
-rw-r--r--lib/gitlab/diff/file_collection/merge_request_diff.rb5
-rw-r--r--lib/gitlab/diff/highlight.rb17
-rw-r--r--lib/gitlab/diff/position.rb18
-rw-r--r--lib/gitlab/diff/position_tracer.rb2
-rw-r--r--lib/gitlab/encoding_helper.rb2
-rw-r--r--lib/gitlab/etag_caching/middleware.rb9
-rw-r--r--lib/gitlab/etag_caching/router.rb12
-rw-r--r--lib/gitlab/git/blob.rb1
-rw-r--r--lib/gitlab/git/compare.rb2
-rw-r--r--lib/gitlab/git/diff.rb50
-rw-r--r--lib/gitlab/git/repository.rb5
-rw-r--r--lib/gitlab/git_access.rb92
-rw-r--r--lib/gitlab/git_access_status.rb15
-rw-r--r--lib/gitlab/git_access_wiki.rb12
-rw-r--r--lib/gitlab/gitaly_client/commit.rb2
-rw-r--r--lib/gitlab/gitaly_client/diff.rb21
-rw-r--r--lib/gitlab/gitaly_client/diff_stitcher.rb31
-rw-r--r--lib/gitlab/google_code_import/client.rb2
-rw-r--r--lib/gitlab/google_code_import/importer.rb18
-rw-r--r--lib/gitlab/health_checks/prometheus_text_format.rb40
-rw-r--r--lib/gitlab/highlight.rb8
-rw-r--r--lib/gitlab/i18n.rb7
-rw-r--r--lib/gitlab/import_export/import_export.yml1
-rw-r--r--lib/gitlab/import_export/relation_factory.rb1
-rw-r--r--lib/gitlab/ldap/user.rb13
-rw-r--r--lib/gitlab/metrics.rb157
-rw-r--r--lib/gitlab/metrics/influx_db.rb170
-rw-r--r--lib/gitlab/metrics/null_metric.rb10
-rw-r--r--lib/gitlab/metrics/prometheus.rb41
-rw-r--r--lib/gitlab/o_auth/user.rb17
-rw-r--r--lib/gitlab/otp_key_rotator.rb87
-rw-r--r--lib/gitlab/path_regex.rb1
-rw-r--r--lib/gitlab/route_map.rb4
-rw-r--r--lib/gitlab/slash_commands/command_definition.rb10
-rw-r--r--lib/gitlab/slash_commands/dsl.rb4
-rw-r--r--lib/gitlab/uploads_transfer.rb2
-rw-r--r--lib/gitlab/url_builder.rb7
-rw-r--r--lib/gitlab/visibility_level.rb8
-rw-r--r--lib/gitlab/workhorse.rb2
-rw-r--r--lib/rouge/lexers/math.rb16
-rw-r--r--lib/rouge/lexers/plantuml.rb16
-rw-r--r--lib/tasks/gitlab/two_factor.rake16
-rw-r--r--lib/tasks/import.rake2
-rw-r--r--locale/bg/gitlab.po260
-rw-r--r--locale/bg/gitlab.po.time_stamp0
-rw-r--r--locale/de/gitlab.pobin7647 -> 18834 bytes
-rw-r--r--locale/en/gitlab.po706
-rw-r--r--locale/es/gitlab.pobin8013 -> 19238 bytes
-rw-r--r--locale/gitlab.pot710
-rw-r--r--locale/pt_BR/gitlab.po260
-rw-r--r--locale/pt_BR/gitlab.po.time_stamp0
-rw-r--r--locale/zh_CN/gitlab.po916
-rw-r--r--locale/zh_CN/gitlab.po.time_stamp0
-rw-r--r--locale/zh_HK/gitlab.po916
-rw-r--r--locale/zh_HK/gitlab.po.time_stamp0
-rw-r--r--locale/zh_TW/gitlab.po916
-rw-r--r--locale/zh_TW/gitlab.po.time_stamp0
-rw-r--r--rubocop/cop/activerecord_serialize.rb16
-rw-r--r--rubocop/cop/polymorphic_associations.rb23
-rw-r--r--rubocop/cop/redirect_with_status.rb44
-rw-r--r--rubocop/model_helpers.rb11
-rw-r--r--rubocop/rubocop.rb2
-rw-r--r--spec/controllers/admin/groups_controller_spec.rb9
-rw-r--r--spec/controllers/admin/users_controller_spec.rb15
-rw-r--r--spec/controllers/autocomplete_controller_spec.rb30
-rw-r--r--spec/controllers/dashboard/milestones_controller_spec.rb38
-rw-r--r--spec/controllers/groups/group_members_controller_spec.rb7
-rw-r--r--spec/controllers/health_controller_spec.rb39
-rw-r--r--spec/controllers/metrics_controller_spec.rb70
-rw-r--r--spec/controllers/profiles/keys_controller_spec.rb2
-rw-r--r--spec/controllers/profiles_controller_spec.rb31
-rw-r--r--spec/controllers/projects/boards/lists_controller_spec.rb2
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb45
-rw-r--r--spec/controllers/projects/labels_controller_spec.rb6
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb7
-rw-r--r--spec/controllers/projects/project_members_controller_spec.rb6
-rw-r--r--spec/controllers/projects/snippets_controller_spec.rb12
-rw-r--r--spec/controllers/registrations_controller_spec.rb2
-rw-r--r--spec/controllers/sessions_controller_spec.rb31
-rw-r--r--spec/controllers/snippets_controller_spec.rb40
-rw-r--r--spec/controllers/uploads_controller_spec.rb34
-rw-r--r--spec/db/production/settings.rb16
-rw-r--r--spec/db/production/settings_spec.rb58
-rw-r--r--spec/factories/ci/builds.rb3
-rw-r--r--spec/factories/ci/pipelines.rb21
-rw-r--r--spec/factories/ci/stages.rb8
-rw-r--r--spec/factories/ci/trigger_requests.rb4
-rw-r--r--spec/factories/commits.rb9
-rw-r--r--spec/factories/conversational_development_index_metrics.rb33
-rw-r--r--spec/factories/file_uploaders.rb (renamed from spec/factories/file_uploader.rb)2
-rw-r--r--spec/factories/forked_project_links.rb4
-rw-r--r--spec/factories/keys.rb19
-rw-r--r--spec/factories/lists.rb6
-rw-r--r--spec/factories/project_statistics.rb8
-rw-r--r--spec/factories/project_wikis.rb2
-rw-r--r--spec/factories/projects.rb18
-rw-r--r--spec/factories/snippets.rb1
-rw-r--r--spec/factories/uploads.rb8
-rw-r--r--spec/factories/wiki_directories.rb2
-rw-r--r--spec/factories_spec.rb14
-rw-r--r--spec/features/admin/admin_appearance_spec.rb4
-rw-r--r--spec/features/admin/admin_conversational_development_index_spec.rb40
-rw-r--r--spec/features/admin/admin_deploy_keys_spec.rb63
-rw-r--r--spec/features/admin/admin_groups_spec.rb4
-rw-r--r--spec/features/admin/admin_users_spec.rb6
-rw-r--r--spec/features/boards/add_issues_modal_spec.rb4
-rw-r--r--spec/features/boards/boards_spec.rb174
-rw-r--r--spec/features/boards/issue_ordering_spec.rb36
-rw-r--r--spec/features/boards/new_issue_spec.rb6
-rw-r--r--spec/features/boards/sidebar_spec.rb8
-rw-r--r--spec/features/dashboard/groups_list_spec.rb122
-rw-r--r--spec/features/dashboard/milestone_tabs_spec.rb40
-rw-r--r--spec/features/dashboard/projects_spec.rb9
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb17
-rw-r--r--spec/features/explore/new_menu_spec.rb172
-rw-r--r--spec/features/issues/bulk_assignment_labels_spec.rb70
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb8
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb4
-rw-r--r--spec/features/issues/form_spec.rb41
-rw-r--r--spec/features/issues/note_polling_spec.rb17
-rw-r--r--spec/features/issues/update_issues_spec.rb24
-rw-r--r--spec/features/merge_requests/conflicts_spec.rb2
-rw-r--r--spec/features/merge_requests/diff_notes_avatars_spec.rb4
-rw-r--r--spec/features/merge_requests/diffs_spec.rb6
-rw-r--r--spec/features/merge_requests/filter_merge_requests_spec.rb16
-rw-r--r--spec/features/merge_requests/mini_pipeline_graph_spec.rb28
-rw-r--r--spec/features/merge_requests/update_merge_requests_spec.rb13
-rw-r--r--spec/features/merge_requests/user_posts_notes_spec.rb8
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb6
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb37
-rw-r--r--spec/features/projects/compare_spec.rb1
-rw-r--r--spec/features/projects/environments/environments_spec.rb8
-rw-r--r--spec/features/projects/group_links_spec.rb9
-rw-r--r--spec/features/projects/new_project_spec.rb4
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb78
-rw-r--r--spec/features/projects/settings/visibility_settings_spec.rb4
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb86
-rw-r--r--spec/features/protected_branches_spec.rb2
-rw-r--r--spec/features/reportable_note/commit_spec.rb33
-rw-r--r--spec/features/reportable_note/issue_spec.rb17
-rw-r--r--spec/features/reportable_note/merge_request_spec.rb26
-rw-r--r--spec/features/reportable_note/snippets_spec.rb33
-rw-r--r--spec/features/snippets/create_snippet_spec.rb73
-rw-r--r--spec/features/snippets/edit_snippet_spec.rb38
-rw-r--r--spec/features/snippets/notes_on_personal_snippets_spec.rb12
-rw-r--r--spec/features/unsubscribe_links_spec.rb4
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_group_spec.rb2
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_profile_spec.rb2
-rw-r--r--spec/finders/events_finder_spec.rb44
-rw-r--r--spec/fixtures/api/schemas/list.json2
-rw-r--r--spec/helpers/application_helper_spec.rb13
-rw-r--r--spec/helpers/emails_helper_spec.rb2
-rw-r--r--spec/helpers/groups_helper_spec.rb2
-rw-r--r--spec/helpers/issuables_helper_spec.rb18
-rw-r--r--spec/helpers/notes_helper_spec.rb10
-rw-r--r--spec/helpers/notifications_helper_spec.rb6
-rw-r--r--spec/helpers/page_layout_helper_spec.rb2
-rw-r--r--spec/helpers/profiles_helper_spec.rb36
-rw-r--r--spec/helpers/projects_helper_spec.rb4
-rw-r--r--spec/helpers/submodule_helper_spec.rb8
-rw-r--r--spec/helpers/visibility_level_helper_spec.rb2
-rw-r--r--spec/javascripts/boards/board_new_issue_spec.js45
-rw-r--r--spec/javascripts/boards/components/board_spec.js112
-rw-r--r--spec/javascripts/build_spec.js2
-rw-r--r--spec/javascripts/commit/pipelines/pipelines_spec.js4
-rw-r--r--spec/javascripts/commits_spec.js26
-rw-r--r--spec/javascripts/datetime_utility_spec.js20
-rw-r--r--spec/javascripts/deploy_keys/components/key_spec.js18
-rw-r--r--spec/javascripts/environments/environment_spec.js4
-rw-r--r--spec/javascripts/environments/environment_table_spec.js2
-rw-r--r--spec/javascripts/filtered_search/filtered_search_manager_spec.js79
-rw-r--r--spec/javascripts/fixtures/boards.rb28
-rw-r--r--spec/javascripts/fixtures/issuable_filter.html.haml2
-rw-r--r--spec/javascripts/gl_dropdown_spec.js4
-rw-r--r--spec/javascripts/gl_emoji_spec.js31
-rw-r--r--spec/javascripts/groups/group_item_spec.js102
-rw-r--r--spec/javascripts/groups/groups_spec.js64
-rw-r--r--spec/javascripts/groups/mock_data.js110
-rw-r--r--spec/javascripts/issuable_spec.js16
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js15
-rw-r--r--spec/javascripts/issue_show/components/edited_spec.js49
-rw-r--r--spec/javascripts/issue_show/mock_data.js12
-rw-r--r--spec/javascripts/lib/utils/ajax_cache_spec.js31
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js8
-rw-r--r--spec/javascripts/notes_spec.js172
-rw-r--r--spec/javascripts/pipelines/header_component_spec.js5
-rw-r--r--spec/javascripts/test_bundle.js1
-rw-r--r--spec/javascripts/vue_shared/components/commit_spec.js4
-rw-r--r--spec/lib/banzai/reference_parser/base_parser_spec.rb2
-rw-r--r--spec/lib/banzai/reference_parser/snippet_parser_spec.rb189
-rw-r--r--spec/lib/ci/gitlab_ci_yaml_processor_spec.rb156
-rw-r--r--spec/lib/gitlab/auth_spec.rb32
-rw-r--r--spec/lib/gitlab/backup/repository_spec.rb63
-rw-r--r--spec/lib/gitlab/checks/change_access_spec.rb57
-rw-r--r--spec/lib/gitlab/ci/stage/seed_spec.rb57
-rw-r--r--spec/lib/gitlab/ci_access_spec.rb15
-rw-r--r--spec/lib/gitlab/current_settings_spec.rb7
-rw-r--r--spec/lib/gitlab/diff/diff_refs_spec.rb61
-rw-r--r--spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb12
-rw-r--r--spec/lib/gitlab/diff/file_spec.rb28
-rw-r--r--spec/lib/gitlab/diff/position_spec.rb48
-rw-r--r--spec/lib/gitlab/etag_caching/middleware_spec.rb19
-rw-r--r--spec/lib/gitlab/etag_caching/router_spec.rb51
-rw-r--r--spec/lib/gitlab/git/compare_spec.rb4
-rw-r--r--spec/lib/gitlab/git/diff_collection_spec.rb3
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb48
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb41
-rw-r--r--spec/lib/gitlab/git_access_spec.rb290
-rw-r--r--spec/lib/gitlab/git_access_wiki_spec.rb7
-rw-r--r--spec/lib/gitlab/gitaly_client/diff_spec.rb30
-rw-r--r--spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb59
-rw-r--r--spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb41
-rw-r--r--spec/lib/gitlab/highlight_spec.rb37
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml9
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml11
-rw-r--r--spec/lib/gitlab/ldap/user_spec.rb14
-rw-r--r--spec/lib/gitlab/metrics_spec.rb145
-rw-r--r--spec/lib/gitlab/o_auth/user_spec.rb44
-rw-r--r--spec/lib/gitlab/otp_key_rotator_spec.rb70
-rw-r--r--spec/lib/gitlab/uploads_transfer_spec.rb11
-rw-r--r--spec/lib/gitlab/url_builder_spec.rb11
-rw-r--r--spec/migrations/clean_upload_symlinks_spec.rb46
-rw-r--r--spec/migrations/migrate_build_stage_reference_spec.rb62
-rw-r--r--spec/migrations/migrate_pipeline_stages_spec.rb56
-rw-r--r--spec/migrations/move_uploads_to_system_dir_spec.rb68
-rw-r--r--spec/migrations/rename_system_namespaces_spec.rb254
-rw-r--r--spec/migrations/update_upload_paths_to_system_spec.rb53
-rw-r--r--spec/models/abuse_report_spec.rb4
-rw-r--r--spec/models/blob_spec.rb8
-rw-r--r--spec/models/blob_viewer/base_spec.rb4
-rw-r--r--spec/models/ci/build_spec.rb97
-rw-r--r--spec/models/ci/legacy_stage_spec.rb (renamed from spec/models/ci/stage_spec.rb)2
-rw-r--r--spec/models/ci/pipeline_spec.rb41
-rw-r--r--spec/models/commit_spec.rb26
-rw-r--r--spec/models/concerns/editable_spec.rb11
-rw-r--r--spec/models/forked_project_link_spec.rb4
-rw-r--r--spec/models/group_spec.rb2
-rw-r--r--spec/models/key_spec.rb10
-rw-r--r--spec/models/merge_request_diff_spec.rb11
-rw-r--r--spec/models/namespace_spec.rb10
-rw-r--r--spec/models/pages_domain_spec.rb51
-rw-r--r--spec/models/personal_access_token_spec.rb20
-rw-r--r--spec/models/project_spec.rb18
-rw-r--r--spec/models/repository_spec.rb40
-rw-r--r--spec/models/user_spec.rb6
-rw-r--r--spec/policies/deploy_key_policy_spec.rb56
-rw-r--r--spec/policies/group_policy_spec.rb32
-rw-r--r--spec/policies/project_snippet_policy_spec.rb4
-rw-r--r--spec/presenters/conversational_development_index/metric_presenter_spec.rb36
-rw-r--r--spec/presenters/merge_request_presenter_spec.rb10
-rw-r--r--spec/presenters/projects/settings/deploy_keys_presenter_spec.rb4
-rw-r--r--spec/requests/api/deploy_keys_spec.rb69
-rw-r--r--spec/requests/api/events_spec.rb142
-rw-r--r--spec/requests/api/files_spec.rb19
-rw-r--r--spec/requests/api/project_snippets_spec.rb28
-rw-r--r--spec/requests/api/projects_spec.rb78
-rw-r--r--spec/requests/api/snippets_spec.rb27
-rw-r--r--spec/requests/api/users_spec.rb110
-rw-r--r--spec/requests/api/v3/projects_spec.rb2
-rw-r--r--spec/requests/git_http_spec.rb697
-rw-r--r--spec/requests/jwt_controller_spec.rb34
-rw-r--r--spec/requests/lfs_http_spec.rb38
-rw-r--r--spec/requests/openid_connect_spec.rb2
-rw-r--r--spec/routing/project_routing_spec.rb4
-rw-r--r--spec/rubocop/cop/activerecord_serialize_spec.rb4
-rw-r--r--spec/rubocop/cop/polymorphic_associations_spec.rb33
-rw-r--r--spec/rubocop/cop/redirect_with_status_spec.rb86
-rw-r--r--spec/serializers/build_artifact_entity_spec.rb14
-rw-r--r--spec/serializers/build_details_entity_spec.rb67
-rw-r--r--spec/serializers/build_entity_spec.rb3
-rw-r--r--spec/serializers/deploy_key_entity_spec.rb61
-rw-r--r--spec/serializers/merge_request_entity_spec.rb2
-rw-r--r--spec/serializers/pipeline_details_entity_spec.rb120
-rw-r--r--spec/serializers/pipeline_entity_spec.rb48
-rw-r--r--spec/serializers/runner_entity_spec.rb23
-rw-r--r--spec/services/boards/create_service_spec.rb5
-rw-r--r--spec/services/boards/issues/list_service_spec.rb11
-rw-r--r--spec/services/boards/lists/list_service_spec.rb31
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb26
-rw-r--r--spec/services/ci/retry_build_service_spec.rb18
-rw-r--r--spec/services/create_deployment_service_spec.rb246
-rw-r--r--spec/services/issuable/bulk_update_service_spec.rb2
-rw-r--r--spec/services/members/create_service_spec.rb16
-rw-r--r--spec/services/merge_requests/merge_request_diff_cache_service_spec.rb4
-rw-r--r--spec/services/projects/create_service_spec.rb10
-rw-r--r--spec/services/projects/fork_service_spec.rb8
-rw-r--r--spec/services/projects/import_service_spec.rb2
-rw-r--r--spec/services/projects/participants_service_spec.rb4
-rw-r--r--spec/services/slash_commands/interpret_service_spec.rb2
-rw-r--r--spec/services/submit_usage_ping_service_spec.rb101
-rw-r--r--spec/spec_helper.rb13
-rw-r--r--spec/support/cycle_analytics_helpers.rb43
-rw-r--r--spec/support/db_cleaner.rb4
-rw-r--r--spec/support/features/reportable_note_shared_examples.rb36
-rw-r--r--spec/support/git_http_helpers.rb22
-rw-r--r--spec/support/helpers/key_generator_helper.rb41
-rw-r--r--spec/support/helpers/note_interaction_helpers.rb8
-rw-r--r--spec/support/import_spec_helper.rb2
-rw-r--r--spec/support/javascript_fixtures_helpers.rb2
-rw-r--r--spec/support/migrations_helpers.rb29
-rw-r--r--spec/support/milestone_tabs_examples.rb14
-rw-r--r--spec/support/stub_configuration.rb4
-rw-r--r--spec/support/test_env.rb16
-rw-r--r--spec/unicorn/unicorn_spec.rb4
-rw-r--r--spec/uploaders/attachment_uploader_spec.rb11
-rw-r--r--spec/uploaders/avatar_uploader_spec.rb11
-rw-r--r--spec/uploaders/file_mover_spec.rb63
-rw-r--r--spec/uploaders/file_uploader_spec.rb10
-rw-r--r--spec/uploaders/lfs_object_uploader_spec.rb31
-rw-r--r--spec/uploaders/records_uploads_spec.rb9
-rw-r--r--spec/workers/gitlab_usage_ping_worker_spec.rb18
-rw-r--r--spec/workers/post_receive_spec.rb29
-rw-r--r--spec/workers/repository_fork_worker_spec.rb26
-rw-r--r--spec/workers/repository_import_worker_spec.rb23
-rw-r--r--tmp/prometheus_multiproc_dir/.gitkeep0
957 files changed, 20958 insertions, 4845 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index dea11bb9f61..b442e48a3d0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -430,6 +430,7 @@ gitlab:assets:compile:
USE_DB: "false"
SKIP_STORAGE_VALIDATION: "true"
WEBPACK_REPORT: "true"
+ NO_COMPRESSION: "true"
script:
- yarn install --pure-lockfile --production --cache-folder .yarn-cache
- bundle exec rake gitlab:assets:compile
diff --git a/.rubocop.yml b/.rubocop.yml
index 3cdafd96456..8f611a96702 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -390,6 +390,15 @@ Style/OpMethod:
Style/ParenthesesAroundCondition:
Enabled: true
+# This cop (by default) checks for uses of methods Hash#has_key? and
+# Hash#has_value? where it enforces Hash#key? and Hash#value?
+# It is configurable to enforce the inverse, using `verbose` method
+# names also.
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+# SupportedStyles: short, verbose
+Style/PreferredHashMethods:
+ Enabled: true
+
# Checks for an obsolete RuntimeException argument in raise/fail.
Style/RedundantException:
Enabled: true
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index cf30f5728c0..e2d9c37479d 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -236,13 +236,6 @@ Style/PerlBackrefs:
Style/PredicateName:
Enabled: false
-# Offense count: 45
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: short, verbose
-Style/PreferredHashMethods:
- Enabled: false
-
# Offense count: 65
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e6d8d398a5..e5567dc3b39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 9.2.5 (2017-06-07)
+
+- No changes.
+
+## 9.2.4 (2017-06-02)
+
+- Fix visibility when referencing snippets.
+
+## 9.2.3 (2017-05-31)
+
+- Move uploads from 'public/uploads' to 'public/uploads/system'.
+- Escapes html content before appending it to the DOM.
+- Restrict API X-Frame-Options to same origin.
+- Allow users autocomplete by author_id only for authenticated users.
+
+## 9.2.2 (2017-05-25)
+
+- Fix issue where real time pipelines were not cached. !11615
+- Make all notes use equal padding.
+
## 9.2.1 (2017-05-23)
- Fix placement of note emoji on hover.
@@ -207,6 +227,20 @@ entry.
- Fix preemptive scroll bar on user activity calendar.
- Pipeline chat notifications convert seconds to minutes and hours.
+## 9.1.7 (2017-06-07)
+
+- No changes.
+
+## 9.1.6 (2017-06-02)
+
+- Fix visibility when referencing snippets.
+
+## 9.1.5 (2017-05-31)
+
+- Move uploads from 'public/uploads' to 'public/uploads/system'.
+- Restrict API X-Frame-Options to same origin.
+- Allow users autocomplete by author_id only for authenticated users.
+
## 9.1.4 (2017-05-12)
- Fix error on CI/CD Settings page related to invalid pipeline trigger. !10948 (dosuken123)
@@ -505,6 +539,20 @@ entry.
- Only send chat notifications for the default branch.
- Don't fill in the default kubernetes namespace.
+## 9.0.10 (2017-06-07)
+
+- No changes.
+
+## 9.0.9 (2017-06-02)
+
+- Fix visibility when referencing snippets.
+
+## 9.0.8 (2017-05-31)
+
+- Move uploads from 'public/uploads' to 'public/uploads/system'.
+- Restrict API X-Frame-Options to same origin.
+- Allow users autocomplete by author_id only for authenticated users.
+
## 9.0.7 (2017-05-05)
- Enforce project features when searching blobs and wikis.
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 78bc1abd14f..d9df1bbc0c7 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-0.10.0
+0.11.0
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index 2b7c5ae0184..17b2ccd9bf9 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-0.4.2
+0.4.3
diff --git a/Gemfile b/Gemfile
index c49b60ffc23..715ce2bc6c2 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,6 +2,7 @@ source 'https://rubygems.org'
gem 'rails', '4.2.8'
gem 'rails-deprecated_sanitizer', '~> 1.0.3'
+gem 'bootsnap', '~> 1.0.0'
# Responders respond_to and respond_with
gem 'responders', '~> 2.0'
@@ -268,6 +269,9 @@ group :metrics do
gem 'allocations', '~> 1.0', require: false, platform: :mri
gem 'method_source', '~> 0.8', require: false
gem 'influxdb', '~> 0.2', require: false
+
+ # Prometheus
+ gem 'prometheus-client-mmap', '~>0.7.0.beta5'
end
group :development do
@@ -368,7 +372,7 @@ gem 'vmstat', '~> 2.3.0'
gem 'sys-filesystem', '~> 1.1.6'
# Gitaly GRPC client
-gem 'gitaly', '~> 0.7.0'
+gem 'gitaly', '~> 0.8.0'
gem 'toml-rb', '~> 0.3.15', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index f8adfec6143..d34b84df5e6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -82,6 +82,8 @@ GEM
bindata (2.3.5)
binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1)
+ bootsnap (1.0.0)
+ msgpack (~> 1.0)
bootstrap-sass (3.3.6)
autoprefixer-rails (>= 5.2.1)
sass (>= 3.3.4)
@@ -272,7 +274,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
- gitaly (0.7.0)
+ gitaly (0.8.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
@@ -457,7 +459,9 @@ GEM
mimemagic (0.3.0)
mini_portile2 (2.1.0)
minitest (5.7.0)
+ mmap2 (2.2.6)
mousetrap-rails (1.4.6)
+ msgpack (1.1.0)
multi_json (1.12.1)
multi_xml (0.6.0)
multipart-post (2.0.0)
@@ -560,6 +564,8 @@ GEM
premailer-rails (1.9.2)
actionmailer (>= 3, < 6)
premailer (~> 1.7, >= 1.7.9)
+ prometheus-client-mmap (0.7.0.beta5)
+ mmap2 (~> 2.2.6)
pry (0.10.4)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
@@ -652,7 +658,7 @@ GEM
retriable (1.4.1)
rinku (2.0.0)
rotp (2.1.2)
- rouge (2.0.7)
+ rouge (2.1.0)
rqrcode (0.7.0)
chunky_png
rqrcode-rails3 (0.1.7)
@@ -885,6 +891,7 @@ DEPENDENCIES
benchmark-ips (~> 2.3.0)
better_errors (~> 2.1.0)
binding_of_caller (~> 0.7.2)
+ bootsnap (~> 1.0.0)
bootstrap-sass (~> 3.3.0)
brakeman (~> 3.6.0)
browser (~> 2.2)
@@ -933,7 +940,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
- gitaly (~> 0.7.0)
+ gitaly (~> 0.8.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.5.1)
@@ -995,6 +1002,7 @@ DEPENDENCIES
pg (~> 0.18.2)
poltergeist (~> 1.9.0)
premailer-rails (~> 1.9.0)
+ prometheus-client-mmap (~> 0.7.0.beta5)
pry-byebug (~> 3.4.1)
pry-rails (~> 0.3.4)
rack-attack (~> 4.4.1)
diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg
new file mode 100644
index 00000000000..8886092ed82
--- /dev/null
+++ b/app/assets/images/i2p-step.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120">
+ <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+</svg>
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index d816df831eb..5d060165f4b 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -5,7 +5,8 @@ import Cookies from 'js-cookie';
class Activities {
constructor() {
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
+
$('.event-filter-link').on('click', (e) => {
e.preventDefault();
this.toggleFilter(e.currentTarget);
@@ -19,7 +20,7 @@ class Activities {
reloadActivities() {
$('.content_list').html('');
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
}
toggleFilter(sender) {
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 23d91fdb259..36ce4fddb72 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -88,6 +88,7 @@ function installGlEmojiElement() {
const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
if (
+ emojiUnicode &&
isEmojiUnicode &&
!isEmojiUnicodeSupported(generatedUnicodeSupportMap, emojiUnicode, unicodeVersion)
) {
diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
index 20ab2d7e827..4f8884d05ac 100644
--- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
+++ b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
@@ -28,7 +28,8 @@ function isSkinToneComboEmoji(emojiUnicode) {
// doesn't support the skin tone versions of horse racing
const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16)
function isHorceRacingSkinToneComboEmoji(emojiUnicode) {
- return Array.from(emojiUnicode)[0].codePointAt(0) === horseRacingCodePoint &&
+ const firstCharacter = Array.from(emojiUnicode)[0];
+ return firstCharacter && firstCharacter.codePointAt(0) === horseRacingCodePoint &&
isSkinToneComboEmoji(emojiUnicode);
}
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index 0e4aa39226b..b94009ee76b 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -88,6 +88,8 @@ $(() => {
if (list.type === 'closed') {
list.position = Infinity;
list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' };
+ } else if (list.type === 'backlog') {
+ list.position = -1;
}
});
@@ -128,7 +130,7 @@ $(() => {
},
computed: {
disabled() {
- return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length;
+ return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
if (this.disabled) {
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 9ba84489910..adb7360327c 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,6 +1,7 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
/* global Sortable */
import Vue from 'vue';
+import AccessorUtilities from '../../lib/utils/accessor';
import boardList from './board_list';
import boardBlankState from './board_blank_state';
import './board_delete';
@@ -22,6 +23,10 @@ gl.issueBoards.Board = Vue.extend({
disabled: Boolean,
issueLinkBase: String,
rootPath: String,
+ boardId: {
+ type: String,
+ required: true,
+ },
},
data () {
return {
@@ -78,7 +83,16 @@ gl.issueBoards.Board = Vue.extend({
methods: {
showNewIssueForm() {
this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
- }
+ },
+ toggleExpanded(e) {
+ if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) {
+ this.list.isExpanded = !this.list.isExpanded;
+
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ localStorage.setItem(`boards.${this.boardId}.${this.list.type}.expanded`, this.list.isExpanded);
+ }
+ }
+ },
},
mounted () {
this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({
@@ -102,4 +116,11 @@ gl.issueBoards.Board = Vue.extend({
this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions);
},
+ created() {
+ if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) {
+ const isCollapsed = localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false';
+
+ this.list.isExpanded = !isCollapsed;
+ }
+ },
});
diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js
index 7ee2696e720..bebca17fb1e 100644
--- a/app/assets/javascripts/boards/components/board_list.js
+++ b/app/assets/javascripts/boards/components/board_list.js
@@ -57,6 +57,9 @@ export default {
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
loadNextPage() {
const getIssues = this.list.nextPage();
const loadingDone = () => {
@@ -108,6 +111,7 @@ export default {
},
created() {
eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
@@ -150,6 +154,7 @@ export default {
},
beforeDestroy() {
eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
template: `
@@ -160,9 +165,11 @@ export default {
v-if="loading">
<loading-icon />
</div>
- <board-new-issue
- :list="list"
- v-if="list.type !== 'closed' && showIssueForm"/>
+ <transition name="slide-down">
+ <board-new-issue
+ :list="list"
+ v-if="list.type !== 'closed' && showIssueForm"/>
+ </transition>
<ul
class="board-list"
v-show="!loading"
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index 1ce95b62138..b1c47b09c35 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -48,6 +48,7 @@ export default {
this.error = true;
});
+ eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
},
cancel() {
@@ -75,6 +76,7 @@ export default {
type="text"
v-model="title"
ref="input"
+ autocomplete="off"
:id="list.id + '-title'" />
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index 4699ef5a51c..daef01bc93d 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -152,6 +152,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
<div class="card-assignee">
<user-avatar-link
v-for="(assignee, index) in issue.assignees"
+ :key="assignee.id"
v-if="shouldRenderAssignee(index)"
class="js-no-trigger"
:link-href="assigneeUrl(assignee)"
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index fe7ab2db85d..478a1335b2b 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -26,7 +26,8 @@ gl.issueBoards.ModalFooter = Vue.extend({
},
methods: {
addIssues() {
- const list = this.modal.selectedList || this.state.lists[0];
+ const firstListIndex = 1;
+ const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.globalId);
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
index 8cd15df90fa..4684ea76647 100644
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js
+++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
@@ -11,7 +11,7 @@ gl.issueBoards.ModalFooterListsDropdown = Vue.extend({
},
computed: {
selected() {
- return this.modal.selectedList || this.state.lists[0];
+ return this.modal.selectedList || this.state.lists[1];
},
},
destroyed() {
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 90561d0f7a8..548de1a4c52 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -12,7 +12,9 @@ class List {
this.position = obj.position;
this.title = obj.title;
this.type = obj.list_type;
- this.preset = ['closed', 'blank'].indexOf(this.type) > -1;
+ this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1;
+ this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1;
+ this.isExpanded = true;
this.page = 1;
this.loading = true;
this.loadingMore = false;
@@ -103,13 +105,19 @@ class List {
}
newIssue (issue) {
- this.addIssue(issue);
+ this.addIssue(issue, null, 0);
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
.then((resp) => {
const data = resp.json();
issue.id = data.iid;
+ })
+ .then(() => {
+ if (this.issuesSize > 1) {
+ const moveBeforeIid = this.issues[1].id;
+ gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
+ }
});
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index ad9997ac334..1e12d4ca415 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -22,6 +22,7 @@ gl.issueBoards.BoardsStore = {
create () {
this.state.lists = [];
this.filter.path = gl.utils.getUrlParamsArray().join('&');
+ this.detail = { issue: {} };
},
addList (listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar);
@@ -31,10 +32,14 @@ gl.issueBoards.BoardsStore = {
},
new (listObj) {
const list = this.addList(listObj);
+ const backlogList = this.findList('type', 'backlog', 'backlog');
list
.save()
.then(() => {
+ // Remove any new issues from the backlog
+ // as they will be visible in the new list
+ list.issues.forEach(backlogList.removeIssue.bind(backlogList));
this.state.lists = _.sortBy(this.state.lists, 'position');
})
.catch(() => {
@@ -47,7 +52,7 @@ gl.issueBoards.BoardsStore = {
},
shouldAddBlankState () {
// Decide whether to add the blank state
- return !(this.state.lists.filter(list => list.type !== 'closed')[0]);
+ return !(this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]);
},
addBlankState () {
if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
@@ -100,7 +105,7 @@ gl.issueBoards.BoardsStore = {
issueTo.removeLabel(listFrom.label);
}
- if (listTo.type === 'closed') {
+ if (listTo.type === 'closed' && listFrom.type !== 'backlog') {
issueLists.forEach((list) => {
list.removeIssue(issue);
});
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index 072a899e9f2..d80b7f5bd42 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -20,6 +20,7 @@ window.Build = (function () {
this.$document = $(document);
this.logBytes = 0;
this.scrollOffsetPadding = 30;
+ this.hasBeenScrolled = false;
this.updateDropdown = this.updateDropdown.bind(this);
this.getBuildTrace = this.getBuildTrace.bind(this);
@@ -62,6 +63,15 @@ window.Build = (function () {
.off('click')
.on('click', this.scrollToBottom.bind(this));
+ const scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
+
+ this.$scrollContainer
+ .off('scroll')
+ .on('scroll', () => {
+ this.hasBeenScrolled = true;
+ scrollThrottled();
+ });
+
$(window)
.off('resize.build')
.on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
@@ -70,25 +80,16 @@ window.Build = (function () {
// eslint-disable-next-line
this.getBuildTrace()
- .then(() => this.makeTraceScrollable())
- .then(() => this.scrollToBottom());
+ .then(() => this.toggleScroll())
+ .then(() => {
+ if (!this.hasBeenScrolled) {
+ this.scrollToBottom();
+ }
+ });
this.verifyTopPosition();
}
- Build.prototype.makeTraceScrollable = function () {
- this.$scrollContainer.niceScroll({
- cursorcolor: '#fff',
- cursoropacitymin: 1,
- cursorwidth: '3px',
- railpadding: { top: 5, bottom: 5, right: 5 },
- });
-
- this.$scrollContainer.on('scroll', _.throttle(this.toggleScroll.bind(this), 100));
-
- this.toggleScroll();
- };
-
Build.prototype.canScroll = function () {
return (this.$scrollContainer.prop('scrollHeight') - this.scrollOffsetPadding) > this.$scrollContainer.height();
};
@@ -104,12 +105,11 @@ window.Build = (function () {
*
*/
Build.prototype.toggleScroll = function () {
- const bottomScroll = this.$scrollContainer.scrollTop() +
- this.scrollOffsetPadding +
- this.$scrollContainer.height();
+ const currentPosition = this.$scrollContainer.scrollTop();
+ const bottomScroll = currentPosition + this.$scrollContainer.innerHeight();
if (this.canScroll()) {
- if (this.$scrollContainer.scrollTop() === 0) {
+ if (currentPosition === 0) {
this.toggleDisableButton(this.$scrollTopBtn, true);
this.toggleDisableButton(this.$scrollBottomBtn, false);
} else if (bottomScroll === this.$scrollContainer.prop('scrollHeight')) {
@@ -123,12 +123,14 @@ window.Build = (function () {
};
Build.prototype.scrollToTop = function () {
- this.$scrollContainer.getNiceScroll(0).doScrollTop(0);
+ this.hasBeenScrolled = true;
+ this.$scrollContainer.scrollTop(0);
this.toggleScroll();
};
Build.prototype.scrollToBottom = function () {
- this.$scrollContainer.getNiceScroll(0).doScrollTo(this.$scrollContainer.prop('scrollHeight'));
+ this.hasBeenScrolled = true;
+ this.$scrollContainer.scrollTop(this.$scrollContainer.prop('scrollHeight'));
this.toggleScroll();
};
@@ -216,7 +218,11 @@ window.Build = (function () {
Build.timeout = setTimeout(() => {
//eslint-disable-next-line
this.getBuildTrace()
- .then(() => this.scrollToBottom());
+ .then(() => {
+ if (!this.hasBeenScrolled) {
+ this.scrollToBottom();
+ }
+ });
}, 4000);
} else {
this.$buildRefreshAnimation.remove();
@@ -238,7 +244,7 @@ window.Build = (function () {
};
Build.prototype.toggleSidebar = function (shouldHide) {
- const shouldShow = !shouldHide;
+ const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
this.$buildTrace
.toggleClass('sidebar-expanded', shouldShow)
@@ -253,7 +259,7 @@ window.Build = (function () {
this.verifyTopPosition();
- if (this.$scrollContainer.getNiceScroll(0)) {
+ if (this.canScroll()) {
this.toggleScroll();
}
};
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index e3f9eaaf39c..2b0bf49cf92 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -7,6 +7,8 @@ window.CommitsList = (function() {
CommitsList.timer = null;
CommitsList.init = function(limit) {
+ this.$contentList = $('.content_list');
+
$("body").on("click", ".day-commits-table li.commit", function(e) {
if (e.target.nodeName !== "A") {
location.href = $(this).attr("url");
@@ -14,9 +16,9 @@ window.CommitsList = (function() {
return false;
}
});
- Pager.init(limit, false, false, function() {
- gl.utils.localTimeAgo($('.js-timeago'));
- });
+
+ Pager.init(limit, false, false, this.processCommits);
+
this.content = $("#commits-list");
this.searchField = $("#commits-search");
this.lastSearch = this.searchField.val();
@@ -62,5 +64,34 @@ window.CommitsList = (function() {
});
};
+ // Prepare loaded data.
+ CommitsList.processCommits = (data) => {
+ let processedData = data;
+ const $processedData = $(processedData);
+ const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last();
+ const lastShownDay = $commitsHeadersLast.data('day');
+ const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first();
+ const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day');
+ let commitsCount;
+
+ // If commits headers show the same date,
+ // remove the last header and change the previous one.
+ if (lastShownDay === loadedShownDayFirst) {
+ // Last shown commits count under the last commits header.
+ commitsCount = $commitsHeadersLast.nextUntil('li.js-commit-header').find('li.commit').length;
+
+ // Remove duplicate of commits header.
+ processedData = $processedData.not(`li.js-commit-header[data-day="${loadedShownDayFirst}"]`);
+
+ // Update commits count in the previous commits header.
+ commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length);
+ $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`);
+ }
+
+ gl.utils.localTimeAgo($processedData.find('.js-timeago'));
+
+ return processedData;
+ };
+
return CommitsList;
})();
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index cb054a2a197..bc3e741f524 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -1,5 +1,6 @@
// ECMAScript polyfills
import 'core-js/fn/array/find';
+import 'core-js/fn/array/find-index';
import 'core-js/fn/array/from';
import 'core-js/fn/array/includes';
import 'core-js/fn/object/assign';
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 5f6eed0c67c..a663e30dfd0 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -75,26 +75,32 @@
</script>
<template>
- <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys">
+ <div class="append-bottom-default deploy-keys">
<loading-icon
v-if="isLoading && !hasKeys"
size="2"
label="Loading deploy keys"
- />
+ />
<div v-else-if="hasKeys">
<keys-panel
title="Enabled deploy keys for this project"
:keys="keys.enabled_keys"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
<keys-panel
title="Deploy keys from projects you have access to"
:keys="keys.available_project_keys"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
<keys-panel
v-if="keys.public_keys.length"
title="Public deploy keys available to any project"
:keys="keys.public_keys"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 0a06a481b96..904f7f64fa8 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -11,6 +11,10 @@
type: Object,
required: true,
},
+ endpoint: {
+ type: String,
+ required: true,
+ },
},
components: {
actionBtn,
@@ -19,6 +23,9 @@
timeagoDate() {
return gl.utils.getTimeago().format(this.deployKey.created_at);
},
+ editDeployKeyPath() {
+ return `${this.endpoint}/${this.deployKey.id}/edit`;
+ },
},
methods: {
isEnabled(id) {
@@ -33,7 +40,8 @@
<div class="pull-left append-right-10 hidden-xs">
<i
aria-hidden="true"
- class="fa fa-key key-icon">
+ class="fa fa-key key-icon"
+ >
</i>
</div>
<div class="deploy-key-content key-list-item-info">
@@ -45,7 +53,8 @@
</div>
<div
v-if="deployKey.can_push"
- class="write-access-allowed">
+ class="write-access-allowed"
+ >
Write access allowed
</div>
</div>
@@ -53,7 +62,8 @@
<a
v-for="project in deployKey.projects"
class="label deploy-project-label"
- :href="project.full_path">
+ :href="project.full_path"
+ >
{{ project.full_name }}
</a>
</div>
@@ -61,20 +71,30 @@
<span class="key-created-at">
created {{ timeagoDate }}
</span>
+ <a
+ v-if="deployKey.can_edit"
+ class="btn btn-small"
+ :href="editDeployKeyPath"
+ >
+ Edit
+ </a>
<action-btn
v-if="!isEnabled(deployKey.id)"
:deploy-key="deployKey"
- type="enable"/>
+ type="enable"
+ />
<action-btn
v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned"
:deploy-key="deployKey"
btn-css-class="btn-warning"
- type="remove" />
+ type="remove"
+ />
<action-btn
v-else
:deploy-key="deployKey"
btn-css-class="btn-warning"
- type="disable" />
+ type="disable"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index eccc470578b..9e6fb244af6 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -20,6 +20,10 @@
type: Object,
required: true,
},
+ endpoint: {
+ type: String,
+ required: true,
+ },
},
components: {
key,
@@ -34,18 +38,22 @@
({{ keys.length }})
</h5>
<ul class="well-list"
- v-if="keys.length">
+ v-if="keys.length"
+ >
<li
v-for="deployKey in keys"
:key="deployKey.id">
<key
:deploy-key="deployKey"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
</li>
</ul>
<div
class="settings-message text-center"
- v-else-if="showHelpBox">
+ v-else-if="showHelpBox"
+ >
No deploy keys found. Create one with the form above.
</div>
</div>
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 53b25da18e5..51cc8c085b2 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -3,7 +3,7 @@
/* global ActiveTabMemoizer */
/* global ShortcutsNavigation */
/* global Build */
-/* global Issuable */
+/* global IssuableIndex */
/* global ShortcutsIssuable */
/* global ZenMode */
/* global Milestone */
@@ -55,6 +55,7 @@ import UsersSelect from './users_select';
import RefSelectDropdown from './ref_select_dropdown';
import GfmAutoComplete from './gfm_auto_complete';
import ShortcutsBlob from './shortcuts_blob';
+import initSettingsPanels from './settings_panels';
(function() {
var Dispatcher;
@@ -127,10 +128,9 @@ import ShortcutsBlob from './shortcuts_blob';
const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
filteredSearchManager.setup();
}
- Issuable.init();
- new gl.IssuableBulkActions({
- prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
- });
+ const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
+ IssuableIndex.init(pagePrefix);
+
shortcut_handler = new ShortcutsNavigation();
new UsersSelect();
break;
@@ -160,9 +160,6 @@ import ShortcutsBlob from './shortcuts_blob';
case 'admin:projects:index':
new ProjectsList();
break;
- case 'dashboard:groups:index':
- new GroupsList();
- break;
case 'explore:groups:index':
new GroupsList();
@@ -218,6 +215,16 @@ import ShortcutsBlob from './shortcuts_blob';
new gl.GLForm($('.tag-form'));
new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs);
break;
+ case 'projects:snippets:new':
+ case 'projects:snippets:edit':
+ case 'projects:snippets:create':
+ case 'projects:snippets:update':
+ case 'snippets:new':
+ case 'snippets:edit':
+ case 'snippets:create':
+ case 'snippets:update':
+ new gl.GLForm($('.snippet-form'));
+ break;
case 'projects:releases:edit':
new ZenMode();
new gl.GLForm($('.release-form'));
@@ -364,9 +371,11 @@ import ShortcutsBlob from './shortcuts_blob';
new ProjectFork();
break;
case 'projects:artifacts:browse':
+ new ShortcutsNavigation();
new BuildArtifacts();
break;
case 'projects:artifacts:file':
+ new ShortcutsNavigation();
new BlobViewer();
break;
case 'help:index':
@@ -382,6 +391,8 @@ import ShortcutsBlob from './shortcuts_blob';
// Initialize Protected Tag Settings
new ProtectedTagCreate();
new ProtectedTagEditList();
+ // Initialize expandable settings panels
+ initSettingsPanels();
break;
case 'projects:ci_cd:show':
new gl.ProjectVariables();
@@ -393,6 +404,9 @@ import ShortcutsBlob from './shortcuts_blob';
case 'users:show':
new UserCallout();
break;
+ case 'admin:conversational_development_index:show':
+ new UserCallout();
+ break;
case 'snippets:show':
new LineHighlighter();
new BlobViewer();
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 111449bb8f7..98ddcc20036 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -5,7 +5,7 @@ import './preview_markdown';
window.DropzoneInput = (function() {
function DropzoneInput(form) {
- var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile;
+ var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile, addFileToForm;
Dropzone.autoDiscover = false;
divHover = '<div class="div-dropzone-hover"></div>';
iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
@@ -71,6 +71,7 @@ window.DropzoneInput = (function() {
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.
@@ -198,6 +199,10 @@ window.DropzoneInput = (function() {
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) {
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
index c9e489dd90e..8120ef182d4 100644
--- a/app/assets/javascripts/environments/components/environment.vue
+++ b/app/assets/javascripts/environments/components/environment.vue
@@ -230,7 +230,7 @@ export default {
</div>
</div>
- <div class="content-list environments-container">
+ <div class="environments-container">
<loading-icon
label="Loading environments"
size="3"
@@ -255,7 +255,7 @@ export default {
v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create js-new-environment-button">
- New Environment
+ New environment
</a>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 012ff1f975b..de2269118cd 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -421,14 +421,21 @@ export default {
};
</script>
<template>
- <tr :class="{ 'js-child-row': model.isChildren }">
- <td>
+ <div
+ :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }"
+ role="row">
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-mobile-header"
+ role="rowheader">
+ Environment
+ </div>
<a
v-if="!model.isFolder"
- class="environment-name"
- :class="{ 'prepend-left-default': model.isChildren }"
+ class="environment-name flex-truncate-parent table-mobile-content"
:href="environmentPath">
- {{model.name}}
+ <span class="flex-truncate-child">{{model.name}}</span>
</a>
<span
v-else
@@ -461,9 +468,9 @@ export default {
{{model.size}}
</span>
</span>
- </td>
+ </div>
- <td class="deployment-column">
+ <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell">
<span v-if="shouldRenderDeploymentID">
{{deploymentInternalId}}
</span>
@@ -478,21 +485,27 @@ export default {
:tooltip-text="deploymentUser.username"
/>
</span>
- </td>
+ </div>
- <td class="environments-build-cell">
+ <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell">
<a
v-if="shouldRenderBuildName"
class="build-link"
:href="buildPath">
{{buildName}}
</a>
- </td>
+ </div>
- <td>
+ <div class="table-section section-25" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ role="rowheader"
+ class="table-mobile-header">
+ Commit
+ </div>
<div
v-if="!model.isFolder && hasLastDeploymentKey"
- class="js-commit-component">
+ class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
@@ -501,25 +514,31 @@ export default {
:title="commitTitle"
:author="commitAuthor"/>
</div>
- <p
+ <div
v-if="!model.isFolder && !hasLastDeploymentKey"
- class="commit-title">
+ class="commit-title table-mobile-content">
No deployments yet
- </p>
- </td>
+ </div>
+ </div>
- <td>
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ role="rowheader"
+ class="table-mobile-header">
+ Updated
+ </div>
<span
v-if="!model.isFolder && canShowDate"
- class="environment-created-date-timeago">
+ class="environment-created-date-timeago table-mobile-content">
{{createdDate}}
</span>
- </td>
+ </div>
- <td class="environments-actions">
+ <div class="table-section section-30 environments-actions table-button-footer" role="gridcell">
<div
v-if="!model.isFolder"
- class="btn-group pull-right"
+ class="btn-group environment-action-buttons"
role="group">
<actions-component
@@ -553,6 +572,6 @@ export default {
:retry-url="retryUrl"
/>
</div>
- </td>
- </tr>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 79c019b3491..07cf92281a0 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -19,7 +19,7 @@ export default {
</script>
<template>
<a
- class="btn monitoring-url has-tooltip"
+ class="btn monitoring-url has-tooltip hidden-xs hidden-sm"
data-container="body"
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 2ba985bfe3e..49dba38edfb 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -43,7 +43,7 @@ export default {
<template>
<button
type="button"
- class="btn"
+ class="btn hidden-xs hidden-sm"
@click="onClick"
:disabled="isLoading">
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index a904453ffa9..091c543860b 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -47,7 +47,7 @@ export default {
<template>
<button
type="button"
- class="btn stop-env-link has-tooltip"
+ class="btn stop-env-link has-tooltip hidden-xs hidden-sm"
data-container="body"
@click="onClick"
:disabled="isLoading"
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index c8c1f17d4d8..1ca65a79951 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -29,7 +29,7 @@ export default {
</script>
<template>
<a
- class="btn terminal-button has-tooltip"
+ class="btn terminal-button has-tooltip hidden-xs hidden-sm"
data-container="body"
:title="title"
:aria-label="title"
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 5148a2ae79b..b1fd9db650b 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -45,68 +45,59 @@ export default {
};
</script>
<template>
- <table class="table ci-table">
- <thead>
- <tr>
- <th class="environments-name">
- Environment
- </th>
- <th class="environments-deploy">
- Last deployment
- </th>
- <th class="environments-build">
- Job
- </th>
- <th class="environments-commit">
- Commit
- </th>
- <th class="environments-date">
- Updated
- </th>
- <th class="environments-actions"></th>
- </tr>
- </thead>
- <tbody>
- <template
- v-for="model in environments"
- v-bind:model="model">
- <tr
- is="environment-item"
- :model="model"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- />
+ <div class="ci-table" role="grid">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10 environments-name" role="columnheader">
+ Environment
+ </div>
+ <div class="table-section section-10 environments-deploy" role="columnheader">
+ Deployment
+ </div>
+ <div class="table-section section-15 environments-build" role="columnheader">
+ Job
+ </div>
+ <div class="table-section section-25 environments-commit" role="columnheader">
+ Commit
+ </div>
+ <div class="table-section section-10 environments-date" role="columnheader">
+ Updated
+ </div>
+ </div>
+ <template
+ v-for="model in environments"
+ v-bind:model="model">
+ <div
+ is="environment-item"
+ :model="model"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
- <tr v-if="isLoadingFolderContent">
- <td colspan="6">
- <loading-icon size="2" />
- </td>
- </tr>
+ <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
+ <div v-if="isLoadingFolderContent">
+ <loading-icon size="2" />
+ </div>
- <template v-else>
- <tr
- is="environment-item"
- v-for="children in model.children"
- :model="children"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- />
+ <template v-else>
+ <div
+ is="environment-item"
+ v-for="children in model.children"
+ :model="children"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <tr>
- <td
- colspan="6"
- class="text-center">
- <a
- :href="folderUrl(model)"
- class="btn btn-default">
- Show all
- </a>
- </td>
- </tr>
- </template>
+ <div>
+ <div class="text-center prepend-top-10">
+ <a
+ :href="folderUrl(model)"
+ class="btn btn-default">
+ Show all
+ </a>
+ </div>
+ </div>
</template>
</template>
- </tbody>
- </table>
+ </template>
+ </div>
</template>
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index aaaeb9bddb1..139206cc185 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -8,39 +8,87 @@ export default class FilterableList {
this.filterForm = form;
this.listFilterElement = filter;
this.listHolderElement = holder;
+ this.isBusy = false;
+ }
+
+ getFilterEndpoint() {
+ return `${this.filterForm.getAttribute('action')}?${$(this.filterForm).serialize()}`;
+ }
+
+ getPagePath() {
+ return this.getFilterEndpoint();
}
initSearch() {
- this.debounceFilter = _.debounce(this.filterResults.bind(this), 500);
+ // Wrap to prevent passing event arguments to .filterResults;
+ this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500);
- this.listFilterElement.removeEventListener('input', this.debounceFilter);
+ this.unbindEvents();
+ this.bindEvents();
+ }
+
+ onFilterInput() {
+ const $form = $(this.filterForm);
+ const queryData = {};
+ const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+
+ if (filterGroupsParam) {
+ queryData.filter_groups = filterGroupsParam;
+ }
+
+ this.filterResults(queryData);
+
+ if (this.setDefaultFilterOption) {
+ this.setDefaultFilterOption();
+ }
+ }
+
+ bindEvents() {
this.listFilterElement.addEventListener('input', this.debounceFilter);
}
- filterResults() {
- const form = this.filterForm;
- const filterUrl = `${form.getAttribute('action')}?${$(form).serialize()}`;
+ unbindEvents() {
+ this.listFilterElement.removeEventListener('input', this.debounceFilter);
+ }
+
+ filterResults(queryData) {
+ if (this.isBusy) {
+ return false;
+ }
$(this.listHolderElement).fadeTo(250, 0.5);
return $.ajax({
- url: form.getAttribute('action'),
- data: $(form).serialize(),
+ url: this.getFilterEndpoint(),
+ data: queryData,
type: 'GET',
dataType: 'json',
context: this,
- complete() {
- $(this.listHolderElement).fadeTo(250, 1);
+ complete: this.onFilterComplete,
+ beforeSend: () => {
+ this.isBusy = true;
},
- success(data) {
- this.listHolderElement.innerHTML = data.html;
-
- // Change url so if user reload a page - search results are saved
- return window.history.replaceState({
- page: filterUrl,
-
- }, document.title, filterUrl);
+ success: (response, textStatus, xhr) => {
+ this.onFilterSuccess(response, xhr, queryData);
},
});
}
+
+ onFilterSuccess(response, xhr, queryData) {
+ if (response.html) {
+ this.listHolderElement.innerHTML = response.html;
+ }
+
+ // Change url so if user reload a page - search results are saved
+ const currentPath = this.getPagePath(queryData);
+
+ return window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
+ }
+
+ onFilterComplete() {
+ this.isBusy = false;
+ $(this.listHolderElement).fadeTo(250, 1);
+ }
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 3be889c684b..8f547bd8f1f 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -77,6 +77,41 @@ class FilteredSearchManager {
}
}
+ bindStateEvents() {
+ this.stateFilters = document.querySelector('.container-fluid .issues-state-filters');
+
+ if (this.stateFilters) {
+ this.searchStateWrapper = this.searchState.bind(this);
+
+ this.stateFilters.querySelector('[data-state="opened"]')
+ .addEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="closed"]')
+ .addEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="all"]')
+ .addEventListener('click', this.searchStateWrapper);
+
+ this.mergedState = this.stateFilters.querySelector('[data-state="merged"]');
+ if (this.mergedState) {
+ this.mergedState.addEventListener('click', this.searchStateWrapper);
+ }
+ }
+ }
+
+ unbindStateEvents() {
+ if (this.stateFilters) {
+ this.stateFilters.querySelector('[data-state="opened"]')
+ .removeEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="closed"]')
+ .removeEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="all"]')
+ .removeEventListener('click', this.searchStateWrapper);
+
+ if (this.mergedState) {
+ this.mergedState.removeEventListener('click', this.searchStateWrapper);
+ }
+ }
+ }
+
bindEvents() {
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
@@ -105,15 +140,15 @@ class FilteredSearchManager {
this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.addEventListener('click', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
- document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
+
+ this.bindStateEvents();
}
unbindEvents() {
@@ -127,15 +162,15 @@ class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.removeEventListener('click', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
- document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
+
+ this.unbindStateEvents();
}
checkForBackspace(e) {
@@ -207,23 +242,13 @@ class FilteredSearchManager {
}
}
- static selectToken(e) {
- const button = e.target.closest('.selectable');
- const removeButtonSelected = e.target.closest('.remove-token');
-
- if (!removeButtonSelected && button) {
- e.preventDefault();
- e.stopPropagation();
- gl.FilteredSearchVisualTokens.selectToken(button);
- }
- }
-
removeToken(e) {
const removeButtonSelected = e.target.closest('.remove-token');
if (removeButtonSelected) {
e.preventDefault();
- e.stopPropagation();
+ // Prevent editToken from being triggered after token is removed
+ e.stopImmediatePropagation();
const button = e.target.closest('.selectable');
gl.FilteredSearchVisualTokens.selectToken(button, true);
@@ -245,10 +270,12 @@ class FilteredSearchManager {
editToken(e) {
const token = e.target.closest('.js-visual-token');
- const sanitizedTokenName = token.querySelector('.name').textContent.trim();
+ const sanitizedTokenName = token && token.querySelector('.name').textContent.trim();
const canEdit = this.canEdit && this.canEdit(sanitizedTokenName);
if (token && canEdit) {
+ e.preventDefault();
+ e.stopPropagation();
gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
@@ -459,7 +486,19 @@ class FilteredSearchManager {
}
}
- search() {
+ searchState(e) {
+ const target = e.currentTarget;
+ // remove focus outline after click
+ target.blur();
+
+ const state = target.dataset && target.dataset.state;
+
+ if (state) {
+ this.search(state);
+ }
+ }
+
+ search(state = null) {
const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
@@ -467,7 +506,7 @@ class FilteredSearchManager {
const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
- const currentState = gl.utils.getParameterByName('state') || 'opened';
+ const currentState = state || gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
tokens.forEach((token) => {
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b8a923cf619..401dec1a370 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -2,6 +2,7 @@ import emojiMap from 'emojis/digests.json';
import emojiAliases from 'emojis/aliases.json';
import { glEmojiTag } from '~/behaviors/gl_emoji';
import glRegexp from '~/lib/utils/regexp';
+import AjaxCache from '~/lib/utils/ajax_cache';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
@@ -35,6 +36,7 @@ class GfmAutoComplete {
// This triggers at.js again
// Needed for slash commands with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
+ $input.on('clear-commands-cache.atwho', () => this.clearCache());
});
}
@@ -375,11 +377,14 @@ class GfmAutoComplete {
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
} else {
- $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => {
- this.loadData($input, at, data);
- }).fail(() => { this.isLoadingData[at] = false; });
+ AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
+ .then((data) => {
+ this.loadData($input, at, data);
+ })
+ .catch(() => { this.isLoadingData[at] = false; });
}
}
+
loadData($input, at, data) {
this.isLoadingData[at] = false;
this.cachedData[at] = data;
@@ -389,6 +394,10 @@ class GfmAutoComplete {
return $input.trigger('keyup');
}
+ clearCache() {
+ this.cachedData = {};
+ }
+
static isLoading(data) {
let dataToInspect = data;
if (data && data.length > 0) {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index d34561e5512..3babe273100 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -248,7 +248,7 @@ GitLabDropdown = (function() {
return function(data) {
_this.fullData = data;
_this.parseData(_this.fullData);
- _this.focusTextInput();
+ _this.focusTextInput(true);
if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') {
return _this.filter.input.trigger('input');
}
@@ -728,8 +728,20 @@ GitLabDropdown = (function() {
return [selectedObject, isMarking];
};
- GitLabDropdown.prototype.focusTextInput = function() {
- if (this.options.filterable) { this.filterInput.focus(); }
+ GitLabDropdown.prototype.focusTextInput = function(triggerFocus = false) {
+ if (this.options.filterable) {
+ $(':focus').blur();
+
+ this.dropdown.one('transitionend', () => {
+ this.filterInput.focus();
+ });
+
+ if (triggerFocus) {
+ // This triggers after a ajax request
+ // in case of slow requests, the dropdown transition could already be finished
+ this.dropdown.trigger('transitionend');
+ }
+ }
};
GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) {
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
new file mode 100644
index 00000000000..7cc6c4b0359
--- /dev/null
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -0,0 +1,27 @@
+<script>
+export default {
+ props: {
+ groups: {
+ type: Object,
+ required: true,
+ },
+ baseGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="content-list group-list-tree">
+ <group-item
+ v-for="(group, index) in groups"
+ :key="index"
+ :group="group"
+ :base-group="baseGroup"
+ :collection="groups"
+ />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
new file mode 100644
index 00000000000..32815b9f73e
--- /dev/null
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -0,0 +1,220 @@
+<script>
+import eventHub from '../event_hub';
+
+export default {
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ baseGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ collection: {
+ 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.webUrl;
+ }
+ }
+ },
+ 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);
+ },
+ },
+ computed: {
+ groupDomId() {
+ return `group-${this.group.id}`;
+ },
+ rowClass() {
+ return {
+ 'group-row': true,
+ 'is-open': this.group.isOpen,
+ 'has-subgroups': this.group.hasSubgroups,
+ 'no-description': !this.group.description,
+ };
+ },
+ visibilityIcon() {
+ return {
+ fa: true,
+ 'fa-globe': this.group.visibility === 'public',
+ 'fa-shield': this.group.visibility === 'internal',
+ 'fa-lock': this.group.visibility === 'private',
+ };
+ },
+ 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);
+ } else {
+ fullPath = this.group.fullName;
+ }
+ } else {
+ fullPath = this.group.name;
+ }
+
+ return fullPath;
+ },
+ hasGroups() {
+ return Object.keys(this.group.subGroups).length > 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ @click.stop="onClickRowGroup"
+ :id="groupDomId"
+ :class="rowClass"
+ >
+ <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>
+ <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>
+ </div>
+ <div
+ class="avatar-container s40 hidden-xs">
+ <a
+ :href="group.webUrl">
+ <img
+ class="avatar s40"
+ :src="group.avatarUrl"
+ />
+ </a>
+ </div>
+ <div
+ class="title">
+ <a
+ :href="group.webUrl">{{fullPath}}</a>
+ <template v-if="group.permissions.humanGroupAccess">
+ as
+ <span class="access-type">{{group.permissions.humanGroupAccess}}</span>
+ </template>
+ </div>
+ <div
+ class="description">{{group.description}}</div>
+ </div>
+ <group-folder
+ v-if="group.isOpen && hasGroups"
+ :groups="group.subGroups"
+ :baseGroup="group"
+ />
+ </li>
+</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
new file mode 100644
index 00000000000..36a04d4202f
--- /dev/null
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -0,0 +1,39 @@
+<script>
+import tablePagination from '~/vue_shared/components/table_pagination.vue';
+import eventHub from '../event_hub';
+
+export default {
+ props: {
+ groups: {
+ type: Object,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ tablePagination,
+ },
+ methods: {
+ change(page) {
+ const filterGroupsParam = gl.utils.getParameterByName('filter_groups');
+ const sortParam = gl.utils.getParameterByName('sort');
+ eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="groups-list-tree-container">
+ <group-folder
+ :groups="groups"
+ />
+ <table-pagination
+ :change="change"
+ :pageInfo="pageInfo"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/event_hub.js b/app/assets/javascripts/groups/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/groups/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
new file mode 100644
index 00000000000..439a931ddad
--- /dev/null
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -0,0 +1,87 @@
+import FilterableList from '~/filterable_list';
+import eventHub from './event_hub';
+
+export default class GroupFilterableList extends FilterableList {
+ constructor({ form, filter, holder, filterEndpoint, pagePath }) {
+ super(form, filter, holder);
+ this.form = form;
+ this.filterEndpoint = filterEndpoint;
+ this.pagePath = pagePath;
+ this.$dropdown = $('.js-group-filter-dropdown-wrap');
+ }
+
+ getFilterEndpoint() {
+ return this.filterEndpoint;
+ }
+
+ getPagePath(queryData) {
+ const params = queryData ? $.param(queryData) : '';
+ const queryString = params ? `?${params}` : '';
+ return `${this.pagePath}${queryString}`;
+ }
+
+ 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();
+ const queryData = {};
+
+ if (filterGroupsParam) {
+ queryData.filter_groups = filterGroupsParam;
+ }
+
+ this.filterResults(queryData);
+ this.setDefaultFilterOption();
+ }
+
+ setDefaultFilterOption() {
+ const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
+ this.$dropdown.find('.dropdown-label').text(defaultOption);
+ }
+
+ onOptionClick(e) {
+ e.preventDefault();
+
+ const queryData = {};
+ const sortParam = gl.utils.getParameterByName('sort', e.currentTarget.href);
+
+ if (sortParam) {
+ queryData.sort = sortParam;
+ }
+
+ this.filterResults(queryData);
+
+ // Active selected option
+ this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+
+ // Clear current value on search form
+ this.form.querySelector('[name="filter_groups"]').value = '';
+ }
+
+ onFilterSuccess(data, xhr, queryData) {
+ super.onFilterSuccess(data, xhr, queryData);
+
+ const paginationData = {
+ 'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
+ 'X-Page': xhr.getResponseHeader('X-Page'),
+ 'X-Total': xhr.getResponseHeader('X-Total'),
+ 'X-Total-Pages': xhr.getResponseHeader('X-Total-Pages'),
+ 'X-Next-Page': xhr.getResponseHeader('X-Next-Page'),
+ 'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
+ };
+
+ eventHub.$emit('updateGroups', data);
+ eventHub.$emit('updatePagination', paginationData);
+ }
+}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
new file mode 100644
index 00000000000..ff601db2aa6
--- /dev/null
+++ b/app/assets/javascripts/groups/index.js
@@ -0,0 +1,190 @@
+/* global Flash */
+
+import Vue from 'vue';
+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';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('dashboard-group-app');
+
+ // Don't do anything if element doesn't exist (No groups)
+ // This is for when the user enters directly to the page via URL
+ if (!el) {
+ return;
+ }
+
+ Vue.component('groups-component', GroupsComponent);
+ Vue.component('group-folder', GroupFolder);
+ Vue.component('group-item', GroupItem);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ data() {
+ this.store = new GroupsStore();
+ this.service = new GroupsService(el.dataset.endpoint);
+
+ return {
+ store: this.store,
+ isLoading: true,
+ state: this.store.state,
+ 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 = gl.utils.getParameterByName('page');
+ if (pageParam) {
+ page = pageParam;
+ }
+
+ filterGroupsParam = gl.utils.getParameterByName('filter_groups');
+ if (filterGroupsParam) {
+ filterGroups = filterGroupsParam;
+ }
+
+ sortParam = gl.utils.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);
+
+ this.updateGroups(response.json());
+ 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((response) => {
+ $.scrollTo(0);
+
+ this.store.removeGroup(group, collection);
+
+ // eslint-disable-next-line no-new
+ new Flash(response.json().notice, 'notice');
+ })
+ .catch((response) => {
+ let message = 'An error occurred. Please try again.';
+
+ if (response.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() {
+ 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 opts = {
+ form,
+ filter,
+ holder,
+ filterEndpoint: el.dataset.endpoint,
+ pagePath: el.dataset.path,
+ };
+
+ 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);
+ },
+ });
+});
diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/services/groups_service.js
new file mode 100644
index 00000000000..97e02fcb76d
--- /dev/null
+++ b/app/assets/javascripts/groups/services/groups_service.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class GroupsService {
+ constructor(endpoint) {
+ this.groups = Vue.resource(endpoint);
+ }
+
+ getGroups(parentId, page, filterGroups, sort) {
+ const data = {};
+
+ if (parentId) {
+ data.parent_id = parentId;
+ } else {
+ // Do not send the following param for sub groups
+ if (page) {
+ data.page = page;
+ }
+
+ if (filterGroups) {
+ data.filter_groups = filterGroups;
+ }
+
+ if (sort) {
+ data.sort = sort;
+ }
+ }
+
+ return this.groups.get(data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ leaveGroup(endpoint) {
+ return Vue.http.delete(endpoint);
+ }
+}
diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js
new file mode 100644
index 00000000000..67ee7d140ce
--- /dev/null
+++ b/app/assets/javascripts/groups/stores/groups_store.js
@@ -0,0 +1,151 @@
+import Vue from 'vue';
+
+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 = gl.utils.normalizeHeaders(pagination);
+ paginationInfo = gl.utils.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[group.id] = group;
+ mappedGroups[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[currentGroup.parentId];
+ if (findParentGroup) {
+ mappedGroups[currentGroup.parentId].subGroups[currentGroup.id] = currentGroup;
+ mappedGroups[currentGroup.parentId].isOpen = true; // Expand group if it has subgroups
+ } else if (parentGroup && parentGroup.id === currentGroup.parentId) {
+ tree[currentGroup.id] = currentGroup;
+ } else {
+ // Means the groups hast no direct parent.
+ // Save for later processing, we will add them to its corresponding base group
+ orphans.push(currentGroup);
+ }
+ } else {
+ // If the group is at the root level, add it to first level elements array.
+ tree[currentGroup.id] = currentGroup;
+ }
+
+ return key;
+ });
+
+ // Hopefully this array will be empty for most cases
+ if (orphans.length) {
+ orphans.map((orphan) => {
+ let found = false;
+ const currentOrphan = orphan;
+
+ Object.keys(tree).map((key) => {
+ const group = tree[key];
+ if (currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0) {
+ group.subGroups[currentOrphan.id] = currentOrphan;
+ group.isOpen = true;
+ currentOrphan.isOrphan = true;
+ found = true;
+ }
+
+ return key;
+ });
+
+ if (!found) {
+ currentOrphan.isOrphan = true;
+ tree[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,
+ 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, 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/importer_status.js b/app/assets/javascripts/importer_status.js
index 34e4a257ff9..5b4ca94ed30 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -56,6 +56,8 @@
if (job.import_status === 'finished') {
job_item.removeClass("active").addClass("success");
return status_field.html('<span><i class="fa fa-check"></i> done</span>');
+ } else if (job.import_status === 'scheduled') {
+ return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled");
} else if (job.import_status === 'started') {
return status_field.html("<i class='fa fa-spinner fa-spin'></i> started");
} else {
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
new file mode 100644
index 00000000000..e46c0e90255
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -0,0 +1,159 @@
+/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */
+/* global IssuableIndex */
+/* global Flash */
+
+export default {
+ init({ container, form, issues, prefixId } = {}) {
+ this.prefixId = prefixId || 'issue_';
+ this.form = form || this.getElement('.bulk-update');
+ this.$labelDropdown = this.form.find('.js-label-select');
+ this.issues = issues || this.getElement('.issues-list .issue');
+ this.willUpdateLabels = false;
+ this.bindEvents();
+ },
+
+ bindEvents() {
+ return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
+ },
+
+ onFormSubmit(e) {
+ e.preventDefault();
+ return this.submit();
+ },
+
+ submit() {
+ const _this = this;
+ const xhr = $.ajax({
+ url: this.form.attr('action'),
+ method: this.form.attr('method'),
+ dataType: 'JSON',
+ data: this.getFormDataAsObject()
+ });
+ xhr.done(() => window.location.reload());
+ xhr.fail(() => this.onFormSubmitFailure());
+ },
+
+ onFormSubmitFailure() {
+ this.form.find('[type="submit"]').enable();
+ return new Flash("Issue update failed");
+ },
+
+ getSelectedIssues() {
+ return this.issues.has('.selected_issue:checked');
+ },
+
+ getLabelsFromSelection() {
+ const labels = [];
+ this.getSelectedIssues().map(function() {
+ const labelsData = $(this).data('labels');
+ if (labelsData) {
+ return labelsData.map(function(labelId) {
+ if (labels.indexOf(labelId) === -1) {
+ return labels.push(labelId);
+ }
+ });
+ }
+ });
+ return labels;
+ },
+
+ /**
+ * Will return only labels that were marked previously and the user has unmarked
+ * @return {Array} Label IDs
+ */
+
+ getUnmarkedIndeterminedLabels() {
+ const result = [];
+ const labelsToKeep = this.$labelDropdown.data('indeterminate');
+
+ this.getLabelsFromSelection().forEach((id) => {
+ if (labelsToKeep.indexOf(id) === -1) {
+ result.push(id);
+ }
+ });
+
+ return result;
+ },
+
+ /**
+ * Simple form serialization, it will return just what we need
+ * Returns key/value pairs from form data
+ */
+
+ getFormDataAsObject() {
+ const formData = {
+ update: {
+ state_event: this.form.find('input[name="update[state_event]"]').val(),
+ // For Merge Requests
+ assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
+ // For Issues
+ assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
+ milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
+ issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
+ subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
+ add_label_ids: [],
+ remove_label_ids: []
+ }
+ };
+ if (this.willUpdateLabels) {
+ formData.update.add_label_ids = this.$labelDropdown.data('marked');
+ formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
+ }
+ return formData;
+ },
+
+ setOriginalDropdownData() {
+ const $labelSelect = $('.bulk-update .js-label-select');
+ $labelSelect.data('common', this.getOriginalCommonIds());
+ $labelSelect.data('marked', this.getOriginalMarkedIds());
+ $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalCommonIds() {
+ const labelIds = [];
+
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalMarkedIds() {
+ const labelIds = [];
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalIndeterminateIds() {
+ const uniqueIds = [];
+ const labelIds = [];
+ let issuableLabels = [];
+
+ // Collect unique label IDs for all checked issues
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
+ issuableLabels.forEach((labelId) => {
+ // Store unique IDs
+ if (uniqueIds.indexOf(labelId) === -1) {
+ uniqueIds.push(labelId);
+ }
+ });
+ // Store array of IDs per issuable
+ labelIds.push(issuableLabels);
+ });
+ // Add uniqueIds to add it as argument for _.intersection
+ labelIds.unshift(uniqueIds);
+ // Return IDs that are present but not in all selected issueables
+ return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
+ },
+
+ getElement(selector) {
+ this.scopeEl = this.scopeEl || $('.content');
+ return this.scopeEl.find(selector);
+ },
+};
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
new file mode 100644
index 00000000000..84bd2e092e6
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -0,0 +1,165 @@
+/* eslint-disable class-methods-use-this, no-new */
+/* global LabelsSelect */
+/* global MilestoneSelect */
+/* global IssueStatusSelect */
+/* global SubscriptionSelect */
+
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+
+const HIDDEN_CLASS = 'hidden';
+const DISABLED_CONTENT_CLASS = 'disabled-content';
+const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar';
+const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar';
+
+export default class IssuableBulkUpdateSidebar {
+ constructor() {
+ this.initDomElements();
+ this.bindEvents();
+ this.initDropdowns();
+ this.setupBulkUpdateActions();
+ }
+
+ initDomElements() {
+ this.$page = $('.page-with-sidebar');
+ this.$sidebar = $('.right-sidebar');
+ this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide');
+ this.$bulkEditSubmitBtn = $('.update-selected-issues');
+ this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle');
+ this.$otherFilters = $('.issues-other-filters');
+ this.$checkAllContainer = $('.check-all-holder');
+ this.$issueChecks = $('.issue-check');
+ this.$issuesList = $('.selected_issue');
+ this.$issuableIdsInput = $('#update_issuable_ids');
+ }
+
+ bindEvents() {
+ this.$bulkUpdateEnableBtn.on('click', e => this.toggleBulkEdit(e, true));
+ this.$bulkEditCancelBtn.on('click', e => this.toggleBulkEdit(e, false));
+ this.$checkAllContainer.on('click', e => this.selectAll(e));
+ this.$issuesList.on('change', () => this.updateFormState());
+ this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
+ this.$checkAllContainer.on('click', () => this.updateFormState());
+ }
+
+ initDropdowns() {
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssueStatusSelect();
+ new SubscriptionSelect();
+ }
+
+ getNavHeight() {
+ const navbarHeight = $('.navbar-gitlab').outerHeight();
+ const layoutNavHeight = $('.layout-nav').outerHeight();
+ const subNavScroll = $('.sub-nav-scroll').outerHeight();
+ return navbarHeight + layoutNavHeight + subNavScroll;
+ }
+
+ initSidebar() {
+ if (!this.navHeight) {
+ this.navHeight = this.getNavHeight();
+ }
+
+ if (!this.sidebarInitialized) {
+ $(document).off('scroll').on('scroll', _.throttle(this.setSidebarHeight, 10).bind(this));
+ $(window).off('resize').on('resize', _.throttle(this.setSidebarHeight, 10).bind(this));
+ this.sidebarInitialized = true;
+ }
+ }
+
+ setupBulkUpdateActions() {
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ updateFormState() {
+ const noCheckedIssues = !$('.selected_issue:checked').length;
+
+ this.toggleSubmitButtonDisabled(noCheckedIssues);
+ this.updateSelectedIssuableIds();
+
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ prepForSubmit() {
+ // if submit button is disabled, submission is blocked. This ensures we disable after
+ // form submission is carried out
+ setTimeout(() => this.$bulkEditSubmitBtn.disable());
+ this.updateSelectedIssuableIds();
+ }
+
+ toggleBulkEdit(e, enable) {
+ e.preventDefault();
+
+ this.toggleSidebarDisplay(enable);
+ this.toggleBulkEditButtonDisabled(enable);
+ this.toggleOtherFiltersDisabled(enable);
+ this.toggleCheckboxDisplay(enable);
+
+ if (enable) {
+ this.initSidebar();
+ }
+ }
+
+ updateSelectedIssuableIds() {
+ this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds());
+ }
+
+ selectAll() {
+ const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked');
+
+ this.$issuesList.prop('checked', checkAllButtonState);
+ }
+
+ toggleSidebarDisplay(show) {
+ this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ }
+
+ toggleBulkEditButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkUpdateEnableBtn.disable();
+ } else {
+ this.$bulkUpdateEnableBtn.enable();
+ }
+ }
+
+ toggleCheckboxDisplay(show) {
+ this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
+ this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
+ }
+
+ toggleOtherFiltersDisabled(disable) {
+ this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable);
+ }
+
+ toggleSubmitButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkEditSubmitBtn.disable();
+ } else {
+ this.$bulkEditSubmitBtn.enable();
+ }
+ }
+ // loosely based on method of the same name in right_sidebar.js
+ setSidebarHeight() {
+ const currentScrollDepth = window.pageYOffset || 0;
+ const diff = this.navHeight - currentScrollDepth;
+
+ if (diff > 0) {
+ this.$sidebar.outerHeight(window.innerHeight - diff);
+ } else {
+ this.$sidebar.outerHeight('100%');
+ }
+ }
+
+ static getCheckedIssueIds() {
+ const $checkedIssues = $('.selected_issue:checked');
+
+ if ($checkedIssues.length > 0) {
+ return $.map($checkedIssues, value => $(value).data('id'));
+ }
+
+ return [];
+ }
+}
diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable_index.js
index 3bfce32768a..5c96646def8 100644
--- a/app/assets/javascripts/issuable.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,30 +1,33 @@
/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */
-/* global Issuable */
+/* global IssuableIndex */
+
+import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
((global) => {
var issuable_created;
issuable_created = false;
- global.Issuable = {
- init: function() {
- Issuable.initTemplates();
- Issuable.initSearch();
- Issuable.initChecks();
- Issuable.initResetFilters();
- Issuable.resetIncomingEmailToken();
- return Issuable.initLabelFilterRemove();
+ global.IssuableIndex = {
+ init: function(pagePrefix) {
+ IssuableIndex.initTemplates();
+ IssuableIndex.initSearch();
+ IssuableIndex.initBulkUpdate(pagePrefix);
+ IssuableIndex.initResetFilters();
+ IssuableIndex.resetIncomingEmailToken();
+ IssuableIndex.initLabelFilterRemove();
},
initTemplates: function() {
- return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
+ return IssuableIndex.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
},
initSearch: function() {
const $searchInput = $('#issuable_search');
- Issuable.initSearchState($searchInput);
+ IssuableIndex.initSearchState($searchInput);
// `immediate` param set to false debounces on the `trailing` edge, lets user finish typing
- const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false);
+ const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false);
$searchInput.off('keyup').on('keyup', debouncedExecSearch);
@@ -37,16 +40,16 @@
initSearchState: function($searchInput) {
const currentSearchVal = $searchInput.val();
- Issuable.searchState = {
+ IssuableIndex.searchState = {
elem: $searchInput,
current: currentSearchVal
};
- Issuable.maybeFocusOnSearch();
+ IssuableIndex.maybeFocusOnSearch();
},
accessSearchPristine: function(set) {
// store reference to previous value to prevent search on non-mutating keyup
- const state = Issuable.searchState;
+ const state = IssuableIndex.searchState;
const currentSearchVal = state.elem.val();
if (set) {
@@ -56,10 +59,10 @@
}
},
maybeFocusOnSearch: function() {
- const currentSearchVal = Issuable.searchState.current;
+ const currentSearchVal = IssuableIndex.searchState.current;
if (currentSearchVal && currentSearchVal !== '') {
const queryLength = currentSearchVal.length;
- const $searchInput = Issuable.searchState.elem;
+ const $searchInput = IssuableIndex.searchState.elem;
/* The following ensures that the cursor is initially placed at
* the end of search input when focus is applied. It accounts
@@ -80,7 +83,7 @@
const $searchValue = $search.val();
const $filtersForm = $('.js-filter-form');
const $input = $(`input[name='${$searchName}']`, $filtersForm);
- const isPristine = Issuable.accessSearchPristine();
+ const isPristine = IssuableIndex.accessSearchPristine();
if (isPristine) {
return;
@@ -92,7 +95,7 @@
$input.val($searchValue);
}
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
},
initLabelFilterRemove: function() {
return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) {
@@ -103,7 +106,7 @@
return this.value === $button.data('label');
}).remove();
// Submit the form to get new data
- Issuable.filterResults($('.filter-form'));
+ IssuableIndex.filterResults($('.filter-form'));
});
},
filterResults: (function(_this) {
@@ -132,38 +135,18 @@
gl.utils.visitUrl(baseIssuesUrl);
});
},
- initChecks: function() {
- this.issuableBulkActions = $('.bulk-update').data('bulkActions');
- $('.check_all_issues').off('click').on('click', function() {
- $('.selected_issue').prop('checked', this.checked);
- return Issuable.checkChanged();
- });
- return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this));
- },
- checkChanged: function() {
- const $checkedIssues = $('.selected_issue:checked');
- const $updateIssuesIds = $('#update_issuable_ids');
- const $issuesOtherFilters = $('.issues-other-filters');
- const $issuesBulkUpdate = $('.issues_bulk_update');
-
- this.issuableBulkActions.willUpdateLabels = false;
- this.issuableBulkActions.setOriginalDropdownData();
-
- if ($checkedIssues.length > 0) {
- const ids = $.map($checkedIssues, function(value) {
- return $(value).data('id');
+ initBulkUpdate: function(pagePrefix) {
+ const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
+ const alreadyInitialized = !!this.bulkUpdateSidebar;
+
+ if (userCanBulkUpdate && !alreadyInitialized) {
+ IssuableBulkUpdateActions.init({
+ prefixId: pagePrefix,
});
- $updateIssuesIds.val(ids);
- $issuesOtherFilters.hide();
- $issuesBulkUpdate.show();
- } else {
- $updateIssuesIds.val([]);
- $issuesBulkUpdate.hide();
- $issuesOtherFilters.show();
+
+ this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
}
- return true;
},
-
resetIncomingEmailToken: function() {
$('.incoming-email-token-reset').on('click', function(e) {
e.preventDefault();
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 800bb9f1fe8..e14414d3f68 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -7,6 +7,7 @@ import Service from '../services/index';
import Store from '../stores';
import titleComponent from './title.vue';
import descriptionComponent from './description.vue';
+import editedComponent from './edited.vue';
import formComponent from './form.vue';
import '../../lib/utils/url_utility';
@@ -50,6 +51,21 @@ export default {
required: false,
default: '',
},
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
issuableTemplates: {
type: Array,
required: false,
@@ -86,6 +102,9 @@ export default {
titleText: this.initialTitleText,
descriptionHtml: this.initialDescriptionHtml,
descriptionText: this.initialDescriptionText,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
});
return {
@@ -98,10 +117,14 @@ export default {
formState() {
return this.store.formState;
},
+ hasUpdated() {
+ return !!this.state.updatedAt;
+ },
},
components: {
descriptionComponent,
titleComponent,
+ editedComponent,
formComponent,
},
methods: {
@@ -240,6 +263,11 @@ export default {
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" />
+ <edited-component
+ v-if="hasUpdated"
+ :updated-at="state.updatedAt"
+ :updated-by-name="state.updatedByName"
+ :updated-by-path="state.updatedByPath" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 3281ec6b172..5ae617356e0 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -16,11 +16,6 @@
type: String,
required: true,
},
- updatedAt: {
- type: String,
- required: false,
- default: '',
- },
taskStatus: {
type: String,
required: false,
@@ -31,7 +26,6 @@
return {
preAnimation: false,
pulseAnimation: false,
- timeAgoEl: $('.js-issue-edited-ago'),
};
},
watch: {
@@ -39,12 +33,6 @@
this.animateChange();
this.$nextTick(() => {
- const toolTipTime = gl.utils.formatDate(this.updatedAt);
-
- this.timeAgoEl.attr('datetime', this.updatedAt)
- .attr('title', toolTipTime)
- .tooltip('fixTitle');
-
this.renderGFM();
});
},
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
new file mode 100644
index 00000000000..d59e6d11032
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -0,0 +1,56 @@
+<script>
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ components: {
+ timeAgoTooltip,
+ },
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
+ },
+};
+</script>
+
+<template>
+ <small
+ class="edited-text"
+ >
+ Edited
+ <time-ago-tooltip
+ v-if="updatedAt"
+ placement="bottom"
+ :time="updatedAt"
+ />
+ <span
+ v-if="hasUpdatedBy"
+ >
+ by
+ <a
+ class="author_link"
+ :href="updatedByPath"
+ >
+ <span>{{updatedByName}}</span>
+ </a>
+ </span>
+ </small>
+</template>
+
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index faf79471946..14b2a1e18e9 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -42,6 +42,9 @@ document.addEventListener('DOMContentLoaded', () => {
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
},
});
},
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 4a16c3cb4dc..27c2d349f52 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -4,6 +4,9 @@ export default class Store {
titleText,
descriptionHtml,
descriptionText,
+ updatedAt,
+ updatedByName,
+ updatedByPath,
}) {
this.state = {
titleHtml,
@@ -11,7 +14,9 @@ export default class Store {
descriptionHtml,
descriptionText,
taskStatus: '',
- updatedAt: '',
+ updatedAt,
+ updatedByName,
+ updatedByPath,
};
this.formState = {
title: '',
@@ -30,6 +35,8 @@ export default class Store {
this.state.descriptionText = data.description_text;
this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at;
+ this.state.updatedByName = data.updated_by_name;
+ this.state.updatedByPath = data.updated_by_path;
}
stateShouldUpdate(data) {
diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js
deleted file mode 100644
index fee3429e2b8..00000000000
--- a/app/assets/javascripts/issues_bulk_assignment.js
+++ /dev/null
@@ -1,166 +0,0 @@
-/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */
-/* global Issuable */
-/* global Flash */
-
-((global) => {
- class IssuableBulkActions {
- constructor({ container, form, issues, prefixId } = {}) {
- this.prefixId = prefixId || 'issue_';
- this.form = form || this.getElement('.bulk-update');
- this.$labelDropdown = this.form.find('.js-label-select');
- this.issues = issues || this.getElement('.issues-list .issue');
- this.form.data('bulkActions', this);
- this.willUpdateLabels = false;
- this.bindEvents();
- // Fixes bulk-assign not working when navigating through pages
- Issuable.initChecks();
- }
-
- bindEvents() {
- return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
- }
-
- onFormSubmit(e) {
- e.preventDefault();
- return this.submit();
- }
-
- submit() {
- const _this = this;
- const xhr = $.ajax({
- url: this.form.attr('action'),
- method: this.form.attr('method'),
- dataType: 'JSON',
- data: this.getFormDataAsObject()
- });
- xhr.done(() => window.location.reload());
- xhr.fail(() => new Flash("Issue update failed"));
- return xhr.always(this.onFormSubmitAlways.bind(this));
- }
-
- onFormSubmitAlways() {
- return this.form.find('[type="submit"]').enable();
- }
-
- getSelectedIssues() {
- return this.issues.has('.selected_issue:checked');
- }
-
- getLabelsFromSelection() {
- const labels = [];
- this.getSelectedIssues().map(function() {
- const labelsData = $(this).data('labels');
- if (labelsData) {
- return labelsData.map(function(labelId) {
- if (labels.indexOf(labelId) === -1) {
- return labels.push(labelId);
- }
- });
- }
- });
- return labels;
- }
-
- /**
- * Will return only labels that were marked previously and the user has unmarked
- * @return {Array} Label IDs
- */
-
- getUnmarkedIndeterminedLabels() {
- const result = [];
- const labelsToKeep = this.$labelDropdown.data('indeterminate');
-
- this.getLabelsFromSelection().forEach((id) => {
- if (labelsToKeep.indexOf(id) === -1) {
- result.push(id);
- }
- });
-
- return result;
- }
-
- /**
- * Simple form serialization, it will return just what we need
- * Returns key/value pairs from form data
- */
-
- getFormDataAsObject() {
- const formData = {
- update: {
- state_event: this.form.find('input[name="update[state_event]"]').val(),
- // For Merge Requests
- assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
- // For Issues
- assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
- milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
- issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
- subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
- add_label_ids: [],
- remove_label_ids: []
- }
- };
- if (this.willUpdateLabels) {
- formData.update.add_label_ids = this.$labelDropdown.data('marked');
- formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
- }
- return formData;
- }
-
- setOriginalDropdownData() {
- const $labelSelect = $('.bulk-update .js-label-select');
- $labelSelect.data('common', this.getOriginalCommonIds());
- $labelSelect.data('marked', this.getOriginalMarkedIds());
- $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
- }
-
- // From issuable's initial bulk selection
- getOriginalCommonIds() {
- const labelIds = [];
-
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalMarkedIds() {
- const labelIds = [];
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalIndeterminateIds() {
- const uniqueIds = [];
- const labelIds = [];
- let issuableLabels = [];
-
- // Collect unique label IDs for all checked issues
- this.getElement('.selected_issue:checked').each((i, el) => {
- issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
- issuableLabels.forEach((labelId) => {
- // Store unique IDs
- if (uniqueIds.indexOf(labelId) === -1) {
- uniqueIds.push(labelId);
- }
- });
- // Store array of IDs per issuable
- labelIds.push(issuableLabels);
- });
- // Add uniqueIds to add it as argument for _.intersection
- labelIds.unshift(uniqueIds);
- // Return IDs that are present but not in all selected issueables
- return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
- }
-
- getElement(selector) {
- this.scopeEl = this.scopeEl || $('.content');
- return this.scopeEl.find(selector);
- }
- }
-
- global.IssuableBulkActions = IssuableBulkActions;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index ac5ce84e31b..8d7d3d73571 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -2,6 +2,8 @@
/* global Issuable */
/* global ListLabel */
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+
(function() {
this.LabelsSelect = (function() {
function LabelsSelect(els) {
@@ -430,20 +432,15 @@
if ($('.selected_issue:checked').length) {
return;
}
- return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label');
+ return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
};
LabelsSelect.prototype.enableBulkLabelDropdown = function() {
- var issuableBulkActions;
- if ($('.selected_issue:checked').length) {
- issuableBulkActions = $('.bulk-update').data('bulkActions');
- return issuableBulkActions.willUpdateLabels = true;
- }
+ IssuableBulkUpdateActions.willUpdateLabels = true;
};
LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) {
var i, markedIds, unmarkedIds, indeterminateIds;
- var issuableBulkActions = $('.bulk-update').data('bulkActions');
markedIds = $dropdown.data('marked') || [];
unmarkedIds = $dropdown.data('unmarked') || [];
@@ -469,13 +466,13 @@
}
// If an indeterminate item is being unmarked
- if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
// If a marked item is being unmarked
// (a marked item could also be a label that is present in all selection)
- if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
}
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
index f1fe95e12e8..7477b5a5214 100644
--- a/app/assets/javascripts/lib/utils/ajax_cache.js
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -6,8 +6,8 @@ class AjaxCache extends Cache {
this.pendingRequests = { };
}
- retrieve(endpoint) {
- if (this.hasData(endpoint)) {
+ retrieve(endpoint, forceRetrieve) {
+ if (this.hasData(endpoint) && !forceRetrieve) {
return Promise.resolve(this.get(endpoint));
}
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index a537267643e..2aca86189fd 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -167,8 +167,8 @@
if the name does not exist this function will return `null`
otherwise it will return the value of the param key provided
*/
- w.gl.utils.getParameterByName = (name) => {
- const url = window.location.href;
+ w.gl.utils.getParameterByName = (name, parseUrl) => {
+ const url = parseUrl || window.location.href;
name = name.replace(/[[\]]/g, '\\$&');
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
const results = regex.exec(url);
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index b2f48049bb4..40eadd9396c 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -3,6 +3,11 @@
import timeago from 'timeago.js';
import dateFormat from 'vendor/date.format';
+import {
+ lang,
+ s__,
+} from '../../locale';
+
window.timeago = timeago;
window.dateFormat = dateFormat;
@@ -48,26 +53,45 @@ window.dateFormat = dateFormat;
var locale;
if (!timeagoInstance) {
+ const localeRemaining = function(number, index) {
+ return [
+ [s__('Timeago|less than a minute ago'), s__('Timeago|a while')],
+ [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')],
+ [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')],
+ [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
+ [s__('Timeago|about an hour ago'), s__('Timeago|1 hour remaining')],
+ [s__('Timeago|about %s hours ago'), s__('Timeago|%s hours remaining')],
+ [s__('Timeago|a day ago'), s__('Timeago|1 day remaining')],
+ [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
+ [s__('Timeago|a week ago'), s__('Timeago|1 week remaining')],
+ [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
+ [s__('Timeago|a month ago'), s__('Timeago|1 month remaining')],
+ [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
+ [s__('Timeago|a year ago'), s__('Timeago|1 year remaining')],
+ [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')]
+ ][index];
+ };
locale = function(number, index) {
return [
- ['less than a minute ago', 'a while'],
- ['less than a minute ago', 'in %s seconds'],
- ['about a minute ago', 'in 1 minute'],
- ['%s minutes ago', 'in %s minutes'],
- ['about an hour ago', 'in 1 hour'],
- ['about %s hours ago', 'in %s hours'],
- ['a day ago', 'in 1 day'],
- ['%s days ago', 'in %s days'],
- ['a week ago', 'in 1 week'],
- ['%s weeks ago', 'in %s weeks'],
- ['a month ago', 'in 1 month'],
- ['%s months ago', 'in %s months'],
- ['a year ago', 'in 1 year'],
- ['%s years ago', 'in %s years']
+ [s__('Timeago|less than a minute ago'), s__('Timeago|a while')],
+ [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')],
+ [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')],
+ [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
+ [s__('Timeago|about an hour ago'), s__('Timeago|in 1 hour')],
+ [s__('Timeago|about %s hours ago'), s__('Timeago|in %s hours')],
+ [s__('Timeago|a day ago'), s__('Timeago|in 1 day')],
+ [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
+ [s__('Timeago|a week ago'), s__('Timeago|in 1 week')],
+ [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
+ [s__('Timeago|a month ago'), s__('Timeago|in 1 month')],
+ [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
+ [s__('Timeago|a year ago'), s__('Timeago|in 1 year')],
+ [s__('Timeago|%s years ago'), s__('Timeago|in %s years')]
][index];
};
- timeago.register('gl_en', locale);
+ timeago.register(lang, locale);
+ timeago.register(`${lang}-remaining`, localeRemaining);
timeagoInstance = timeago();
}
@@ -79,13 +103,11 @@ window.dateFormat = dateFormat;
if (!time) {
return '';
}
- suffix || (suffix = 'remaining');
- expiredLabel || (expiredLabel = 'Past due');
- timefor = gl.utils.getTimeago().format(time).replace('in', '');
- if (timefor.indexOf('ago') > -1) {
+ if (new Date(time) < new Date()) {
+ expiredLabel || (expiredLabel = s__('Timeago|Past due'));
timefor = expiredLabel;
} else {
- timefor = timefor.trim() + ' ' + suffix;
+ timefor = gl.utils.getTimeago().format(time, `${lang}-remaining`).trim();
}
return timefor;
};
@@ -102,7 +124,7 @@ window.dateFormat = dateFormat;
};
w.gl.utils.updateTimeagoText = function(el) {
- const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), 'gl_en');
+ const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), lang);
if (el.textContent !== formattedDate) {
el.textContent = formattedDate;
diff --git a/app/assets/javascripts/locale/bg/app.js b/app/assets/javascripts/locale/bg/app.js
new file mode 100644
index 00000000000..ba56c0bea25
--- /dev/null
+++ b/app/assets/javascripts/locale/bg/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['bg'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-05 09:40-0400","Last-Translator":"Lyubomir Vasilev <lyubomirv@abv.bg>","Language-Team":"Bulgarian","Language":"bg","X-Generator":"Zanata 3.9.6","Plural-Forms":"nplurals=2; plural=(n != 1)","lang":"bg","domain":"app","plural_forms":"nplurals=2; plural=(n != 1)"},"ByAuthor|by":["от"],"Commit":["Подаване","Подавания"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Анализът на циклите дава общ поглед върху това колко време е нужно на една идея да се превърне в завършена функционалност в проекта."],"CycleAnalyticsStage|Code":["Програмиране"],"CycleAnalyticsStage|Issue":["Проблем"],"CycleAnalyticsStage|Plan":["Планиране"],"CycleAnalyticsStage|Production":["Издаване"],"CycleAnalyticsStage|Review":["Преглед и одобрение"],"CycleAnalyticsStage|Staging":["Подготовка за издаване"],"CycleAnalyticsStage|Test":["Тестване"],"Deploy":["Внедряване","Внедрявания"],"FirstPushedBy|First":["Първо"],"FirstPushedBy|pushed by":["изпращане на промени от"],"From issue creation until deploy to production":["От създаването на проблема до внедряването в крайната версия"],"From merge request merge until deploy to production":["От прилагането на заявката за сливане до внедряването в крайната версия"],"Introducing Cycle Analytics":["Представяме Ви анализът на циклите"],"Last %d day":["Последния %d ден","Последните %d дни"],"Limited to showing %d event at most":["Ограничено до показване на последното %d събитие","Ограничено до показване на последните %d събития"],"Median":["Медиана"],"New Issue":["Нов проблем","Нови проблема"],"Not available":["Не е налично"],"Not enough data":["Няма достатъчно данни"],"OpenedNDaysAgo|Opened":["Отворен"],"Pipeline Health":["Състояние"],"ProjectLifecycle|Stage":["Етап"],"Read more":["Прочетете повече"],"Related Commits":["Свързани подавания"],"Related Deployed Jobs":["Свързани задачи за внедряване"],"Related Issues":["Свързани проблеми"],"Related Jobs":["Свързани задачи"],"Related Merge Requests":["Свързани заявки за сливане"],"Related Merged Requests":["Свързани приложени заявки за сливане"],"Showing %d event":["Показване на %d събитие","Показване на %d събития"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Етапът на програмиране показва времето от първото подаване до създаването на заявката за сливане. Данните ще бъдат добавени тук автоматично след като бъде създадена първата заявка за сливане."],"The collection of events added to the data gathered for that stage.":["Съвкупността от събития добавени към данните събрани за този етап."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Етапът на проблемите показва колко е времето от създаването на проблем до определянето на целеви етап на проекта за него, или до добавянето му в списък на дъската за проблеми. Започнете да добавяте проблеми, за да видите данните за този етап."],"The phase of the development lifecycle.":["Етапът от цикъла на разработка"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Етапът на планиране показва колко е времето от преходната стъпка до изпращането на първото подаване. Това време ще бъде добавено автоматично след като изпратите първото си подаване."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Етапът на издаване показва общото време, което е нужно от създаването на проблем до внедряването на кода в крайната версия."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Етапът на преглед и одобрение показва времето от създаването на заявката за сливане до прилагането ѝ. Данните ще бъдат добавени автоматично след като приложите първата си заявка за сливане."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Етапът на подготовка за издаване показва времето между прилагането на заявката за сливане и внедряването на кода в средата на работещата крайна версия. Данните ще бъдат добавени автоматично след като направите първото си внедряване в крайната версия."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Етапът на тестване показва времето, което е нужно на „Gitlab CI“ да изпълни всички задачи за свързаната заявка за сливане. Данните ще бъдат добавени автоматично след като приключи изпълнените на първата Ви такава задача."],"The time taken by each data entry gathered by that stage.":["Времето, което отнема всеки запис от данни за съответния етап."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Стойността, която се намира в средата на последователността от наблюдавани данни. Например: медианата на 3, 5 и 9 е 5, а медианата на 3, 5, 7 и 8 е (5+7)/2 = 6."],"Time before an issue gets scheduled":["Време преди един проблем да бъде планиран за работа"],"Time before an issue starts implementation":["Време преди работата по проблем да започне"],"Time between merge request creation and merge/close":["Време между създаване на заявка за сливане и прилагането/отхвърлянето ѝ"],"Time until first merge request":["Време преди първата заявка за сливане"],"Time|hr":["час","часа"],"Time|min":["мин","мин"],"Time|s":["сек"],"Total Time":["Общо време"],"Total test time for all commits/merges":["Общо време за тестване на всички подавания/сливания"],"Want to see the data? Please ask an administrator for access.":["Искате ли да видите данните? Помолете администратор за достъп."],"We don't have enough data to show this stage.":["Няма достатъчно данни за този етап."],"You need permission.":["Нуждаете се от разрешение."],"day":["ден","дни"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js
index 9411f078ecf..5f6d9f01bf7 100644
--- a/app/assets/javascripts/locale/de/app.js
+++ b/app/assets/javascripts/locale/de/app.js
@@ -1 +1 @@
-var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}}; \ No newline at end of file
+var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Are you sure you want to delete this pipeline schedule?":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":["Von"],"CI configuration":[""],"Cancel":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["Commit","Commits"],"CommitMessage|Add %{file_name}":["Commit\u0000Commits"],"Commits":["Commit\u0000Commits"],"Commits|History":["Commit\u0000Commits"],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":["Zugehörige Merge Requests"],"CreateNewFork|Fork":[""],"Cron Timezone":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Delete":[""],"Deploy":["Deployment","Deployments"],"Description":[""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Files":[""],"Filter":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Interval Pattern":[""],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Last Pipeline":[""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["Neues Issue","Neue Issues"],"New Pipeline Schedule":[""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":["Neues Issue\u0000Neue Issues"],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"No schedules":[""],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":["Erstellt"],"Owner":[""],"Pipeline Health":["Pipeline Kennzahlen"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":["Phase"],"ProjectNetworkGraph|Graph":[""],"Read more":["Mehr"],"Readme":["Mehr"],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Select a timezone":[""],"Select target branch":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"Target Branch":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Timeago|%s days ago":["Tag\u0000Tage"],"Timeago|%s days remaining":["Tag\u0000Tage"],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":["Tag\u0000Tage"],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":["Tag\u0000Tage"],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":["Tag\u0000Tage"],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":["Tag\u0000Tage"],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":["Sie benötigen Zugriffsrechte."],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["Tag","Tage"],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js
index ade9b667b3c..774dc2f094f 100644
--- a/app/assets/javascripts/locale/en/app.js
+++ b/app/assets/javascripts/locale/en/app.js
@@ -1 +1 @@
-var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file
+var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Are you sure you want to delete this pipeline schedule?":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":[""],"CI configuration":[""],"Cancel":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["",""],"CommitMessage|Add %{file_name}":[""],"Commits":[""],"Commits|History":[""],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":[""],"CreateNewFork|Fork":[""],"Cron Timezone":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Delete":[""],"Deploy":["",""],"Description":[""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Files":[""],"Filter":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Interval Pattern":[""],"Introducing Cycle Analytics":[""],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["",""],"Last Pipeline":[""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["",""],"Median":[""],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["",""],"New Pipeline Schedule":[""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":[""],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"No schedules":[""],"Not available":[""],"Not enough data":[""],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":[""],"Owner":[""],"Pipeline Health":[""],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":[""],"ProjectNetworkGraph|Graph":[""],"Read more":[""],"Readme":[""],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Select a timezone":[""],"Select target branch":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["",""],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"Target Branch":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Timeago|%s days ago":[""],"Timeago|%s days remaining":[""],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":[""],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":[""],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":[""],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":[""],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":[""],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["",""],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js
index f5f510d7c2b..0fbe4ada8fa 100644
--- a/app/assets/javascripts/locale/es/app.js
+++ b/app/assets/javascripts/locale/es/app.js
@@ -1 +1 @@
-var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-20 22:37-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d día","Últimos %d días"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["día","días"]}}}; \ No newline at end of file
+var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-20 22:37-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Are you sure you want to delete this pipeline schedule?":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":["por"],"CI configuration":[""],"Cancel":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["Cambio","Cambios"],"CommitMessage|Add %{file_name}":["Cambio\u0000Cambios"],"Commits":["Cambio\u0000Cambios"],"Commits|History":["Cambio\u0000Cambios"],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":["Solicitudes de fusión Relacionadas"],"CreateNewFork|Fork":[""],"Cron Timezone":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Delete":[""],"Deploy":["Despliegue","Despliegues"],"Description":[""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Files":[""],"Filter":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Interval Pattern":[""],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["Último %d día","Últimos %d días"],"Last Pipeline":[""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["Nueva incidencia","Nuevas incidencias"],"New Pipeline Schedule":[""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":["Nueva incidencia\u0000Nuevas incidencias"],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"No schedules":[""],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":["Abierto"],"Owner":[""],"Pipeline Health":["Estado del Pipeline"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":["Etapa"],"ProjectNetworkGraph|Graph":[""],"Read more":["Leer más"],"Readme":["Leer más"],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Select a timezone":[""],"Select target branch":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"Target Branch":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Timeago|%s days ago":["día\u0000días"],"Timeago|%s days remaining":["día\u0000días"],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":["día\u0000días"],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":["día\u0000días"],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":["día\u0000días"],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":["día\u0000días"],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":["Necesitas permisos."],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["día","días"],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/pt_BR/app.js b/app/assets/javascripts/locale/pt_BR/app.js
new file mode 100644
index 00000000000..f2eed3da064
--- /dev/null
+++ b/app/assets/javascripts/locale/pt_BR/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['pt_BR'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-05 03:29-0400","Last-Translator":"Alexandre Alencar <alexandre.alencar@gmail.com>","Language-Team":"Portuguese (Brazil)","Language":"pt-BR","X-Generator":"Zanata 3.9.6","Plural-Forms":"nplurals=2; plural=(n != 1)","lang":"pt_BR","domain":"app","plural_forms":"nplurals=2; plural=(n != 1)"},"ByAuthor|by":["por"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["A Análise de Ciclo fornece uma visão geral de quanto tempo uma ideia demora para ir para produção em seu projeto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Tarefa"],"CycleAnalyticsStage|Plan":["Plano"],"CycleAnalyticsStage|Production":["Produção"],"CycleAnalyticsStage|Review":["Revisão"],"CycleAnalyticsStage|Staging":["Homologação"],"CycleAnalyticsStage|Test":["Teste"],"Deploy":["Implantação","Implantações"],"FirstPushedBy|First":["Primeiro"],"FirstPushedBy|pushed by":["publicado por"],"From issue creation until deploy to production":["Da criação de tarefas até a implantação para a produção"],"From merge request merge until deploy to production":["Da incorporação do merge request até a implantação em produção"],"Introducing Cycle Analytics":["Apresentando a Análise de Ciclo"],"Last %d day":["Último %d dia","Últimos %d dias"],"Limited to showing %d event at most":["Limitado a mostrar %d evento no máximo","Limitado a mostrar %d eventos no máximo"],"Median":["Mediana"],"New Issue":["Nova Tarefa","Novas Tarefas"],"Not available":["Não disponível"],"Not enough data":["Dados insuficientes"],"OpenedNDaysAgo|Opened":["Aberto"],"Pipeline Health":["Saúde da Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Ler mais"],"Related Commits":["Commits Relacionados"],"Related Deployed Jobs":["Jobs Relacionados Incorporados"],"Related Issues":["Tarefas Relacionadas"],"Related Jobs":["Jobs Relacionados"],"Related Merge Requests":["Merge Requests Relacionados"],"Related Merged Requests":["Merge Requests Relacionados"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["O estágio de codificação mostra o tempo desde o primeiro commit até a criação do merge request. \\nOs dados serão automaticamente adicionados aqui uma vez que você tenha criado seu primeiro merge request."],"The collection of events added to the data gathered for that stage.":["A coleção de eventos adicionados aos dados coletados para esse estágio."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["O estágio em questão mostra o tempo que leva desde a criação de uma tarefa até a sua assinatura para um milestone, ou a sua adição para a lista no seu Painel de Tarefas. Comece a criar tarefas para ver dados para esta etapa."],"The phase of the development lifecycle.":["A fase do ciclo de vida do desenvolvimento."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["A fase de planejamento mostra o tempo do passo anterior até empurrar o seu primeiro commit. Este tempo será adicionado automaticamente assim que você realizar seu primeiro commit."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["O estágio de produção mostra o tempo total que leva entre criar uma tarefa e implantar o código na produção. Os dados serão adicionados automaticamente até que você complete todo o ciclo de produção."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["A etapa de revisão mostra o tempo de criação de um merge request até que o merge seja feito. Os dados serão automaticamente adicionados depois que você fizer seu primeiro merge request."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["O estágio de estágio mostra o tempo entre a fusão do MR e o código de implantação para o ambiente de produção. Os dados serão automaticamente adicionados depois de implantar na produção pela primeira vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["A fase de teste mostra o tempo que o GitLab CI leva para executar cada pipeline para o merge request relacionado. Os dados serão automaticamente adicionados após a conclusão do primeiro pipeline."],"The time taken by each data entry gathered by that stage.":["O tempo necessário para cada entrada de dados reunida por essa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["O valor situado no ponto médio de uma série de valores observados. Ex., entre 3, 5, 9, a mediana é 5. Entre 3, 5, 7, 8, a mediana é (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tempo até que uma tarefa seja planejada"],"Time before an issue starts implementation":["Tempo até que uma tarefa comece a ser implementada"],"Time between merge request creation and merge/close":["Tempo entre a criação do merge request e o merge/fechamento"],"Time until first merge request":["Tempo até o primeiro merge request"],"Time|hr":["h","hs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tempo Total"],"Total test time for all commits/merges":["Tempo de teste total para todos os commits/merges"],"Want to see the data? Please ask an administrator for access.":["Precisa visualizar os dados? Solicite acesso ao administrador."],"We don't have enough data to show this stage.":["Não temos dados suficientes para mostrar esta fase."],"You need permission.":["Você precisa de permissão."],"day":["dia","dias"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js
new file mode 100644
index 00000000000..2439fa931ef
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_CN/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_CN'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_CN","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Are you sure you want to delete this pipeline schedule?":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":["作者:"],"CI configuration":[""],"Cancel":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["提交"],"CommitMessage|Add %{file_name}":["提交"],"Commits":["提交"],"Commits|History":["提交"],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":["相关的合并请求"],"CreateNewFork|Fork":[""],"Cron Timezone":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["周期分析概述了项目从想法到产品实现的各阶段所需的时间。"],"CycleAnalyticsStage|Code":["编码"],"CycleAnalyticsStage|Issue":["议题"],"CycleAnalyticsStage|Plan":["计划"],"CycleAnalyticsStage|Production":["生产"],"CycleAnalyticsStage|Review":["评审"],"CycleAnalyticsStage|Staging":["预发布"],"CycleAnalyticsStage|Test":["测试"],"Delete":[""],"Deploy":["部署"],"Description":[""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Files":[""],"Filter":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":["从创建议题到部署至生产环境"],"From merge request merge until deploy to production":["从合并请求被合并后到部署至生产环境"],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Interval Pattern":[""],"Introducing Cycle Analytics":["周期分析简介"],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["最后 %d 天"],"Last Pipeline":[""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["最多显示 %d 个事件"],"Median":["中位数"],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["新议题"],"New Pipeline Schedule":[""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":["新议题"],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"No schedules":[""],"Not available":["数据不足"],"Not enough data":["数据不足"],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":["开始于"],"Owner":[""],"Pipeline Health":["流水线健康指标"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":["项目生命周期"],"ProjectNetworkGraph|Graph":[""],"Read more":["了解更多"],"Readme":["了解更多"],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":["相关的提交"],"Related Deployed Jobs":["相关的部署作业"],"Related Issues":["相关的议题"],"Related Jobs":["相关的作业"],"Related Merge Requests":["相关的合并请求"],"Related Merged Requests":["相关已合并的合并请求"],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Select a timezone":[""],"Select target branch":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["显示 %d 个事件"],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"Target Branch":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"],"The collection of events added to the data gathered for that stage.":["与该阶段相关的事件。"],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"],"The phase of the development lifecycle.":["项目生命周期中的各个阶段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"],"The time taken by each data entry gathered by that stage.":["该阶段每条数据所花的时间"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":["议题被列入日程表的时间"],"Time before an issue starts implementation":["开始进行编码前的时间"],"Time between merge request creation and merge/close":["从创建合并请求到被合并或关闭的时间"],"Time until first merge request":["创建第一个合并请求之前的时间"],"Timeago|%s days ago":["天"],"Timeago|%s days remaining":["天"],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":["天"],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":["天"],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":["天"],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":["天"],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["小时"],"Time|min":["分钟"],"Time|s":["秒"],"Total Time":["总时间"],"Total test time for all commits/merges":["所有提交和合并的总测试时间"],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":["权限不足。如需查看相关数据,请向管理员申请权限。"],"We don't have enough data to show this stage.":["该阶段的数据不足,无法显示。"],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":["您需要相关的权限。"],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["天"],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_HK/app.js b/app/assets/javascripts/locale/zh_HK/app.js
new file mode 100644
index 00000000000..216b6286d93
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_HK/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_HK'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_HK","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_HK","domain":"app","plural_forms":"nplurals=1; plural=0;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Are you sure you want to delete this pipeline schedule?":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":["作者:"],"CI configuration":[""],"Cancel":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["提交"],"CommitMessage|Add %{file_name}":["提交"],"Commits":["提交"],"Commits|History":["提交"],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":["相關的合併請求"],"CreateNewFork|Fork":[""],"Cron Timezone":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了項目從想法到產品實現的各階段所需的時間。"],"CycleAnalyticsStage|Code":["編碼"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["生產"],"CycleAnalyticsStage|Review":["評審"],"CycleAnalyticsStage|Staging":["預發布"],"CycleAnalyticsStage|Test":["測試"],"Delete":[""],"Deploy":["部署"],"Description":[""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Files":[""],"Filter":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":["從創建議題到部署到生產環境"],"From merge request merge until deploy to production":["從合併請求的合併到部署至生產環境"],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Interval Pattern":[""],"Introducing Cycle Analytics":["週期分析簡介"],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["最後 %d 天"],"Last Pipeline":[""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["新議題"],"New Pipeline Schedule":[""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":["新議題"],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"No schedules":[""],"Not available":["不可用"],"Not enough data":["數據不足"],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":["開始於"],"Owner":[""],"Pipeline Health":["流水線健康指標"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":["項目生命週期"],"ProjectNetworkGraph|Graph":[""],"Read more":["了解更多"],"Readme":["了解更多"],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":["相關的提交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的合並請求"],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Select a timezone":[""],"Select target branch":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["顯示 %d 個事件"],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"Target Branch":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"],"The phase of the development lifecycle.":["項目生命週期中的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"],"The time taken by each data entry gathered by that stage.":["該階段每條數據所花的時間"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["開始進行編碼前的時間"],"Time between merge request creation and merge/close":["從創建合併請求到被合並或關閉的時間"],"Time until first merge request":["創建第壹個合併請求之前的時間"],"Timeago|%s days ago":["天"],"Timeago|%s days remaining":["天"],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":["天"],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":["天"],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":["天"],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":["天"],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有提交和合併的總測試時間"],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關數據,請向管理員申請權限。"],"We don't have enough data to show this stage.":["該階段的數據不足,無法顯示。"],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":["您需要相關的權限。"],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["天"],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_TW/app.js b/app/assets/javascripts/locale/zh_TW/app.js
new file mode 100644
index 00000000000..6b5420015e8
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_TW/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_TW'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_TW","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_TW","domain":"app","plural_forms":"nplurals=1; plural=0;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Are you sure you want to delete this pipeline schedule?":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":["作者:"],"CI configuration":[""],"Cancel":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["送交"],"CommitMessage|Add %{file_name}":["送交"],"Commits":["送交"],"Commits|History":["送交"],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":["相關的合併請求"],"CreateNewFork|Fork":[""],"Cron Timezone":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"],"CycleAnalyticsStage|Code":["程式開發"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["上線"],"CycleAnalyticsStage|Review":["複閱"],"CycleAnalyticsStage|Staging":["預備"],"CycleAnalyticsStage|Test":["測試"],"Delete":[""],"Deploy":["部署"],"Description":[""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Files":[""],"Filter":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":["從議題建立至線上部署"],"From merge request merge until deploy to production":["從請求被合併後至線上部署"],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Interval Pattern":[""],"Introducing Cycle Analytics":["週期分析簡介"],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["最後 %d 天"],"Last Pipeline":[""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["新議題"],"New Pipeline Schedule":[""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":["新議題"],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"No schedules":[""],"Not available":["無法使用"],"Not enough data":["資料不足"],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":["開始於"],"Owner":[""],"Pipeline Health":["流水線健康指標"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":["專案生命週期"],"ProjectNetworkGraph|Graph":[""],"Read more":["了解更多"],"Readme":["了解更多"],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":["相關的送交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的請求"],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Select a timezone":[""],"Select target branch":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["顯示 %d 個事件"],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"Target Branch":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"],"The phase of the development lifecycle.":["專案開發生命週期的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"],"The time taken by each data entry gathered by that stage.":["每筆該階段相關資料所花的時間。"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["議題等待開始實作的時間"],"Time between merge request creation and merge/close":["合併請求被合併或是關閉的時間"],"Time until first merge request":["第一個合併請求被建立前的時間"],"Timeago|%s days ago":["天"],"Timeago|%s days remaining":["天"],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":["天"],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":["天"],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":["天"],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":["天"],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有送交和合併的總測試時間"],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關資料,請向管理員申請權限。"],"We don't have enough data to show this stage.":["因該階段的資料不足而無法顯示相關資訊"],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":["您需要相關的權限。"],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["天"],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 1ac82b7e291..fe367d0c42a 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -104,12 +104,11 @@ import './group_label_subscription';
import './groups_select';
import './header';
import './importer_status';
-import './issuable';
+import './issuable_index';
import './issuable_context';
import './issuable_form';
import './issue';
import './issue_status_select';
-import './issues_bulk_assignment';
import './label_manager';
import './labels';
import './labels_select';
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 0ca7cabfc5a..b0143b12cfe 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -16,6 +16,7 @@ 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 CommentTypeToggle from './comment_type_toggle';
import './autosave';
import './dropzone_input';
@@ -66,7 +67,6 @@ const normalizeNewlines = function(str) {
this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
- this.flashErrors = [];
this.cleanBinding();
this.addBinding();
@@ -325,6 +325,9 @@ const normalizeNewlines = function(str) {
if (Notes.isNewNote(noteEntity, this.note_ids)) {
this.note_ids.push(noteEntity.id);
+ if ($notesList.length) {
+ $notesList.find('.system-note.being-posted').remove();
+ }
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
this.setupNewNote($newNote);
@@ -1118,12 +1121,14 @@ const normalizeNewlines = function(str) {
};
Notes.prototype.addFlash = function(...flashParams) {
- this.flashErrors.push(new Flash(...flashParams));
+ this.flashInstance = new Flash(...flashParams);
};
Notes.prototype.clearFlash = function() {
- this.flashErrors.forEach(flash => flash.flashContainer.remove());
- this.flashErrors = [];
+ if (this.flashInstance && this.flashInstance.flashContainer) {
+ this.flashInstance.flashContainer.hide();
+ this.flashInstance = null;
+ }
};
Notes.prototype.cleanForm = function($form) {
@@ -1187,7 +1192,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.getFormData = function($form) {
return {
formData: $form.serialize(),
- formContent: $form.find('.js-note-text').val(),
+ formContent: _.escape($form.find('.js-note-text').val()),
formAction: $form.attr('action'),
};
};
@@ -1207,19 +1212,46 @@ const normalizeNewlines = function(str) {
};
/**
+ * Gets appropriate description from slash commands found in provided `formContent`
+ */
+ Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) {
+ let tempFormContent;
+
+ // Identify executed slash commands from `formContent`
+ const executedCommands = availableSlashCommands.filter((command, index) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(formContent);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ tempFormContent = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ tempFormContent = `Applying command to ${commandDescription}`;
+ }
+ } else {
+ tempFormContent = 'Applying command';
+ }
+
+ return tempFormContent;
+ };
+
+ /**
* Create placeholder note DOM element populated with comment body
* that we will show while comment is being posted.
* Once comment is _actually_ posted on server, we will have final element
* in response that we will show in place of this temporary element.
*/
- Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) {
+ Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
const discussionClass = isDiscussionNote ? 'discussion' : '';
- const escapedFormContent = _.escape(formContent);
const $tempNote = $(
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
- <a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a>
+ <a href="/${currentUsername}">
+ <img class="avatar s40" src="${currentUserAvatar}">
+ </a>
</div>
<div class="timeline-content ${discussionClass}">
<div class="note-header">
@@ -1232,7 +1264,7 @@ const normalizeNewlines = function(str) {
</div>
<div class="note-body">
<div class="note-text">
- <p>${escapedFormContent}</p>
+ <p>${formContent}</p>
</div>
</div>
</div>
@@ -1244,6 +1276,23 @@ const normalizeNewlines = function(str) {
};
/**
+ * Create Placeholder System Note DOM element populated with slash command description
+ */
+ Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) {
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <i>${formContent}</i>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ };
+
+ /**
* This method does following tasks step-by-step whenever a new comment
* is submitted by user (both main thread comments as well as discussion comments).
*
@@ -1274,7 +1323,9 @@ const normalizeNewlines = function(str) {
const isDiscussionForm = $form.hasClass('js-discussion-note-form');
const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
const { formData, formContent, formAction } = this.getFormData($form);
- const uniqueId = _.uniqueId('tempNote_');
+ let noteUniqueId;
+ let systemNoteUniqueId;
+ let hasSlashCommands = false;
let $notesContainer;
let tempFormContent;
@@ -1295,16 +1346,28 @@ const normalizeNewlines = function(str) {
tempFormContent = formContent;
if (this.hasSlashCommands(formContent)) {
tempFormContent = this.stripSlashCommands(formContent);
+ hasSlashCommands = true;
}
+ // Show placeholder note
if (tempFormContent) {
- // Show placeholder note
+ noteUniqueId = _.uniqueId('tempNote_');
$notesContainer.append(this.createPlaceholderNote({
formContent: tempFormContent,
- uniqueId,
+ uniqueId: noteUniqueId,
isDiscussionNote,
currentUsername: gon.current_username,
currentUserFullname: gon.current_user_fullname,
+ currentUserAvatar: gon.current_user_avatar_url,
+ }));
+ }
+
+ // Show placeholder system note
+ if (hasSlashCommands) {
+ systemNoteUniqueId = _.uniqueId('tempSystemNote_');
+ $notesContainer.append(this.createPlaceholderSystemNote({
+ formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)),
+ uniqueId: systemNoteUniqueId,
}));
}
@@ -1322,7 +1385,13 @@ const normalizeNewlines = function(str) {
gl.utils.ajaxPost(formAction, formData)
.then((note) => {
// Submission successful! remove placeholder
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ // Reset cached commands list when command is applied
+ if (hasSlashCommands) {
+ $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
+ }
+
// Clear previous form errors
this.clearFlashWrapper();
@@ -1359,7 +1428,11 @@ const normalizeNewlines = function(str) {
$form.trigger('ajax:success', [note]);
}).fail(() => {
// Submission failed, remove placeholder note and show Flash error message
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ if (hasSlashCommands) {
+ $notesContainer.find(`#${systemNoteUniqueId}`).remove();
+ }
// Show form again on UI on failure
if (isDiscussionForm && $notesContainer.length) {
@@ -1405,7 +1478,7 @@ const normalizeNewlines = function(str) {
const cachedNoteBodyText = $noteBodyText.html();
// Show updated comment content temporarily
- $noteBodyText.html(formContent);
+ $noteBodyText.html(_.escape(formContent));
$editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half');
$editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>');
@@ -1418,7 +1491,7 @@ const normalizeNewlines = function(str) {
})
.fail(() => {
// Submission failed, revert back to original note
- $noteBodyText.html(cachedNoteBodyText);
+ $noteBodyText.html(_.escape(cachedNoteBodyText));
$editingNote.removeClass('being-posted fade-in');
$editingNote.find('.fa.fa-spinner').remove();
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 0ef20af9260..01110420cca 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -6,11 +6,12 @@ import '~/lib/utils/url_utility';
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
const Pager = {
- init(limit = 0, preload = false, disable = false, callback = $.noop) {
+ init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) {
this.url = $('.content_list').data('href') || gl.utils.removeParams(['limit', 'offset']);
this.limit = limit;
this.offset = parseInt(gl.utils.getParameterByName('offset'), 10) || this.limit;
this.disable = disable;
+ this.prepareData = prepareData;
this.callback = callback;
this.loading = $('.loading').first();
if (preload) {
@@ -29,7 +30,7 @@ import '~/lib/utils/url_utility';
dataType: 'json',
error: () => this.loading.hide(),
success: (data) => {
- this.append(data.count, data.html);
+ this.append(data.count, this.prepareData(data.html));
this.callback();
// keep loading until we've filled the viewport height
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 7fc19fce1ff..c05c76c9a64 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -16,6 +16,7 @@
/* global Flash */
import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tooltipMixin from '../../vue_shared/mixins/tooltip';
export default {
props: {
@@ -31,6 +32,10 @@ export default {
},
},
+ mixins: [
+ tooltipMixin,
+ ],
+
data() {
return {
isLoading: false,
@@ -127,9 +132,10 @@ export default {
<template>
<div class="dropdown">
<button
+ ref="tooltip"
:class="triggerButtonClass"
@click="onClickStage"
- class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button"
+ class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js
index 9f247af1dec..23b967b4b32 100644
--- a/app/assets/javascripts/pipelines/pipelines.js
+++ b/app/assets/javascripts/pipelines/pipelines.js
@@ -284,9 +284,7 @@ export default {
<table-pagination
v-if="shouldRenderPagination"
- :pagenum="pagenum"
:change="change"
- :count="state.count.all"
:pageInfo="state.pageInfo"
/>
</div>
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
index 04b381fe0e0..c0f757269cb 100644
--- a/app/assets/javascripts/project_new.js
+++ b/app/assets/javascripts/project_new.js
@@ -1,11 +1,17 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */
+function highlightChanges($elm) {
+ $elm.addClass('highlight-changes');
+ setTimeout(() => $elm.removeClass('highlight-changes'), 10);
+}
+
(function() {
this.ProjectNew = (function() {
function ProjectNew() {
this.toggleSettings = this.toggleSettings.bind(this);
this.$selects = $('.features select');
this.$repoSelects = this.$selects.filter('.js-repo-select');
+ this.$projectSelects = this.$selects.not('.js-repo-select');
$('.project-edit-container').on('ajax:before', (function(_this) {
return function() {
@@ -26,6 +32,42 @@
if (!visibilityContainer) return;
const visibilitySelect = new gl.VisibilitySelect(visibilityContainer);
visibilitySelect.init();
+
+ const $visibilitySelect = $(visibilityContainer).find('select');
+ let projectVisibility = $visibilitySelect.val();
+ const PROJECT_VISIBILITY_PRIVATE = '0';
+
+ $visibilitySelect.on('change', () => {
+ const newProjectVisibility = $visibilitySelect.val();
+
+ if (projectVisibility !== newProjectVisibility) {
+ this.$projectSelects.each((idx, select) => {
+ const $select = $(select);
+ const $options = $select.find('option');
+ const values = $.map($options, e => e.value);
+
+ // if switched to "private", limit visibility options
+ if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ if ($select.val() !== values[0] && $select.val() !== values[1]) {
+ $select.val(values[1]).trigger('change');
+ highlightChanges($select);
+ }
+ $options.slice(2).disable();
+ }
+
+ // if switched from "private", increase visibility for non-disabled options
+ if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ $options.enable();
+ if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) {
+ $select.val(values[values.length - 1]).trigger('change');
+ highlightChanges($select);
+ }
+ }
+ });
+
+ projectVisibility = newProjectVisibility;
+ }
+ });
};
ProjectNew.prototype.toggleSettings = function() {
@@ -56,8 +98,10 @@
ProjectNew.prototype.toggleRepoVisibility = function () {
var $repoAccessLevel = $('.js-repo-access-level select');
+ var $lfsEnabledOption = $('.js-lfs-enabled select');
var containerRegistry = document.querySelectorAll('.js-container-registry')[0];
var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled');
+ var prevSelectedVal = parseInt($repoAccessLevel.val(), 10);
this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']")
.nextAll()
@@ -71,29 +115,40 @@
var $this = $(this);
var repoSelectVal = parseInt($this.val(), 10);
- $this.find('option').show();
+ $this.find('option').enable();
- if (selectedVal < repoSelectVal) {
- $this.val(selectedVal);
+ if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
+ $this.val(selectedVal).trigger('change');
+ highlightChanges($this);
}
- $this.find("option[value='" + selectedVal + "']").nextAll().hide();
+ $this.find("option[value='" + selectedVal + "']").nextAll().disable();
});
if (selectedVal) {
this.$repoSelects.removeClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.removeClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = '';
}
} else {
this.$repoSelects.addClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.val('false').addClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = 'none';
containerRegistryCheckbox.checked = false;
}
}
+
+ prevSelectedVal = selectedVal;
}.bind(this));
};
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
index 068e9698e1d..9d045886262 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -10,7 +10,7 @@ export default class ProtectedTagDropdown {
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag');
+ this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
this.buildDropdown();
this.bindEvents();
@@ -73,7 +73,7 @@ export default class ProtectedTagDropdown {
};
this.$dropdownContainer
- .find('.create-new-protected-tag code')
+ .find('.js-create-new-protected-tag code')
.text(tagName);
}
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
new file mode 100644
index 00000000000..e67f449e1a2
--- /dev/null
+++ b/app/assets/javascripts/settings_panels.js
@@ -0,0 +1,27 @@
+function expandSection($section) {
+ $section.find('.js-settings-toggle').text('Close');
+ $section.find('.settings-content').addClass('expanded').off('scroll').scrollTop(0);
+}
+
+function closeSection($section) {
+ $section.find('.js-settings-toggle').text('Expand');
+ $section.find('.settings-content').removeClass('expanded').on('scroll', () => expandSection($section));
+}
+
+function toggleSection($section) {
+ const $content = $section.find('.settings-content');
+ $content.removeClass('no-animate');
+ if ($content.hasClass('expanded')) {
+ closeSection($section);
+ } else {
+ expandSection($section);
+ }
+}
+
+export default function initSettingsPanels() {
+ $('.settings').each((i, elm) => {
+ const $section = $(elm);
+ $section.on('click', '.js-settings-toggle', () => toggleSection($section));
+ $section.find('.settings-content:not(.expanded)').on('scroll', () => expandSection($section));
+ });
+}
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index b9d57cbcad4..ff2208baeab 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,11 +1,10 @@
import Cookies from 'js-cookie';
-const USER_CALLOUT_COOKIE = 'user_callout_dismissed';
-
export default class UserCallout {
- constructor() {
- this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE);
- this.userCalloutBody = $('.user-callout');
+ constructor(className = 'user-callout') {
+ this.userCalloutBody = $(`.${className}`);
+ this.cookieName = this.userCalloutBody.data('uid');
+ this.isCalloutDismissed = Cookies.get(this.cookieName);
this.init();
}
@@ -18,7 +17,7 @@ export default class UserCallout {
dismissCallout(e) {
const $currentTarget = $(e.currentTarget);
- Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 });
+ Cookies.set(this.cookieName, 'true', { expires: 365 });
if ($currentTarget.hasClass('close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js
index 8e22057e2e9..ff5ae28e062 100644
--- a/app/assets/javascripts/vue_shared/components/commit.js
+++ b/app/assets/javascripts/vue_shared/components/commit.js
@@ -135,8 +135,8 @@ export default {
{{shortSha}}
</a>
- <p class="commit-title">
- <span v-if="title">
+ <div class="commit-title flex-truncate-parent">
+ <span v-if="title" class="flex-truncate-child">
<user-avatar-link
v-if="hasAuthor"
class="avatar-image-container"
@@ -153,7 +153,7 @@ export default {
<span v-else>
Cant find HEAD commit for this branch
</span>
- </p>
+ </div>
</div>
`,
};
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index b8ba77f4513..9dc9f9a9068 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -49,3 +49,4 @@
@import "framework/icons.scss";
@import "framework/snippets.scss";
@import "framework/memory_graph.scss";
+@import "framework/responsive-tables.scss";
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 75907c35b7e..19166757e64 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -1,4 +1,7 @@
.awards {
+ display: flex;
+ flex-wrap: wrap;
+
.emoji-icon {
width: 20px;
height: 20px;
@@ -100,7 +103,6 @@
.award-menu-holder {
display: inline-block;
- position: absolute;
.tooltip {
white-space: nowrap;
@@ -108,9 +110,11 @@
}
.award-control {
- margin: 0 5px 6px 0;
+ margin: 4px 8px 4px 0;
outline: 0;
position: relative;
+ display: block;
+ float: left;
&.disabled {
cursor: default;
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 57387b913dc..00c981f64c5 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -445,3 +445,9 @@ table {
word-wrap: break-word;
}
}
+
+.disabled-content {
+ pointer-events: none;
+ opacity: .5;
+}
+
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 5ab48b6c874..17f1dc2f479 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -201,6 +201,11 @@
width: 100%;
}
+ &.dropdown-open-left {
+ right: 0;
+ left: auto;
+ }
+
&.is-loading {
.dropdown-content {
display: none;
@@ -261,7 +266,14 @@
text-transform: capitalize;
}
- .separator + .dropdown-header {
+ .dropdown-bold-header {
+ font-weight: 600;
+ line-height: 22px;
+ padding: 0 16px;
+ }
+
+ .separator + .dropdown-header,
+ .separator + .dropdown-bold-header {
padding-top: 2px;
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 585f4871f5f..cfbaaaa04c7 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -22,12 +22,6 @@
}
@media (min-width: $screen-sm-min) {
- .issues_bulk_update {
- .dropdown-menu-toggle {
- width: 132px;
- }
- }
-
.filter-item:not(:last-child) {
margin-right: 6px;
}
@@ -148,15 +142,17 @@
}
}
}
+}
- .selected {
- .name {
- background-color: $filter-name-selected-color;
- }
+.filtered-search-token:hover,
+.filtered-search-token .selected,
+.filtered-search-term .selected {
+ .name {
+ background-color: $filter-name-selected-color;
+ }
- .value-container {
- background-color: $filter-value-selected-color;
- }
+ .value-container {
+ background-color: $filter-value-selected-color;
}
}
@@ -376,12 +372,6 @@
padding: 0;
}
-@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- .issue-bulk-update-dropdown-toggle {
- width: 100px;
- }
-}
-
@media (max-width: $screen-xs-max) {
.issues-details-filters {
padding: 0 0 10px;
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 49163653548..38727e15c6f 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -264,3 +264,103 @@ ul.controls {
ul.indent-list {
padding: 10px 0 0 30px;
}
+
+
+// Specific styles for tree list
+.group-list-tree {
+ .folder-toggle-wrap {
+ float: left;
+ line-height: $list-text-height;
+ font-size: 0;
+
+ span {
+ font-size: $gl-font-size;
+ }
+ }
+
+ .folder-caret,
+ .folder-icon {
+ display: inline-block;
+ }
+
+ .folder-caret {
+ width: 15px;
+ }
+
+ .folder-icon {
+ width: 20px;
+ }
+
+ > .group-row:not(.has-subgroups) {
+ .folder-caret .fa {
+ opacity: 0;
+ }
+ }
+
+ .content-list li:last-child {
+ padding-bottom: 0;
+ }
+
+ .group-list-tree {
+ margin-bottom: 0;
+ margin-left: 30px;
+ position: relative;
+
+ &::before {
+ content: '';
+ display: block;
+ width: 0;
+ position: absolute;
+ top: 5px;
+ bottom: 0;
+ left: -16px;
+ border-left: 2px solid $border-white-normal;
+ }
+
+ .group-row {
+ position: relative;
+
+ &::before {
+ content: "";
+ display: block;
+ width: 10px;
+ height: 0;
+ border-top: 2px solid $border-white-normal;
+ position: absolute;
+ top: 30px;
+ left: -16px;
+ }
+
+ &:last-child::before {
+ background: $white-light;
+ height: auto;
+ top: 30px;
+ bottom: 0;
+ }
+ }
+ }
+
+ .group-row {
+ padding: 0;
+ border: none;
+ }
+
+ .group-row-contents {
+ padding: 10px 10px 8px;
+ border-top: solid 1px transparent;
+ border-bottom: solid 1px $white-normal;
+
+ &:hover {
+ border-color: $row-hover-border;
+ background-color: $row-hover;
+ cursor: pointer;
+ }
+ }
+}
+
+.js-groups-list-holder {
+ .groups-list-loading {
+ font-size: 34px;
+ text-align: center;
+ }
+}
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 678af978edd..600a1f53b58 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -29,10 +29,6 @@
display: none;
}
- .issues-holder .issue-check {
- display: none;
- }
-
.rss-btn {
display: none;
}
@@ -112,11 +108,6 @@
}
}
- .issue-edited-ago,
- .note_edited_ago {
- display: none;
- }
-
aside:not(.right-sidebar) {
display: none;
}
diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss
index 28b2a7cfacd..3787ef370b2 100644
--- a/app/assets/stylesheets/framework/nav.scss
+++ b/app/assets/stylesheets/framework/nav.scss
@@ -45,7 +45,8 @@
li {
display: flex;
- a {
+ a,
+ .btn-link {
padding: $gl-btn-padding;
padding-bottom: 11px;
font-size: 14px;
@@ -67,7 +68,29 @@
}
}
- &.active a {
+ .btn-link {
+ padding-top: 16px;
+ padding-left: 15px;
+ padding-right: 15px;
+ border-left: none;
+ border-right: none;
+ border-top: none;
+ border-radius: 0;
+
+ &:hover,
+ &:active,
+ &:focus {
+ background-color: transparent;
+ }
+
+ &:active {
+ outline: 0;
+ box-shadow: none;
+ }
+ }
+
+ &.active a,
+ &.active .btn-link {
border-bottom: 2px solid $link-underline-blue;
color: $black;
font-weight: 600;
diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss
new file mode 100644
index 00000000000..f0a4c66aa1a
--- /dev/null
+++ b/app/assets/stylesheets/framework/responsive-tables.scss
@@ -0,0 +1,91 @@
+@mixin flex-max-width($max) {
+ flex: 0 0 #{$max + '%'};
+ max-width: #{$max + '%'};
+}
+
+.gl-responsive-table-row {
+ margin-top: 10px;
+ border: 1px solid $border-color;
+
+ @media (min-width: $screen-md-min) {
+ padding: 15px 0;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ border: none;
+ border-bottom: 1px solid $white-normal;
+ }
+
+ .table-section {
+ white-space: nowrap;
+
+ $section-widths: 10 15 20 25 30 40;
+ @each $width in $section-widths {
+ &.section-#{$width} {
+ flex: 0 0 #{$width + '%'};
+
+ @media (min-width: $screen-md-min) {
+ max-width: #{$width + '%'};
+ }
+ }
+ }
+
+ &:not(.table-button-footer) {
+ @media (max-width: $screen-sm-max) {
+ display: flex;
+ align-self: stretch;
+ padding: 10px;
+ align-items: center;
+ height: 62px;
+
+ &:not(:first-of-type) {
+ border-top: 1px solid $white-normal;
+ }
+ }
+ }
+ }
+}
+
+.table-row-header {
+ font-size: 13px;
+
+ @media (max-width: $screen-sm-max) {
+ display: none;
+ }
+}
+
+.table-mobile-header {
+ color: $gl-text-color-secondary;
+ @include flex-max-width(40);
+
+ @media (min-width: $screen-md-min) {
+ display: none;
+ }
+}
+
+.table-mobile-content {
+ @media (max-width: $screen-sm-max) {
+ @include flex-max-width(60);
+ text-align: right;
+ }
+}
+
+.flex-truncate-parent {
+ display: flex;
+}
+
+.flex-truncate-child {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (min-width: $screen-md-min) {
+ flex: 0 0 90%;
+ }
+
+ .avatar {
+ float: none;
+ margin-right: 4px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 5b62d7fa3a7..d4421e3af74 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -33,7 +33,7 @@
padding-right: 0;
@media (min-width: $screen-sm-min) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
@@ -56,7 +56,7 @@
z-index: 300;
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
}
@@ -88,3 +88,35 @@
min-height: 100%;
}
}
+
+@mixin maintain-sidebar-dimensions {
+ display: block;
+ width: $gutter-width;
+ padding: 10px 20px;
+}
+
+.issues-bulk-update.right-sidebar {
+ @include maintain-sidebar-dimensions;
+ transition: right $sidebar-transition-duration;
+ right: -$gutter-width;
+
+ &.right-sidebar-expanded {
+ @include maintain-sidebar-dimensions;
+ right: 0;
+ }
+
+ &.right-sidebar-collapsed {
+ @include maintain-sidebar-dimensions;
+ right: -$gutter-width;
+
+ .block {
+ padding: 16px 0;
+ width: 250px;
+ border-bottom: 1px solid $border-color;
+ }
+ }
+
+ .issuable-sidebar {
+ padding: 0 3px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 975a4b40383..4114a050d9a 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -187,6 +187,7 @@ $divergence-graph-bar-bg: #ccc;
$divergence-graph-separator-bg: #ccc;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
+$highlight-changes-color: rgb(235, 255, 232);
/*
@@ -569,3 +570,10 @@ $filter-value-selected-color: #d7d7d7;
Animation Functions
*/
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
+
+/*
+Convdev Index
+*/
+$color-high-score: $green-400;
+$color-average-score: $orange-400;
+$color-low-score: $red-400;
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index ebe662136d5..740e383dbb5 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -96,9 +96,51 @@
@media (min-width: $screen-sm-min) {
width: 400px;
}
+
+ &.is-expandable {
+ .board-header {
+ cursor: pointer;
+ }
+ }
+
+ &.is-collapsed {
+ width: 50px;
+
+ .board-header {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ }
+
+ .board-title {
+ position: initial;
+ padding: 0;
+ border-bottom: 0;
+
+ > span {
+ display: block;
+ transform: rotate(90deg) translate(25px, 0);
+ }
+ }
+
+ .board-title-expandable-toggle {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-left: -10px;
+ }
+
+ .board-list-component,
+ .board-issue-count-holder {
+ display: none;
+ }
+ }
}
.board-inner {
+ position: relative;
height: 100%;
font-size: $issue-boards-font-size;
background: $gray-light;
@@ -175,21 +217,53 @@
}
}
+.slide-down-enter {
+ transform: translateY(-100%);
+}
+
+.slide-down-enter-active {
+ transition: transform $fade-in-duration;
+
+ + .board-list {
+ transform: translateY(-136px);
+ transition: none;
+ }
+}
+
+.slide-down-enter-to {
+ + .board-list {
+ transform: translateY(0);
+ transition: transform $fade-in-duration ease;
+ }
+}
+
+.slide-down-leave {
+ transform: translateY(0);
+}
+
+.slide-down-leave-active {
+ transition: all $fade-in-duration;
+ transform: translateY(-136px);
+
+ + .board-list {
+ transition: transform $fade-in-duration ease;
+ transform: translateY(-136px);
+ }
+}
+
.board-list-component {
height: calc(100% - 49px);
+ overflow: hidden;
}
.board-list {
height: 100%;
+ width: 100%;
margin-bottom: 0;
padding: 5px;
list-style: none;
overflow-y: scroll;
overflow-x: hidden;
-
- &.is-smaller {
- height: calc(100% - 136px);
- }
}
.board-list-loading {
@@ -351,6 +425,7 @@
}
.board-new-issue-form {
+ z-index: 1;
margin: 5px;
}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index e35558ad8e8..d931a78e112 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -71,7 +71,9 @@
height: 35px;
display: flex;
justify-content: flex-end;
- border-bottom: 1px outset $white-light;
+ background: $gray-light;
+ border: 1px solid $gray-normal;
+ color: $gl-text-color;
.truncated-info {
margin: 0 auto;
@@ -82,7 +84,7 @@
}
.raw-link {
- color: inherit;
+ color: $gl-text-color;
margin-left: 5px;
text-decoration: underline;
}
@@ -93,17 +95,25 @@
display: flex;
align-self: center;
font-size: 15px;
+ margin-bottom: 4px;
svg {
height: 15px;
display: block;
- fill: $white-light;
+ fill: $gl-text-color;
}
- a,
+ .controllers-buttons,
.btn-scroll {
- margin: 0 8px;
- color: $white-light;
+ color: $gl-text-color;
+ height: 15px;
+ vertical-align: middle;
+ padding: 0;
+ width: 12px;
+ }
+
+ .controllers-buttons {
+ margin: 1px 10px;
}
.btn-scroll.animate {
@@ -137,9 +147,9 @@
top: 35px;
left: 10px;
bottom: 0;
- overflow-y: hidden;
- padding-bottom: 20px;
- padding-right: 20px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ padding: 10px 20px 20px 5px;
}
.environment-information {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index bb72f453d1b..9db0f2075cb 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -228,7 +228,7 @@
margin: 10px 0;
background: $gray-light;
display: none;
- white-space: pre-line;
+ white-space: pre-wrap;
word-break: normal;
pre {
diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss
new file mode 100644
index 00000000000..0413114c279
--- /dev/null
+++ b/app/assets/stylesheets/pages/convdev_index.scss
@@ -0,0 +1,255 @@
+$space-between-cards: 8px;
+
+.convdev-empty svg {
+ margin: 64px auto 32px;
+ max-width: 420px;
+}
+
+.convdev-header {
+ margin-top: $gl-padding;
+ margin-bottom: $gl-padding;
+ padding: 0 4px;
+ display: flex;
+ align-items: center;
+
+ .convdev-header-title {
+ font-size: 48px;
+ line-height: 1;
+ margin: 0;
+ }
+
+ .convdev-header-subtitle {
+ font-size: 22px;
+ line-height: 1;
+ color: $gl-text-color-secondary;
+ margin-left: 8px;
+ font-weight: 500;
+
+ a {
+ font-size: 18px;
+ color: $gl-text-color-secondary;
+
+ &:hover {
+ color: $blue-500;
+ }
+ }
+ }
+}
+
+.convdev-cards {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.convdev-card-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ width: 50%;
+ border-color: $border-color;
+ margin: 0 0 32px;
+ padding: $space-between-cards / 2;
+ position: relative;
+
+ @media (min-width: $screen-xs-min) {
+ width: percentage(1 / 4);
+ }
+
+ @media (min-width: $screen-sm-min) {
+ width: percentage(1 / 5);
+ }
+
+ @media (min-width: $screen-md-min) {
+ width: percentage(1 / 6);
+ }
+
+ @media (min-width: $screen-lg-min) {
+ width: percentage(1 / 10);
+ }
+}
+
+.convdev-card {
+ border: solid 1px $border-color;
+ border-radius: 3px;
+ border-top-width: 3px;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.convdev-card-low {
+ border-top-color: $color-low-score;
+
+ .card-score-big {
+ background-color: $red-25;
+ }
+}
+
+.convdev-card-average {
+ border-top-color: $color-average-score;
+
+ .card-score-big {
+ background-color: $orange-25;
+ }
+}
+
+.convdev-card-high {
+ border-top-color: $color-high-score;
+
+ .card-score-big {
+ background-color: $green-25;
+ }
+}
+
+.convdev-card-title {
+ margin: $gl-padding auto auto;
+ max-width: 100px;
+
+ h3 {
+ font-size: 14px;
+ margin: 0 0 2px;
+ }
+
+ .text-light {
+ font-size: 13px;
+ line-height: 1.25;
+ color: $gl-text-color-secondary;
+ }
+}
+
+.card-scores {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ margin: $gl-padding $gl-btn-padding;
+ line-height: 1;
+}
+
+.card-score {
+ color: $gl-text-color-secondary;
+
+ .card-score-name {
+ font-size: 13px;
+ margin-top: 4px;
+ }
+}
+
+.card-score-value {
+ font-size: 16px;
+ color: $gl-text-color;
+ font-weight: 500;
+}
+
+.card-score-big {
+ border-top: 2px solid $border-color;
+ border-bottom: 1px solid $border-color;
+ font-size: 22px;
+ padding: 10px 0;
+ font-weight: 500;
+}
+
+.card-buttons {
+ display: flex;
+
+ > * {
+ font-size: 16px;
+ color: $gl-text-color-secondary;
+ padding: 10px;
+ flex-grow: 1;
+
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+
+ + * {
+ border-left: solid 1px $border-color;
+ }
+ }
+}
+
+.convdev-steps {
+ margin-top: $gl-padding;
+ height: 1px;
+ min-width: 100%;
+ justify-content: space-around;
+ position: relative;
+ background: $border-color;
+}
+
+.convdev-step {
+ $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
+ @each $pos in $step-positions {
+ $i: index($step-positions, $pos);
+
+ &:nth-child(#{$i}) {
+ left: $pos;
+ }
+ }
+
+ position: absolute;
+ transform-origin: 75% 50%;
+ padding: 8px;
+ height: 50px;
+ width: 50px;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border: solid 1px $border-color;
+ background: $white-light;
+ transform: translate(-50%, -50%);
+ color: $gl-text-color-secondary;
+ fill: $gl-text-color-secondary;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ &:hover {
+ padding: 8px 10px;
+ fill: currentColor;
+ z-index: 100;
+ height: auto;
+ width: auto;
+
+ .convdev-step-title {
+ max-height: 2em;
+ opacity: 1;
+ transition: opacity 0.2s;
+ }
+
+ svg {
+ transform: scale(1.5);
+ margin: $gl-btn-padding;
+ }
+ }
+
+ svg {
+ transition: transform 0.1s;
+ width: 30px;
+ height: 30px;
+ min-height: 30px;
+ min-width: 30px;
+ }
+}
+
+.convdev-step-title {
+ max-height: 0;
+ opacity: 0;
+ text-transform: uppercase;
+ margin: $gl-vert-padding 0 0;
+ text-align: center;
+ font-size: 12px;
+}
+
+.convdev-high-score {
+ color: $color-high-score;
+}
+
+.convdev-average-score {
+ color: $color-average-score;
+}
+
+.convdev-low-score {
+ color: $color-low-score;
+}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index f269d53093d..b24803678ea 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -11,34 +11,7 @@
}
.environments-container {
- .table-holder {
- width: 100%;
-
- @media (max-width: $screen-sm-max) {
- overflow: auto;
- }
- }
-
- .table.ci-table {
- .environments-actions {
- min-width: 300px;
- }
-
- .environments-commit,
- .environments-actions {
- width: 20%;
- }
-
- .environments-date {
- width: 10%;
- }
-
- .environments-name,
- .environments-deploy,
- .environments-build {
- width: 15%;
- }
-
+ .ci-table {
.deployment-column {
> span {
word-break: break-all;
@@ -150,6 +123,59 @@
}
}
+.gl-responsive-table-row {
+ .environments-actions {
+ @media (min-width: $screen-md-min) {
+ text-align: right;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ background-color: $gray-normal;
+ align-self: stretch;
+ border-top: 1px solid $border-color;
+
+ .environment-action-buttons {
+ padding: 10px 5px;
+ display: flex;
+
+ .btn {
+ border-radius: 3px;
+ }
+
+ > .btn-group,
+ > .external-url,
+ > .btn {
+ flex: 1;
+ flex-basis: 28px;
+ margin: 0 5px;
+ }
+
+ .dropdown-new {
+ width: 100%;
+ }
+
+ .dropdown-menu {
+ min-width: initial;
+ }
+ }
+ }
+ }
+
+ .branch-commit {
+ max-width: 100%;
+ }
+}
+
+.folder-row {
+ padding: 15px 0;
+ border-bottom: 1px solid $white-normal;
+
+ @media (max-width: $screen-sm-max) {
+ border-top: 1px solid $white-normal;
+ margin-top: 10px;
+ }
+}
+
.prometheus-graph {
text {
fill: $gl-text-color;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index c2346f2f1c3..b3f310ff67d 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -58,7 +58,7 @@
}
.emoji-block {
- padding: 10px 0 4px;
+ padding: 10px 0;
}
}
@@ -729,3 +729,33 @@
}
}
}
+
+.confidential-issue-warning {
+ background-color: $gl-gray;
+ border-radius: 3px;
+ padding: $gl-btn-padding $gl-padding;
+ margin-top: $gl-padding-top;
+ font-size: 14px;
+ color: $white-light;
+
+ .fa {
+ margin-right: 8px;
+ }
+
+ a {
+ color: $white-light;
+ text-decoration: underline;
+ }
+
+ &.affix {
+ position: static;
+ width: initial;
+
+ @media (min-width: $screen-sm-min) {
+ position: sticky;
+ position: -webkit-sticky;
+ top: 60px;
+ z-index: 200;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 702e7662527..f923a1104a9 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -249,14 +249,19 @@ ul.related-merge-requests > li {
}
@media (min-width: $screen-sm-min) {
- .new-branch-col {
- padding-top: 0;
- text-align: right;
- }
+ .emoji-block .row {
+ display: flex;
- .create-mr-dropdown-wrap {
- .btn-group:not(.hide) {
- display: inline-block;
+ .new-branch-col {
+ padding-top: 0;
+ text-align: right;
+ align-self: center;
+ }
+
+ .create-mr-dropdown-wrap {
+ .btn-group:not(.hide) {
+ display: inline-block;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 971d54e7472..4be0e133b69 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -3,6 +3,41 @@
border-bottom: 1px solid $border-color;
}
+.project-member-tabs {
+ background: $gray-light;
+ border: 1px solid $border-color;
+
+ li {
+ width: 50%;
+
+ &.active {
+ background: $white-light;
+ }
+
+ &:first-child {
+ border-right: 1px solid $border-color;
+ }
+
+ a {
+ width: 100%;
+ text-align: center;
+ }
+ }
+}
+
+.users-project-form {
+ .btn-create {
+ margin-right: 10px;
+ }
+}
+
+.project-member-tab-content {
+ padding: $gl-padding;
+ border: 1px solid $border-color;
+ border-top: 0;
+ margin-bottom: $gl-padding;
+}
+
.member {
.list-item-name {
@media (min-width: $screen-sm-min) {
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 875e47cdff3..aa307414737 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -103,26 +103,6 @@
}
}
-.confidential-issue-warning {
- background-color: $gray-normal;
- border-radius: 3px;
- padding: 3px 12px;
- margin: auto;
- margin-top: 0;
- text-align: center;
- font-size: 12px;
-
- @media (max-width: $screen-sm-max) {
- // On smaller devices the warning becomes the fourth item in the list,
- // rather than centering, and grows to span the full width of the
- // comment area.
- order: 4;
- -webkit-order: 4;
- margin: 6px auto;
- width: 100%;
- }
-}
-
.discussion-form {
padding: $gl-padding-top $gl-padding $gl-padding;
background-color: $white-light;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index f956e3757bf..e622e5c3f4b 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -38,9 +38,12 @@ ul.notes {
}
.discussion {
- overflow: hidden;
display: block;
position: relative;
+
+ .diff-content {
+ overflow: visible;
+ }
}
> li {
@@ -443,6 +446,53 @@ ul.notes {
.note-action-button {
margin-left: 8px;
}
+
+ .more-actions-toggle {
+ margin-left: 2px;
+ }
+}
+
+.more-actions {
+ display: inline;
+
+ .tooltip {
+ white-space: nowrap;
+ }
+}
+
+.more-actions-toggle {
+ padding: 0;
+ outline: none;
+
+ &:hover .icon,
+ &:focus .icon {
+ color: $blue-600;
+ }
+
+ .icon {
+ padding: 0 6px;
+ }
+}
+
+.more-actions-dropdown {
+ width: 180px;
+ min-width: 180px;
+ margin-top: $gl-btn-padding;
+
+ li > a,
+ li > .btn {
+ color: $gl-text-color;
+ padding: $gl-btn-padding;
+ width: 100%;
+ text-align: left;
+
+ &:hover,
+ &:focus {
+ color: $gl-text-color;
+ background-color: $blue-25;
+ border-radius: $border-radius-default;
+ }
+ }
}
.discussion-actions {
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index fe084eb9397..c207159f606 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -287,6 +287,7 @@ table.u2f-registrations {
.user-callout {
margin: 0 auto;
+ max-width: $screen-lg-min;
.bordered-box {
border: 1px solid $blue-300;
@@ -295,14 +296,15 @@ table.u2f-registrations {
position: relative;
display: flex;
justify-content: center;
+ align-items: center;
}
.landing {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
+ padding: 32px;
.close {
position: absolute;
+ top: 20px;
right: 20px;
opacity: 1;
@@ -330,11 +332,20 @@ table.u2f-registrations {
height: 110px;
vertical-align: top;
}
+
+ &.convdev {
+ margin: 0 0 0 30px;
+
+ svg {
+ height: 127px;
+ }
+ }
}
.user-callout-copy {
display: inline-block;
vertical-align: top;
+ max-width: 570px;
}
}
@@ -348,12 +359,20 @@ table.u2f-registrations {
.landing {
.svg-container,
.user-callout-copy {
- margin: 0;
+ margin: 0 auto;
display: block;
svg {
height: 75px;
}
+
+ &.convdev {
+ margin: $gl-padding auto 0;
+
+ svg {
+ height: 120px;
+ }
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 24ab2bedea2..a2f781a6a6e 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -29,6 +29,20 @@
& > .form-group {
padding-left: 0;
}
+
+ select option[disabled] {
+ display: none;
+ }
+ }
+
+ select {
+ background: transparent;
+ transition: background 2s ease-out;
+
+ &.highlight-changes {
+ background: $highlight-changes-color;
+ transition: none;
+ }
}
.help-block {
@@ -675,14 +689,16 @@ pre.light-well {
}
}
-.new_protected_branch {
+.new_protected_branch,
+.new-protected-tag {
label {
margin-top: 6px;
font-weight: normal;
}
}
-.create-new-protected-branch-button {
+.create-new-protected-branch-button,
+.create-new-protected-tag-button {
@include dropdown-link;
width: 100%;
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 3889deee21a..33b3c083fd2 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -1,3 +1,90 @@
+@keyframes expandMaxHeight {
+ 0% {
+ max-height: 0;
+ }
+
+ 99% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: none;
+ }
+}
+
+@keyframes collapseMaxHeight {
+ 0% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: 0;
+ }
+}
+
+.settings {
+ overflow: hidden;
+ border-bottom: 1px solid $gray-darker;
+
+ &:first-of-type {
+ margin-top: 10px;
+ }
+}
+
+.settings-header {
+ position: relative;
+ padding: 20px 110px 10px 0;
+
+ h4 {
+ margin-top: 0;
+ }
+
+ button {
+ position: absolute;
+ top: 20px;
+ right: 6px;
+ min-width: 80px;
+ }
+}
+
+.settings-content {
+ max-height: 1px;
+ overflow-y: scroll;
+ margin-right: -20px;
+ padding-right: 130px;
+ animation: collapseMaxHeight 300ms ease-out;
+
+ &.expanded {
+ max-height: none;
+ overflow-y: visible;
+ animation: expandMaxHeight 300ms ease-in;
+ }
+
+ &.no-animate {
+ animation: none;
+ }
+
+ @media(max-width: $screen-sm-max) {
+ padding-right: 20px;
+ }
+
+ &::before {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-bottom: 4px;
+ }
+
+ &::after {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-top: 20px;
+ }
+}
+
.settings-list-icon {
color: $gl-text-color-secondary;
font-size: $settings-icon-size;
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 152d7baad49..75fb19e815f 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -149,6 +149,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:version_check_enabled,
:terminal_max_session_time,
:polling_interval_multiplier,
+ :prometheus_metrics_enabled,
:usage_ping_enabled,
disabled_oauth_sign_in_sources: [],
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 9c9f420c1e0..434ff6b2a62 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -39,7 +39,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
def destroy
@application.destroy
- redirect_to admin_applications_url, notice: 'Application was successfully destroyed.'
+ redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.'
end
private
diff --git a/app/controllers/admin/conversational_development_index_controller.rb b/app/controllers/admin/conversational_development_index_controller.rb
new file mode 100644
index 00000000000..921169d3e2b
--- /dev/null
+++ b/app/controllers/admin/conversational_development_index_controller.rb
@@ -0,0 +1,5 @@
+class Admin::ConversationalDevelopmentIndexController < Admin::ApplicationController
+ def show
+ @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present
+ end
+end
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index 4f6a7e9e2cb..e5cba774dcb 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -1,6 +1,6 @@
class Admin::DeployKeysController < Admin::ApplicationController
before_action :deploy_keys, only: [:index]
- before_action :deploy_key, only: [:destroy]
+ before_action :deploy_key, only: [:destroy, :edit, :update]
def index
end
@@ -10,12 +10,24 @@ class Admin::DeployKeysController < Admin::ApplicationController
end
def create
- @deploy_key = deploy_keys.new(deploy_key_params.merge(user: current_user))
+ @deploy_key = deploy_keys.new(create_params.merge(user: current_user))
if @deploy_key.save
redirect_to admin_deploy_keys_path
else
- render "new"
+ render 'new'
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if deploy_key.update_attributes(update_params)
+ flash[:notice] = 'Deploy key was successfully updated.'
+ redirect_to admin_deploy_keys_path
+ else
+ render 'edit'
end
end
@@ -23,7 +35,7 @@ class Admin::DeployKeysController < Admin::ApplicationController
deploy_key.destroy
respond_to do |format|
- format.html { redirect_to admin_deploy_keys_path }
+ format.html { redirect_to admin_deploy_keys_path, status: 302 }
format.json { head :ok }
end
end
@@ -38,7 +50,11 @@ class Admin::DeployKeysController < Admin::ApplicationController
@deploy_keys ||= DeployKey.are_public
end
- def deploy_key_params
+ def create_params
params.require(:deploy_key).permit(:key, :title, :can_push)
end
+
+ def update_params
+ params.require(:deploy_key).permit(:title, :can_push)
+ end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 5885b3543bb..2ce26de1768 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -43,19 +43,22 @@ class Admin::GroupsController < Admin::ApplicationController
end
def members_update
- status = Members::CreateService.new(@group, current_user, params).execute
+ member_params = params.permit(:user_ids, :access_level, :expires_at)
+ result = Members::CreateService.new(@group, current_user, member_params.merge(limit: -1)).execute
- if status
+ if result[:status] == :success
redirect_to [:admin, @group], notice: 'Users were successfully added.'
else
- redirect_to [:admin, @group], alert: 'No users specified.'
+ redirect_to [:admin, @group], alert: result[:message]
end
end
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
- redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion."
+ redirect_to admin_groups_path,
+ status: 302,
+ alert: "Group '#{@group.name}' was scheduled for deletion."
end
private
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index b9251e140f8..054c3500b35 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -34,7 +34,7 @@ class Admin::HooksController < Admin::ApplicationController
def destroy
hook.destroy
- redirect_to admin_hooks_path
+ redirect_to admin_hooks_path, status: 302
end
def test
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index 79a53556f0a..43b4e3a2cc3 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -36,9 +36,9 @@ class Admin::IdentitiesController < Admin::ApplicationController
def destroy
if @identity.destroy
RepairLdapBlockedUserService.new(@user).execute
- redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully removed.'
+ redirect_to admin_user_identities_path(@user), status: 302, notice: 'User identity was successfully removed.'
else
- redirect_to admin_user_identities_path(@user), alert: 'Failed to remove user identity.'
+ redirect_to admin_user_identities_path(@user), status: 302, alert: 'Failed to remove user identity.'
end
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 8e7adc06584..39dbf85f6c0 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -11,7 +11,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController
session[:impersonator_id] = nil
- redirect_to admin_user_path(original_user)
+ redirect_to admin_user_path(original_user), status: 302
end
private
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 054bb52b696..0b76193a90e 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -15,9 +15,9 @@ class Admin::KeysController < Admin::ApplicationController
respond_to do |format|
if key.destroy
- format.html { redirect_to [:admin, user], notice: 'User key was successfully removed.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, notice: 'User key was successfully removed.' }
else
- format.html { redirect_to [:admin, user], alert: 'Failed to remove user key.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, alert: 'Failed to remove user key.' }
end
end
end
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index 4531657268c..cbc7a14ae83 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -41,7 +41,7 @@ class Admin::LabelsController < Admin::ApplicationController
respond_to do |format|
format.html do
- redirect_to(admin_labels_path, notice: 'Label was removed')
+ redirect_to admin_labels_path, status: 302, notice: 'Label was removed'
end
format.js
end
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 70ac6a75434..7ed2de71028 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -18,7 +18,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
runner = rp.runner
rp.destroy
- redirect_to admin_runner_path(runner)
+ redirect_to admin_runner_path(runner), status: 302
end
private
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 348641e5ecb..719893c0bc8 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -27,7 +27,7 @@ class Admin::RunnersController < Admin::ApplicationController
def destroy
@runner.destroy
- redirect_to admin_runners_path
+ redirect_to admin_runners_path, status: 302
end
def resume
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 1d66955bb71..d52d67a67a5 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -8,7 +8,9 @@ class Admin::SpamLogsController < Admin::ApplicationController
if params[:remove_user]
spam_log.remove_user(deleted_by: current_user)
- redirect_to admin_spam_logs_path, notice: "User #{spam_log.user.username} was successfully removed."
+ redirect_to admin_spam_logs_path,
+ status: 302,
+ notice: "User #{spam_log.user.username} was successfully removed."
else
spam_log.destroy
head :ok
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 563bcc65bd6..b09eef17c23 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -138,10 +138,10 @@ class Admin::UsersController < Admin::ApplicationController
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, user.id)
+ user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete))
respond_to do |format|
- format.html { redirect_to admin_users_path, notice: "The user is being deleted." }
+ format.html { redirect_to admin_users_path, status: 302, notice: "The user is being deleted." }
format.json { head :ok }
end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 907717dcb96..fe331a883c1 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -21,7 +21,7 @@ class AutocompleteController < ApplicationController
@users = [current_user, *@users].uniq
end
- if params[:author_id].present?
+ if params[:author_id].present? && current_user
author = User.find_by_id(params[:author_id])
@users = [author, *@users].uniq if author
end
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index ae91e02488a..2b6afaa6233 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -106,4 +106,8 @@ module LfsRequest
def objects
@objects ||= (params[:objects] || []).to_a
end
+
+ def has_authentication_ability?(capability)
+ (authentication_abilities || []).include?(capability)
+ end
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index b1bacc8ffe5..8d07780f6c2 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -2,14 +2,15 @@ module MembershipActions
extend ActiveSupport::Concern
def create
- status = Members::CreateService.new(membershipable, current_user, params).execute
+ create_params = params.permit(:user_ids, :access_level, :expires_at)
+ result = Members::CreateService.new(membershipable, current_user, create_params).execute
redirect_url = members_page_url
- if status
+ if result[:status] == :success
redirect_to redirect_url, notice: 'Users were successfully added.'
else
- redirect_to redirect_url, alert: 'No users specified.'
+ redirect_to redirect_url, alert: result[:message]
end
end
@@ -51,9 +52,14 @@ module MembershipActions
"You left the \"#{membershipable.human_name}\" #{source_type}."
end
- redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize]
+ respond_to do |format|
+ format.html do
+ redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize]
+ redirect_to redirect_path, notice: notice
+ end
- redirect_to redirect_path, notice: notice
+ format.json { render json: { notice: notice } }
+ end
end
protected
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index 3e2a0fe4f8b..b2536a1c949 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -46,8 +46,10 @@ module MilestoneActions
def milestone_redirect_path
if @project
namespace_project_milestone_path(@project.namespace, @project, @milestone)
- else
+ elsif @group
group_milestone_path(@group, @milestone.safe_title, title: @milestone.title)
+ else
+ dashboard_milestone_path(@milestone.safe_title, title: @milestone.title)
end
end
end
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index d03265e9f20..742157d113d 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,16 +1,30 @@
class Dashboard::GroupsController < Dashboard::ApplicationController
def index
- @group_members = current_user.group_members.includes(source: :route).joins(:group)
- @group_members = @group_members.merge(Group.search(params[:filter_groups])) if params[:filter_groups].present?
- @group_members = @group_members.merge(Group.sort(@sort = params[:sort]))
- @group_members = @group_members.page(params[:page])
+ @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
+
+ @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
+ @groups = @groups.includes(:route)
+ @groups = @groups.sort(@sort = params[:sort])
+ @groups = @groups.page(params[:page])
respond_to do |format|
format.html
format.json do
- render json: {
- html: view_to_html_string("dashboard/groups/_groups", locals: { group_members: @group_members })
- }
+ render json: GroupSerializer
+ .new(current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@groups)
end
end
end
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index df528d10f6e..751dbbd8e96 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -1,6 +1,8 @@
class Dashboard::MilestonesController < Dashboard::ApplicationController
+ include MilestoneActions
+
before_action :projects
- before_action :milestone, only: [:show]
+ before_action :milestone, only: [:show, :merge_requests, :participants, :labels]
def index
respond_to do |format|
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 4d7d45787fc..623392c1240 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -15,7 +15,11 @@ class Dashboard::TodosController < Dashboard::ApplicationController
TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
+ format.html do
+ redirect_to dashboard_todos_path,
+ status: 302,
+ notice: 'Todo was successfully marked as done.'
+ end
format.js { head :ok }
format.json { render json: todos_counts }
end
@@ -25,7 +29,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, notice: 'All todos were marked as done.' }
+ format.html { redirect_to dashboard_todos_path, status: 302, notice: 'All todos were marked as done.' }
format.js { head :ok }
format.json { render json: todos_counts.merge(updated_ids: updated_ids) }
end
diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb
index ad2c20b42db..735915abdaa 100644
--- a/app/controllers/groups/avatars_controller.rb
+++ b/app/controllers/groups/avatars_controller.rb
@@ -5,6 +5,6 @@ class Groups::AvatarsController < Groups::ApplicationController
@group.remove_avatar!
@group.save
- redirect_to edit_group_path(@group)
+ redirect_to edit_group_path(@group), status: 302
end
end
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index 3fa0516fb0c..dda59262483 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -54,7 +54,7 @@ class Groups::LabelsController < Groups::ApplicationController
respond_to do |format|
format.html do
- redirect_to group_labels_path(@group), notice: 'Label was removed'
+ redirect_to group_labels_path(@group), status: 302, notice: 'Label was removed'
end
format.js
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 18a2d69db29..c08943d993a 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -101,7 +101,7 @@ class GroupsController < Groups::ApplicationController
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
- redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion."
+ redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion."
end
protected
@@ -173,7 +173,7 @@ class GroupsController < Groups::ApplicationController
def build_canonical_path(group)
return group_path(group) if action_name == 'show' # root group path
-
+
params[:id] = group.to_param
url_for(params)
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index 125746d0426..abc832e6ddc 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -20,25 +20,8 @@ class HealthController < ActionController::Base
render_check_results(results)
end
- def metrics
- results = CHECKS.flat_map(&:metrics)
-
- response = results.map(&method(:metric_to_prom_line)).join("\n")
-
- render text: response, content_type: 'text/plain; version=0.0.4'
- end
-
private
- def metric_to_prom_line(metric)
- labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || ''
- if labels.empty?
- "#{metric.name} #{metric.value}"
- else
- "#{metric.name}{#{labels}} #{metric.value}"
- end
- end
-
def render_check_results(results)
flattened = results.flat_map do |name, result|
if result.is_a?(Gitlab::HealthChecks::Result)
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 1c01be06451..11db164b3fa 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -25,8 +25,10 @@ class JwtController < ApplicationController
authenticate_with_http_basic do |login, password|
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
- render_unauthorized unless @authentication_result.success? &&
- (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User))
+ if @authentication_result.failed? ||
+ (@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User))
+ render_unauthorized
+ end
end
rescue Gitlab::Auth::MissingPersonalTokenError
render_missing_personal_token
@@ -37,7 +39,7 @@ class JwtController < ApplicationController
errors: [
{ code: 'UNAUTHORIZED',
message: "HTTP Basic: Access denied\n" \
- "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \
+ "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}" }
]
}, status: 401
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
new file mode 100644
index 00000000000..0e9a19c0b6f
--- /dev/null
+++ b/app/controllers/metrics_controller.rb
@@ -0,0 +1,21 @@
+class MetricsController < ActionController::Base
+ include RequiresHealthToken
+
+ protect_from_forgery with: :exception
+
+ before_action :validate_prometheus_metrics
+
+ def index
+ render text: metrics_service.metrics_text, content_type: 'text/plain; verssion=0.0.4'
+ end
+
+ private
+
+ def metrics_service
+ @metrics_service ||= MetricsService.new
+ end
+
+ def validate_prometheus_metrics
+ render_404 unless Gitlab::Metrics.prometheus_metrics_enabled?
+ end
+end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index 4193ac11399..656107d2b26 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -10,6 +10,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
Doorkeeper::AccessToken.revoke_all_for(params[:id], current_resource_owner)
end
- redirect_to applications_profile_url, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
+ redirect_to applications_profile_url,
+ status: 302,
+ notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
end
end
diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb
index daa51ae41df..933e0f3bceb 100644
--- a/app/controllers/profiles/avatars_controller.rb
+++ b/app/controllers/profiles/avatars_controller.rb
@@ -5,6 +5,6 @@ class Profiles::AvatarsController < Profiles::ApplicationController
@user.save
- redirect_to profile_path
+ redirect_to profile_path, status: 302
end
end
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index 6a1f468ba5a..2353f0840d6 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -39,7 +39,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}."
end
- redirect_to profile_chat_names_path
+ redirect_to profile_chat_names_path, status: 302
end
private
diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb
index 1c24c4db993..5655fb2ba0e 100644
--- a/app/controllers/profiles/emails_controller.rb
+++ b/app/controllers/profiles/emails_controller.rb
@@ -23,7 +23,7 @@ class Profiles::EmailsController < Profiles::ApplicationController
current_user.update_secondary_emails!
respond_to do |format|
- format.html { redirect_to profile_emails_url }
+ format.html { redirect_to profile_emails_url, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index e4452f46056..88f49da555a 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -26,7 +26,7 @@ class Profiles::KeysController < Profiles::ApplicationController
@key.destroy
respond_to do |format|
- format.html { redirect_to profile_keys_url }
+ format.html { redirect_to profile_keys_url, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 0abe7ea3c9b..f748d191ef4 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -38,7 +38,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
end
def set_index_vars
- @scopes = Gitlab::Auth::API_SCOPES
+ @scopes = Gitlab::Auth::AVAILABLE_SCOPES
@personal_access_token = finder.build
@inactive_personal_access_tokens = finder(state: 'inactive').execute
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index d3fa81cd623..313cdcd1c15 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -77,7 +77,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def destroy
current_user.disable_two_factor!
- redirect_to profile_account_path
+ redirect_to profile_account_path, status: 302
end
def skip
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
index c02fe85c3cc..e3d7737f44a 100644
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -2,6 +2,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
u2f_registration.destroy
- redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device."
+ redirect_to profile_two_factor_auth_path, status: 302, notice: "Successfully deleted U2F device."
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 8cd1c47eb3f..72f34930ca8 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -9,7 +9,7 @@ class ProfilesController < Profiles::ApplicationController
end
def update
- user_params.except!(:email) if @user.ldap_user?
+ user_params.except!(:email) if @user.external_email?
respond_to do |format|
if @user.update_attributes(user_params)
@@ -76,7 +76,7 @@ class ProfilesController < Profiles::ApplicationController
end
def user_params
- params.require(:user).permit(
+ @user_params ||= params.require(:user).permit(
:avatar,
:bio,
:email,
diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb
index 53788687076..21a403f3765 100644
--- a/app/controllers/projects/avatars_controller.rb
+++ b/app/controllers/projects/avatars_controller.rb
@@ -21,6 +21,6 @@ class Projects::AvatarsController < Projects::ApplicationController
@project.save
- redirect_to edit_project_path(@project)
+ redirect_to edit_project_path(@project), status: 302
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 7025c7a1de6..d8d14ea1fed 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -55,7 +55,7 @@ class Projects::BlobController < Projects::ApplicationController
def edit
if can_collaborate_with_project?
- blob.load_all_data!(@repository)
+ blob.load_all_data!
else
redirect_to action: 'show'
end
@@ -74,7 +74,7 @@ class Projects::BlobController < Projects::ApplicationController
def preview
@content = params[:content]
- @blob.load_all_data!(@repository)
+ @blob.load_all_data!
diffy = Diffy::Diff.new(@blob.data, @content, diff: '-U 3', include_diff_info: true)
diff_lines = diffy.diff.scan(/.*\n/)[2..-1]
diff_lines = Gitlab::Diff::Parser.new.parse(diff_lines)
@@ -93,9 +93,11 @@ class Projects::BlobController < Projects::ApplicationController
def diff
apply_diff_view_cookie!
- @form = UnfoldForm.new(params)
- @lines = Gitlab::Highlight.highlight_lines(repository, @ref, @path)
- @lines = @lines[@form.since - 1..@form.to - 1]
+ @blob.load_all_data!
+ @lines = Gitlab::Highlight.highlight(@blob.path, @blob.data, repository: @repository).lines
+
+ @form = UnfoldForm.new(params)
+ @lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe)
if @form.bottom?
@match_line = ''
@@ -111,7 +113,7 @@ class Projects::BlobController < Projects::ApplicationController
private
def blob
- @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path), @project)
+ @blob ||= @repository.blob_at(@commit.id, @path)
if @blob
@blob
diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb
index 67e3c9add81..ad53bb749a0 100644
--- a/app/controllers/projects/boards/lists_controller.rb
+++ b/app/controllers/projects/boards/lists_controller.rb
@@ -5,7 +5,9 @@ module Projects
before_action :authorize_read_list!, only: [:index]
def index
- render json: serialize_as_json(board.lists)
+ lists = ::Boards::Lists::ListService.new(project, current_user).execute(board)
+
+ render json: serialize_as_json(lists)
end
def create
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index f27089b8590..7f1469e107d 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -4,6 +4,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
+ before_action :authorize_update_deploy_key!, only: [:edit, :update]
layout "project_settings"
@@ -21,7 +22,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create
- @key = DeployKey.new(deploy_key_params.merge(user: current_user))
+ @key = DeployKey.new(create_params.merge(user: current_user))
unless @key.valid? && @project.deploy_keys << @key
flash[:alert] = @key.errors.full_messages.join(', ').html_safe
@@ -29,6 +30,18 @@ class Projects::DeployKeysController < Projects::ApplicationController
redirect_to_repository_settings(@project)
end
+ def edit
+ end
+
+ def update
+ if deploy_key.update_attributes(update_params)
+ flash[:notice] = 'Deploy key was successfully updated.'
+ redirect_to_repository_settings(@project)
+ else
+ render 'edit'
+ end
+ end
+
def enable
Projects::EnableDeployKeyService.new(@project, current_user, params).execute
@@ -52,7 +65,19 @@ class Projects::DeployKeysController < Projects::ApplicationController
protected
- def deploy_key_params
+ def deploy_key
+ @deploy_key ||= @project.deploy_keys.find(params[:id])
+ end
+
+ def create_params
params.require(:deploy_key).permit(:key, :title, :can_push)
end
+
+ def update_params
+ params.require(:deploy_key).permit(:title, :can_push)
+ end
+
+ def authorize_update_deploy_key!
+ access_denied! unless can?(current_user, :update_deploy_key, deploy_key)
+ end
end
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index 9a1bf037a95..928f17e6a8e 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -104,7 +104,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
def render_missing_personal_token
render plain: "HTTP Basic: Access denied\n" \
- "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \
+ "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
status: 401
end
@@ -128,32 +128,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController
@authentication_result = Gitlab::Auth.find_for_git_client(
login, password, project: project, ip: request.ip)
- return false unless @authentication_result.success?
-
- if download_request?
- authentication_has_download_access?
- else
- authentication_has_upload_access?
- end
+ @authentication_result.success?
end
def ci?
authentication_result.ci?(project)
end
-
- def authentication_has_download_access?
- has_authentication_ability?(:download_code) || has_authentication_ability?(:build_download_code)
- end
-
- def authentication_has_upload_access?
- has_authentication_ability?(:push_code)
- end
-
- def has_authentication_ability?(capability)
- (authentication_abilities || []).include?(capability)
- end
-
- def authentication_project
- authentication_result.project
- end
end
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 9e4edcae101..b6b62da7b60 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -1,38 +1,27 @@
class Projects::GitHttpController < Projects::GitHttpClientController
include WorkhorseRequest
+ before_action :access_check
+
+ rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
+ rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
+
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
def info_refs
- if upload_pack? && upload_pack_allowed?
- log_user_activity
-
- render_ok
- elsif receive_pack? && receive_pack_allowed?
- render_ok
- elsif http_blocked?
- render_http_not_allowed
- else
- render_denied
- end
+ log_user_activity if upload_pack?
+
+ render_ok
end
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
- if upload_pack? && upload_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
# POST /foo/bar.git/git-receive-pack" (git push)
def git_receive_pack
- if receive_pack? && receive_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
private
@@ -45,10 +34,6 @@ class Projects::GitHttpController < Projects::GitHttpClientController
git_command == 'git-upload-pack'
end
- def receive_pack?
- git_command == 'git-receive-pack'
- end
-
def git_command
if action_name == 'info_refs'
params[:service]
@@ -62,47 +47,27 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name)
end
- def render_http_not_allowed
- render plain: access_check.message, status: :forbidden
+ def render_403(exception)
+ render plain: exception.message, status: :forbidden
end
- def render_denied
- if user && can?(user, :read_project, project)
- render plain: access_denied_message, status: :forbidden
- else
- # Do not leak information about project existence
- render_not_found
- end
- end
-
- def access_denied_message
- 'Access denied'
+ def render_404(exception)
+ render plain: exception.message, status: :not_found
end
- def upload_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.upload_pack
-
- access_check.allowed? || ci?
+ def access
+ @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities)
end
- def access
- @access ||= access_klass.new(user, project, 'http', authentication_abilities: authentication_abilities)
+ def access_actor
+ return user if user
+ return :ci if ci?
end
def access_check
# Use the magic string '_any' to indicate we do not know what the
# changes are. This is also what gitlab-shell does.
- @access_check ||= access.check(git_command, '_any')
- end
-
- def http_blocked?
- !access.protocol_allowed?
- end
-
- def receive_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.receive_pack
-
- access_check.allowed?
+ access.check(git_command, '_any')
end
def access_klass
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 66b7bdbd988..deb33a2f0ff 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -36,7 +36,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to namespace_project_settings_members_path(project.namespace, project)
+ redirect_to namespace_project_settings_members_path(project.namespace, project), status: 302
end
format.js { head :ok }
end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 38bd82841dc..f5143280154 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -47,7 +47,7 @@ class Projects::HooksController < Projects::ApplicationController
def destroy
hook.destroy
- redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
+ redirect_to namespace_project_settings_integrations_path(@project.namespace, @project), status: 302
end
private
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index a1b84afcd91..4b143434ea5 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -14,14 +14,7 @@ class Projects::ImportsController < Projects::ApplicationController
@project.import_url = params[:project][:import_url]
if @project.save
- @project.reload
-
- if @project.import_failed?
- @project.import_retry
- else
- @project.import_start
- @project.add_import_job
- end
+ @project.reload.import_schedule
end
redirect_to namespace_project_import_path(@project.namespace, @project)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 59df1e7b86a..8b1efd0c572 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -199,14 +199,21 @@ class Projects::IssuesController < Projects::ApplicationController
def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
- render json: {
+ response = {
title: view_context.markdown_field(@issue, :title),
title_text: @issue.title,
description: view_context.markdown_field(@issue, :description),
description_text: @issue.description,
- task_status: @issue.task_status,
- updated_at: @issue.updated_at
+ task_status: @issue.task_status
}
+
+ if @issue.is_edited?
+ response[:updated_at] = @issue.updated_at
+ response[:updated_by_name] = @issue.last_edited_by.name
+ response[:updated_by_path] = user_path(@issue.last_edited_by)
+ end
+
+ render json: response
end
def create_merge_request
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index d2cd1cfdab8..cb4f46388fd 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -45,6 +45,17 @@ class Projects::JobsController < Projects::ApplicationController
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
+
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: BuildSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@build, {}, BuildDetailsEntity)
+ end
+ end
end
def trace
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 71bfb7163da..1beac202efe 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -8,7 +8,7 @@ class Projects::LabelsController < Projects::ApplicationController
before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update,
:generate, :destroy, :remove_priority,
:set_priorities]
- before_action :authorize_admin_group!, only: [:promote]
+ before_action :authorize_admin_group_labels!, only: [:promote]
respond_to :js, :html
@@ -74,7 +74,9 @@ class Projects::LabelsController < Projects::ApplicationController
@label.destroy
@labels = find_labels
- redirect_to(namespace_project_labels_path(@project.namespace, @project), notice: 'Label was removed')
+ redirect_to namespace_project_labels_path(@project.namespace, @project),
+ status: 302,
+ notice: 'Label was removed'
end
def remove_priority
@@ -159,7 +161,7 @@ class Projects::LabelsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_label, @project)
end
- def authorize_admin_group!
- return render_404 unless can?(current_user, :admin_group, @project.group)
+ def authorize_admin_group_labels!
+ return render_404 unless can?(current_user, :admin_label, @project.group)
end
end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index c56bce19eee..ae16f69955a 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -80,7 +80,7 @@ class Projects::MilestonesController < Projects::ApplicationController
Milestones::DestroyService.new(project, current_user).execute(milestone)
respond_to do |format|
- format.html { redirect_to namespace_project_milestones_path }
+ format.html { redirect_to namespace_project_milestones_path, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 93b2c180810..28b383e69eb 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -15,8 +15,9 @@ class Projects::PagesController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_pages_path(@project.namespace, @project),
- notice: 'Pages were removed')
+ redirect_to namespace_project_pages_path(@project.namespace, @project),
+ status: 302,
+ notice: 'Pages were removed'
end
end
end
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 3a93977fd27..dbd011f6c5d 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -27,8 +27,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_pages_path(@project.namespace, @project),
- notice: 'Domain was removed')
+ redirect_to namespace_project_pages_path(@project.namespace, @project),
+ status: 302,
+ notice: 'Domain was removed'
end
format.js
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 1616b2cb6b8..ef4f083b98f 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -43,15 +43,17 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
if schedule.update(owner: current_user)
redirect_to pipeline_schedules_path(@project)
else
- redirect_to pipeline_schedules_path(@project), alert: "Failed to change the owner"
+ redirect_to pipeline_schedules_path(@project), alert: _("Failed to change the owner")
end
end
def destroy
if schedule.destroy
- redirect_to pipeline_schedules_path(@project)
+ redirect_to pipeline_schedules_path(@project), status: 302
else
- redirect_to pipeline_schedules_path(@project), alert: "Failed to remove the pipeline schedule"
+ redirect_to pipeline_schedules_path(@project),
+ status: 302,
+ alert: _("Failed to remove the pipeline schedule")
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 87ec0df257a..6223e7943f8 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -99,7 +99,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def stage
- @stage = pipeline.stage(params[:stage])
+ @stage = pipeline.legacy_stage(params[:stage])
return not_found unless @stage
respond_to do |format|
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index ba24fa9acfe..d1719f12072 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -19,7 +19,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
def protected_ref_params
params.require(:protected_branch).permit(:name,
- merge_access_levels_attributes: [:access_level, :id],
- push_access_levels_attributes: [:access_level, :id])
+ merge_access_levels_attributes: access_level_attributes,
+ push_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 083a70968e5..b51bdf7aa78 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
format.js { head :ok }
end
end
+
+ protected
+
+ def access_level_attributes
+ %i(access_level id)
+ end
end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
index c61ddf145e6..a5dbd7e46ae 100644
--- a/app/controllers/projects/protected_tags_controller.rb
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -18,6 +18,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
end
def protected_ref_params
- params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id])
+ params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 17f391ba07f..98e78585be8 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -11,9 +11,11 @@ module Projects
def destroy
if image.destroy
redirect_to project_container_registry_path(@project),
+ status: 302,
notice: 'Image repository has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
+ status: 302,
alert: 'Failed to remove image repository!'
end
end
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index d689cade3ab..5050dba3aab 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -6,9 +6,11 @@ module Projects
def destroy
if tag.delete
redirect_to project_container_registry_path(@project),
+ status: 302,
notice: 'Registry tag has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
+ status: 302,
alert: 'Failed to remove registry tag!'
end
end
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index 8267b14941d..3cb01405b05 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -22,6 +22,6 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
runner_project = project.runner_projects.find(params[:id])
runner_project.destroy
- redirect_to runners_path(project)
+ redirect_to runners_path(project), status: 302
end
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 8b50ea207a5..160e632648a 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -24,7 +24,7 @@ class Projects::RunnersController < Projects::ApplicationController
@runner.destroy
end
- redirect_to runners_path(@project)
+ redirect_to runners_path(@project), status: 302
end
def resume
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 3a97c1e98af..8a8f8d6a27d 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -79,7 +79,7 @@ class Projects::SnippetsController < Projects::ApplicationController
@snippet.destroy
- redirect_to namespace_project_snippets_path(@project.namespace, @project)
+ redirect_to namespace_project_snippets_path(@project.namespace, @project), status: 302
end
protected
@@ -107,6 +107,6 @@ class Projects::SnippetsController < Projects::ApplicationController
end
def snippet_params
- params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
+ params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
end
end
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index afa56de920b..e86adddd77f 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -50,7 +50,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = "Could not remove the trigger."
end
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), status: 302
end
private
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index 0953eecaeb5..50e25a00f03 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -36,7 +36,9 @@ class Projects::VariablesController < Projects::ApplicationController
@key = @project.variables.find(params[:id])
@key.destroy
- redirect_to namespace_project_settings_ci_cd_path(project.namespace, project), notice: 'Variable was successfully removed.'
+ redirect_to namespace_project_settings_ci_cd_path(project.namespace, project),
+ status: 302,
+ notice: 'Variable was successfully removed.'
end
private
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 887d18dbec3..e54b90b8d52 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -85,10 +85,9 @@ class Projects::WikisController < Projects::ApplicationController
@page = @project_wiki.find_page(params[:id])
WikiPages::DestroyService.new(@project, current_user).execute(@page)
- redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, :home),
- notice: "Page was successfully deleted"
- )
+ redirect_to namespace_project_wiki_path(@project.namespace, @project, :home),
+ status: 302,
+ notice: "Page was successfully deleted"
end
def git_access
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index cc62e1fa99b..38ed7d776a7 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -34,7 +34,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
project_path(@project),
- notice: "Project '#{@project.name}' was successfully created."
+ notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
)
else
render 'new'
@@ -49,7 +49,7 @@ class ProjectsController < Projects::ApplicationController
respond_to do |format|
if result[:status] == :success
- flash[:notice] = "Project '#{@project.name}' was successfully updated."
+ flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
format.html do
redirect_to(edit_project_path(@project))
end
@@ -76,7 +76,7 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_fork_project, @project)
if ::Projects::UnlinkForkService.new(@project, current_user).execute
- flash[:notice] = 'The fork relationship has been removed.'
+ flash[:notice] = _('The fork relationship has been removed.')
end
end
@@ -97,7 +97,7 @@ class ProjectsController < Projects::ApplicationController
end
if @project.pending_delete?
- flash[:alert] = "Project #{@project.name} queued for deletion."
+ flash[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name }
end
respond_to do |format|
@@ -117,11 +117,11 @@ 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_with_namespace}' will be deleted."
+ flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace }
- redirect_to dashboard_projects_path
+ redirect_to dashboard_projects_path, status: 302
rescue Projects::DestroyService::DestroyError => ex
- redirect_to edit_project_path(@project), alert: ex.message
+ redirect_to edit_project_path(@project), status: 302, alert: ex.message
end
def new_issue_address
@@ -156,7 +156,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
project_path(@project),
- notice: "Housekeeping successfully started"
+ notice: _("Housekeeping successfully started")
)
rescue ::Projects::HousekeepingService::LeaseTaken => ex
redirect_to(
@@ -170,7 +170,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
edit_project_path(@project),
- notice: "Project export started. A download link will be sent by email."
+ notice: _("Project export started. A download link will be sent by email.")
)
end
@@ -182,16 +182,16 @@ class ProjectsController < Projects::ApplicationController
else
redirect_to(
edit_project_path(@project),
- alert: "Project export link has expired. Please generate a new export from your project settings."
+ alert: _("Project export link has expired. Please generate a new export from your project settings.")
)
end
end
def remove_export
if @project.remove_exports
- flash[:notice] = "Project export has been deleted."
+ flash[:notice] = _("Project export has been deleted.")
else
- flash[:alert] = "Project export could not be deleted."
+ flash[:alert] = _("Project export could not be deleted.")
end
redirect_to(edit_project_path(@project))
end
@@ -202,7 +202,7 @@ class ProjectsController < Projects::ApplicationController
else
redirect_to(
edit_project_path(@project),
- alert: "Project export could not be deleted."
+ alert: _("Project export could not be deleted.")
)
end
end
@@ -220,13 +220,13 @@ class ProjectsController < Projects::ApplicationController
branches = BranchesFinder.new(@repository, params).execute.map(&:name)
options = {
- 'Branches' => branches.take(100)
+ s_('RefSwitcher|Branches') => branches.take(100)
}
unless @repository.tag_count.zero?
tags = TagsFinder.new(@repository, params).execute.map(&:name)
- options['Tags'] = tags.take(100)
+ options[s_('RefSwitcher|Tags')] = tags.take(100)
end
# If reference is commit id - we should add it to branch/tag selectbox
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 3ca14dee33c..1bc6520370a 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -25,12 +25,12 @@ class RegistrationsController < Devise::RegistrationsController
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, current_user.id)
+ current_user.delete_async(deleted_by: current_user)
respond_to do |format|
format.html do
session.try(:destroy)
- redirect_to new_user_session_path, notice: "Account scheduled for removal."
+ redirect_to new_user_session_path, status: 302, notice: "Account scheduled for removal."
end
end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 8c6ba4915cd..d7c702b94f8 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -47,6 +47,10 @@ class SessionsController < Devise::SessionsController
private
+ def login_counter
+ @login_counter ||= Gitlab::Metrics.counter(:user_session_logins, 'User sign in count')
+ end
+
# Handle an "initial setup" state, where there's only one user, it's an admin,
# and they require a password change.
def check_initial_setup
@@ -90,7 +94,7 @@ class SessionsController < Devise::SessionsController
# Prevent a 'you are already signed in' message directly after signing:
# we should never redirect to '/users/sign_in' after signing in successfully.
- unless redirect_path == new_user_session_path
+ unless URI(redirect_path).path == new_user_session_path
store_location_for(:redirect, redirect_path)
end
end
@@ -103,6 +107,10 @@ class SessionsController < Devise::SessionsController
provider = Gitlab.config.omniauth.auto_sign_in_with_provider
return unless provider.present?
+ # If a "auto_sign_in" query parameter is set to a falsy value, don't auto sign-in.
+ # Otherwise, the default is to auto sign-in.
+ return if Gitlab::Utils.to_boolean(params[:auto_sign_in]) == false
+
# Auto sign in with an Omniauth provider only if the standard "you need to sign-in" alert is
# registered or no alert at all. In case of another alert (such as a blocked user), it is safer
# to do nothing to prevent redirection loops with certain Omniauth providers.
@@ -125,6 +133,7 @@ class SessionsController < Devise::SessionsController
end
def log_user_activity(user)
+ login_counter.increment
Users::ActivityService.new(user, 'login').execute
end
diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb
index ccc739da879..cb6c3a7cd98 100644
--- a/app/controllers/sherlock/transactions_controller.rb
+++ b/app/controllers/sherlock/transactions_controller.rb
@@ -13,7 +13,7 @@ module Sherlock
def destroy_all
Gitlab::Sherlock.collection.clear
- redirect_to(:back)
+ redirect_to :back, status: 302
end
end
end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 5b2d143ee79..3d86dd2ea2c 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -45,6 +45,8 @@ class SnippetsController < ApplicationController
@snippet = CreateSnippetService.new(nil, current_user, create_params).execute
+ move_temporary_files if @snippet.valid? && params[:files]
+
recaptcha_check_with_fallback { render :new }
end
@@ -82,7 +84,7 @@ class SnippetsController < ApplicationController
@snippet.destroy
- redirect_to snippets_path
+ redirect_to snippets_path, status: 302
end
def preview_markdown
@@ -124,6 +126,12 @@ class SnippetsController < ApplicationController
end
def snippet_params
- params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
+ params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
+ end
+
+ def move_temporary_files
+ params[:files].each do |file|
+ FileMover.new(file, @snippet).execute
+ end
end
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index eef53730291..dc882b17143 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -9,12 +9,16 @@ class UploadsController < ApplicationController
private
def find_model
+ return nil unless params[:id]
+
return render_404 unless upload_model && upload_mount
@model = upload_model.find(params[:id])
end
def authorize_access!
+ return nil unless model
+
authorized =
case model
when Note
@@ -33,6 +37,8 @@ class UploadsController < ApplicationController
end
def authorize_create_access!
+ return nil unless model
+
# for now we support only personal snippets comments
authorized = can?(current_user, :comment_personal_snippet, model)
@@ -73,7 +79,12 @@ class UploadsController < ApplicationController
def uploader
return @uploader if defined?(@uploader)
- if model.is_a?(PersonalSnippet)
+ case model
+ when nil
+ @uploader = PersonalFileUploader.new(nil, params[:secret])
+
+ @uploader.retrieve_from_store!(params[:filename])
+ when PersonalSnippet
@uploader = PersonalFileUploader.new(model, params[:secret])
@uploader.retrieve_from_store!(params[:filename])
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
new file mode 100644
index 00000000000..b0450ddc1fd
--- /dev/null
+++ b/app/finders/events_finder.rb
@@ -0,0 +1,62 @@
+class EventsFinder
+ attr_reader :source, :params, :current_user
+
+ # Used to filter Events
+ #
+ # Arguments:
+ # source - which user or project to looks for events on
+ # current_user - only return events for projects visible to this user
+ # params:
+ # action: string
+ # target_type: string
+ # before: datetime
+ # after: datetime
+ #
+ def initialize(params = {})
+ @source = params.delete(:source)
+ @current_user = params.delete(:current_user)
+ @params = params
+ end
+
+ def execute
+ events = source.events
+
+ events = by_current_user_access(events)
+ events = by_action(events)
+ events = by_target_type(events)
+ events = by_created_at_before(events)
+ events = by_created_at_after(events)
+
+ events
+ end
+
+ private
+
+ def by_current_user_access(events)
+ events.merge(ProjectsFinder.new(current_user: current_user).execute).references(:project)
+ end
+
+ def by_action(events)
+ return events unless Event::ACTIONS[params[:action]]
+
+ events.where(action: Event::ACTIONS[params[:action]])
+ end
+
+ def by_target_type(events)
+ return events unless Event::TARGET_TYPES[params[:target_type]]
+
+ events.where(target_type: Event::TARGET_TYPES[params[:target_type]])
+ end
+
+ def by_created_at_before(events)
+ return events unless params[:before]
+
+ events.where('events.created_at < ?', params[:before].beginning_of_day)
+ end
+
+ def by_created_at_after(events)
+ return events unless params[:after]
+
+ events.where('events.created_at > ?', params[:after].end_of_day)
+ end
+end
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index dc13386184e..c358f23f541 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -39,7 +39,7 @@ class TodosFinder
private
def action_id?
- action_id.present? && Todo::ACTION_NAMES.has_key?(action_id.to_i)
+ action_id.present? && Todo::ACTION_NAMES.key?(action_id.to_i)
end
def action_id
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 36d9090b3ae..71154da7ec5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -181,7 +181,7 @@ module ApplicationHelper
end
def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
- return if object.last_edited_at == object.created_at || object.last_edited_at.blank?
+ return unless object.is_edited?
content_tag :small, class: 'edited-text' do
output = content_tag(:span, 'Edited ')
@@ -275,8 +275,8 @@ module ApplicationHelper
'active' if condition
end
- def show_user_callout?
- cookies[:user_callout_dismissed].nil?
+ def show_callout?(name)
+ cookies[name] != 'true'
end
def linkedin_url(user)
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 0081bbd92b3..00464810054 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -61,7 +61,7 @@ module ButtonHelper
html: true,
placement: placement,
container: 'body',
- title: "Set a password on your account<br>to pull or push via #{protocol}"
+ title: _("Set a password on your account to pull or push via %{protocol}") % { protocol: protocol }
}
end
@@ -76,7 +76,7 @@ module ButtonHelper
html: true,
placement: placement,
container: 'body',
- title: 'Add an SSH key to your profile<br>to pull or push via SSH.'
+ title: _('Add an SSH key to your profile to pull or push via SSH.')
}
end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 32b1e7822af..21c0eb8b54c 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -16,16 +16,18 @@ module CiStatusHelper
return status.label
end
- case status
- when 'success'
- 'passed'
- when 'success_with_warnings'
- 'passed with warnings'
- when 'manual'
- 'waiting for manual action'
- else
- status
- end
+ label = case status
+ when 'success'
+ 'passed'
+ when 'success_with_warnings'
+ 'passed with warnings'
+ when 'manual'
+ 'waiting for manual action'
+ else
+ status
+ end
+ translation = "CiStatusLabel|#{label}"
+ s_(translation)
end
def ci_text_for_status(status)
@@ -35,13 +37,22 @@ module CiStatusHelper
case status
when 'success'
- 'passed'
+ s_('CiStatusText|passed')
when 'success_with_warnings'
- 'passed'
+ s_('CiStatusText|passed')
when 'manual'
- 'blocked'
+ s_('CiStatusText|blocked')
else
- status
+ # All states are already being translated inside the detailed statuses:
+ # :running => Gitlab::Ci::Status::Running
+ # :skipped => Gitlab::Ci::Status::Skipped
+ # :failed => Gitlab::Ci::Status::Failed
+ # :success => Gitlab::Ci::Status::Success
+ # :canceled => Gitlab::Ci::Status::Canceled
+ # The following states are customized above:
+ # :manual => Gitlab::Ci::Status::Manual
+ status_translation = "CiStatusText|#{status}"
+ s_(status_translation)
end
end
diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/conversational_development_index_helper.rb
new file mode 100644
index 00000000000..1ff54415811
--- /dev/null
+++ b/app/helpers/conversational_development_index_helper.rb
@@ -0,0 +1,16 @@
+module ConversationalDevelopmentIndexHelper
+ def score_level(score)
+ if score < 33.33
+ 'low'
+ elsif score < 66.66
+ 'average'
+ else
+ 'high'
+ end
+ end
+
+ def format_score(score)
+ precision = score < 1 ? 2 : 1
+ number_with_precision(score, precision: precision)
+ end
+end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 8ed99642c7a..ac8c518ac84 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -1,27 +1,27 @@
module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block)
- content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
+ content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do
data_attr = { toggle: "dropdown" }
- if options.has_key?(:data)
+ if options.key?(:data)
data_attr = options[:data].merge(data_attr)
end
dropdown_output = dropdown_toggle(toggle_text, data_attr, options)
- dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.has_key?(:dropdown_class)}") do
+ dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do
output = ""
- if options.has_key?(:title)
+ if options.key?(:title)
output << dropdown_title(options[:title])
end
- if options.has_key?(:filter)
+ if options.key?(:filter)
output << dropdown_filter(options[:placeholder])
end
- output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
- capture(&block) if block && !options.has_key?(:footer_content)
+ output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
+ capture(&block) if block && !options.key?(:footer_content)
end
if block && options[:footer_content]
@@ -41,7 +41,7 @@ module DropdownsHelper
def dropdown_toggle(toggle_text, data_attr, options = {})
default_label = data_attr[:default_label]
- content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.has_key?(:toggle_class)}", id: (options[:id] if options.has_key?(:id)), type: "button", data: data_attr) do
+ content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do
output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}")
output << icon('chevron-down')
output.html_safe
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 40864bed0ff..8c7af62e199 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -128,7 +128,7 @@ module GitlabRoutingHelper
def preview_markdown_path(project, *args)
if @snippet.is_a?(PersonalSnippet)
- preview_markdown_snippet_path(@snippet)
+ preview_markdown_snippets_path
else
preview_markdown_namespace_project_path(project.namespace, project, *args)
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index a6014088e92..c003b01e226 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -8,7 +8,7 @@ module GroupsHelper
group = Group.find_by_full_path(group)
end
- group.try(:avatar_url) || image_path('no_group_avatar.png')
+ group.try(:avatar_url) || ActionController::Base.helpers.image_path('no_group_avatar.png')
end
def group_title(group, name = nil, url = nil)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index c380a10c82d..5e8f0849969 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -200,7 +200,7 @@ module IssuablesHelper
end
def issuable_initial_data(issuable)
- {
+ data = {
endpoint: namespace_project_issue_path(@project.namespace, @project, issuable),
canUpdate: can?(current_user, :update_issue, issuable),
canDestroy: can?(current_user, :destroy_issue, issuable),
@@ -217,7 +217,23 @@ module IssuablesHelper
initialTitleText: issuable.title,
initialDescriptionHtml: markdown_field(issuable, :description),
initialDescriptionText: issuable.description
- }.to_json
+ }
+
+ data.merge!(updated_at_by(issuable))
+
+ data.to_json
+ end
+
+ def updated_at_by(issuable)
+ return {} unless issuable.is_edited?
+
+ {
+ updatedAt: issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: issuable.last_edited_by.name,
+ path: user_path(issuable.last_edited_by)
+ }
+ }
end
private
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index c515774140c..a230db22fa2 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -121,6 +121,8 @@ module MilestonesHelper
merge_requests_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
elsif @group
merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ else
+ merge_requests_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
end
end
@@ -129,6 +131,8 @@ module MilestonesHelper
participants_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
elsif @group
participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ else
+ participants_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
end
end
@@ -137,6 +141,8 @@ module MilestonesHelper
labels_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
elsif @group
labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ else
+ labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
end
end
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 17bfd07e00f..88dfe78c90c 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -13,7 +13,7 @@ module NavHelper
else
"page-gutter right-sidebar-expanded"
end
- elsif current_path?('builds#show')
+ elsif current_path?('jobs#show')
"page-gutter build-sidebar right-sidebar-expanded"
elsif current_path?('wikis#show') ||
current_path?('wikis#edit') ||
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 3d4802290b5..c59d8dafc83 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -90,14 +90,18 @@ module NotesHelper
end
end
- def note_url(note)
+ def note_url(note, project = @project)
if note.noteable.is_a?(PersonalSnippet)
snippet_note_path(note.noteable, note)
else
- namespace_project_note_path(@project.namespace, @project, note)
+ namespace_project_note_path(project.namespace, project, note)
end
end
+ def noteable_note_url(note)
+ Gitlab::UrlBuilder.build(note)
+ end
+
def form_resources
if @snippet.is_a?(PersonalSnippet)
[@note]
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 03cc8f2b6bd..fde961e2da4 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -21,30 +21,36 @@ module NotificationsHelper
end
def notification_title(level)
+ # Can be anything in `NotificationSetting.level:
case level.to_sym
when :participating
- 'Participate'
+ s_('NotificationLevel|Participate')
when :mention
- 'On mention'
+ s_('NotificationLevel|On mention')
else
- level.to_s.titlecase
+ N_('NotificationLevel|Global')
+ N_('NotificationLevel|Watch')
+ N_('NotificationLevel|Disabled')
+ N_('NotificationLevel|Custom')
+ level = "NotificationLevel|#{level.to_s.humanize}"
+ s_(level)
end
end
def notification_description(level)
case level.to_sym
when :participating
- 'You will only receive notifications for threads you have participated in'
+ _('You will only receive notifications for threads you have participated in')
when :mention
- 'You will receive notifications only for comments in which you were @mentioned'
+ _('You will receive notifications only for comments in which you were @mentioned')
when :watch
- 'You will receive notifications for any activity'
+ _('You will receive notifications for any activity')
when :disabled
- 'You will not get any notifications via email'
+ _('You will not get any notifications via email')
when :global
- 'Use your global notification setting'
+ _('Use your global notification setting')
when :custom
- 'You will only receive notifications for the events you choose'
+ _('You will only receive notifications for the events you choose')
end
end
@@ -76,11 +82,22 @@ module NotificationsHelper
end
def notification_event_name(event)
+ # All values from NotificationSetting::EMAIL_EVENTS
case event
when :success_pipeline
- 'Successful pipeline'
+ s_('NotificationEvent|Successful pipeline')
else
- event.to_s.humanize
+ N_('NotificationEvent|New note')
+ N_('NotificationEvent|New issue')
+ N_('NotificationEvent|Reopen issue')
+ N_('NotificationEvent|Close issue')
+ N_('NotificationEvent|Reassign issue')
+ N_('NotificationEvent|New merge request')
+ N_('NotificationEvent|Close merge request')
+ N_('NotificationEvent|Reassign merge request')
+ N_('NotificationEvent|Merge merge request')
+ N_('NotificationEvent|Failed pipeline')
+ s_(event.to_s.humanize)
end
end
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
new file mode 100644
index 00000000000..45238f12ac7
--- /dev/null
+++ b/app/helpers/profiles_helper.rb
@@ -0,0 +1,7 @@
+module ProfilesHelper
+ def email_provider_label
+ return unless current_user.external_email?
+
+ current_user.email_provider.present? ? Gitlab::OAuth::Provider.label_for(current_user.email_provider) : "LDAP"
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 7b0584c42a2..7441b58fddb 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -70,15 +70,18 @@ module ProjectsHelper
end
def remove_project_message(project)
- "You are going to remove #{project.name_with_namespace}.\n Removed project CANNOT be restored!\n Are you ABSOLUTELY sure?"
+ _("You are going to remove %{project_name_with_namespace}.\nRemoved project CANNOT be restored!\nAre you ABSOLUTELY sure?") %
+ { project_name_with_namespace: project.name_with_namespace }
end
def transfer_project_message(project)
- "You are going to transfer #{project.name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+ _("You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?") %
+ { project_name_with_namespace: project.name_with_namespace }
end
def remove_fork_project_message(project)
- "You are going to remove the fork relationship to source project #{@project.forked_from_project.name_with_namespace}. Are you ABSOLUTELY sure?"
+ _("You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?") %
+ { forked_from_project: @project.forked_from_project.name_with_namespace }
end
def project_nav_tabs
@@ -138,11 +141,15 @@ module ProjectsHelper
if @project.private?
level = @project.project_feature.send(field)
- options.delete('Everyone with access')
- highest_available_option = options.values.max if level == ProjectFeature::ENABLED
+ disabled_option = ProjectFeature::ENABLED
+ highest_available_option = ProjectFeature::PRIVATE if level == disabled_option
end
- options = options_for_select(options, selected: highest_available_option || @project.project_feature.public_send(field))
+ options = options_for_select(
+ options.invert,
+ selected: highest_available_option || @project.project_feature.public_send(field),
+ disabled: disabled_option
+ )
content_tag(
:select,
@@ -155,12 +162,13 @@ module ProjectsHelper
end
def link_to_autodeploy_doc
- link_to 'About auto deploy', help_page_path('ci/autodeploy/index'), target: '_blank'
+ link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank'
end
def autodeploy_flash_notice(branch_name)
- "Branch <strong>#{truncate(sanitize(branch_name))}</strong> was created. To set up auto deploy, \
- choose a GitLab CI Yaml template and commit your changes. #{link_to_autodeploy_doc}".html_safe
+ translation = _("Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}") %
+ { branch_name: truncate(sanitize(branch_name)), link_to_autodeploy_doc: link_to_autodeploy_doc }
+ translation.html_safe
end
def project_list_cache_key(project)
@@ -246,11 +254,11 @@ module ProjectsHelper
def project_lfs_status(project)
if project.lfs_enabled?
content_tag(:span, class: 'lfs-enabled') do
- 'Enabled'
+ s_('LFSStatus|Enabled')
end
else
content_tag(:span, class: 'lfs-disabled') do
- 'Disabled'
+ s_('LFSStatus|Disabled')
end
end
end
@@ -259,7 +267,7 @@ module ProjectsHelper
if current_user
current_user.name
else
- "Your name"
+ _("Your name")
end
end
@@ -296,17 +304,18 @@ module ProjectsHelper
if project.last_activity_at
time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago')
else
- "Never"
+ s_("ProjectLastActivity|Never")
end
end
def add_special_file_path(project, file_name:, commit_message: nil, branch_name: nil, context: nil)
+ commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name.downcase }
namespace_project_new_blob_path(
project.namespace,
project,
project.default_branch || 'master',
file_name: file_name,
- commit_message: commit_message || "Add #{file_name.downcase}",
+ commit_message: commit_message,
branch_name: branch_name,
context: context
)
@@ -443,9 +452,9 @@ module ProjectsHelper
def project_feature_options
{
- 'Disabled' => ProjectFeature::DISABLED,
- 'Only team members' => ProjectFeature::PRIVATE,
- 'Everyone with access' => ProjectFeature::ENABLED
+ ProjectFeature::DISABLED => s_('ProjectFeature|Disabled'),
+ ProjectFeature::PRIVATE => s_('ProjectFeature|Only team members'),
+ ProjectFeature::ENABLED => s_('ProjectFeature|Everyone with access')
}
end
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index c0763a8a9c4..8e0a1e2ecdf 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -13,6 +13,17 @@ module SubmoduleHelper
if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
namespace, project = $1, $2
+ gitlab_hosts = [Gitlab.config.gitlab.url,
+ Gitlab.config.gitlab_shell.ssh_path_prefix]
+
+ gitlab_hosts.each do |host|
+ if url.start_with?(host)
+ namespace, _, project = url.sub(host, '').rpartition('/')
+ break
+ end
+ end
+
+ namespace.sub!(/\A\//, '')
project.rstrip!
project.sub!(/\.git\z/, '')
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index b4aaf498068..35755bc149b 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -29,11 +29,11 @@ module VisibilityLevelHelper
def project_visibility_level_description(level)
case level
when Gitlab::VisibilityLevel::PRIVATE
- "Project access must be granted explicitly to each user."
+ _("Project access must be granted explicitly to each user.")
when Gitlab::VisibilityLevel::INTERNAL
- "The project can be cloned by any logged in user."
+ _("The project can be accessed by any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The project can be cloned without any authentication."
+ _("The project can be accessed without any authentication.")
end
end
@@ -81,7 +81,9 @@ module VisibilityLevelHelper
end
def visibility_level_label(level)
- Project.visibility_levels.key(level)
+ # The visibility level can be:
+ # 'VisibilityLevel|Private', 'VisibilityLevel|Internal', 'VisibilityLevel|Public'
+ s_(Project.visibility_levels.key(level))
end
def restricted_visibility_levels(show_all = false)
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 0d7c2d20029..4cbd90c5817 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -15,8 +15,7 @@ class AbuseReport < ActiveRecord::Base
alias_method :author, :reporter
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def notify
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 3d12f3c306b..2192f76499d 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -143,7 +143,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
- unless Gitlab::VisibilityLevel.options.has_value?(level)
+ unless Gitlab::VisibilityLevel.options.value?(level)
record.errors.add(attr, "'#{level}' is not a valid visibility level")
end
end
@@ -151,7 +151,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :import_sources do |record, attr, value|
value&.each do |source|
- unless Gitlab::ImportSources.options.has_value?(source)
+ unless Gitlab::ImportSources.options.value?(source)
record.errors.add(attr, "'#{source}' is not a import source")
end
end
@@ -189,8 +189,9 @@ class ApplicationSetting < ActiveRecord::Base
end
def self.cached
- ensure_cache_setup
- Rails.cache.fetch(CACHE_KEY)
+ value = Rails.cache.read(CACHE_KEY)
+ ensure_cache_setup if value.present?
+ value
end
def self.ensure_cache_setup
@@ -199,7 +200,7 @@ class ApplicationSetting < ActiveRecord::Base
ApplicationSetting.define_attribute_methods
end
- def self.defaults_ce
+ def self.defaults
{
after_sign_up_text: nil,
akismet_enabled: false,
@@ -250,10 +251,6 @@ class ApplicationSetting < ActiveRecord::Base
}
end
- def self.defaults
- defaults_ce
- end
-
def self.create_from_defaults
create(defaults)
end
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 6ada6fae4eb..ebe60441603 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -5,7 +5,7 @@ class AwardEmoji < ActiveRecord::Base
include Participable
include GhostUser
- belongs_to :awardable, polymorphic: true
+ belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
validates :awardable, :user, presence: true
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 6a42a12891c..954d4e4d779 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -94,6 +94,10 @@ class Blob < SimpleDelegator
end
end
+ def load_all_data!
+ super(project.repository) if project
+ end
+
def no_highlighting?
raw_size && raw_size > MAXIMUM_TEXT_HIGHLIGHT_SIZE
end
@@ -151,6 +155,10 @@ class Blob < SimpleDelegator
@extension ||= extname.downcase.delete('.')
end
+ def file_type
+ Gitlab::FileDetector.type_of(path)
+ end
+
def video?
UploaderHelper::VIDEO_EXT.include?(extension)
end
@@ -176,16 +184,19 @@ class Blob < SimpleDelegator
end
def rendered_as_text?(ignore_errors: true)
- simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?)
+ simple_viewer.is_a?(BlobViewer::Text) && (ignore_errors || simple_viewer.render_error.nil?)
end
def show_viewer_switcher?
rendered_as_text? && rich_viewer
end
+ def expanded?
+ !!@expanded
+ end
+
def expand!
- simple_viewer&.expanded = true
- rich_viewer&.expanded = true
+ @expanded = true
end
private
diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb
index e6119d25fab..35965d01692 100644
--- a/app/models/blob_viewer/base.rb
+++ b/app/models/blob_viewer/base.rb
@@ -6,15 +6,15 @@ module BlobViewer
self.loading_partial_name = 'loading'
- delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class
+ delegate :partial_path, :loading_partial_path, :rich?, :simple?, :load_async?, :text?, :binary?, to: :class
attr_reader :blob
- attr_accessor :expanded
delegate :project, to: :blob
def initialize(blob)
@blob = blob
+ @initially_binary = blob.binary?
end
def self.partial_path
@@ -52,19 +52,15 @@ module BlobViewer
def self.can_render?(blob, verify_binary: true)
return false if verify_binary && binary? != blob.binary?
return true if extensions&.include?(blob.extension)
- return true if file_types&.include?(Gitlab::FileDetector.type_of(blob.path))
+ return true if file_types&.include?(blob.file_type)
false
end
- def load_async?
- self.class.load_async? && render_error.nil?
- end
-
def collapsed?
return @collapsed if defined?(@collapsed)
- @collapsed = !expanded && collapse_limit && blob.raw_size > collapse_limit
+ @collapsed = !blob.expanded? && collapse_limit && blob.raw_size > collapse_limit
end
def too_large?
@@ -73,6 +69,10 @@ module BlobViewer
@too_large = size_limit && blob.raw_size > size_limit
end
+ def binary_detected_after_load?
+ !@initially_binary && blob.binary?
+ end
+
# This method is used on the server side to check whether we can attempt to
# render the blob at all. Human-readable error messages are found in the
# `BlobHelper#blob_render_error_reason` helper.
diff --git a/app/models/blob_viewer/empty.rb b/app/models/blob_viewer/empty.rb
index d9d128eb273..2380578ed72 100644
--- a/app/models/blob_viewer/empty.rb
+++ b/app/models/blob_viewer/empty.rb
@@ -4,6 +4,5 @@ module BlobViewer
include ServerSide
self.partial_name = 'empty'
- self.binary = true
end
end
diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb
index 05a3dd7d913..e6bcacf7f70 100644
--- a/app/models/blob_viewer/server_side.rb
+++ b/app/models/blob_viewer/server_side.rb
@@ -9,9 +9,7 @@ module BlobViewer
end
def prepare!
- if blob.project
- blob.load_all_data!(blob.project.repository)
- end
+ blob.load_all_data!
end
def render_error
diff --git a/app/models/board.rb b/app/models/board.rb
index cf8317891b5..18081a32157 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -5,6 +5,10 @@ class Board < ActiveRecord::Base
validates :project, presence: true
+ def backlog_list
+ lists.merge(List.backlog).take
+ end
+
def closed_list
lists.merge(List.closed).take
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 58dfdd87652..cec1ca89a6a 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -47,8 +47,8 @@ module Ci
before_destroy { unscoped_project }
after_create :execute_hooks
- after_save :update_project_statistics, if: :artifacts_size_changed?
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics_after_save, on: [:create, :update]
+ after_commit :update_project_statistics, on: :destroy
class << self
# This is needed for url_for to work,
@@ -138,6 +138,17 @@ module Ci
ExpandVariables.expand(environment, simple_variables) if environment
end
+ def environment_url
+ return @environment_url if defined?(@environment_url)
+
+ @environment_url =
+ if unexpanded_url = options&.dig(:environment, :url)
+ ExpandVariables.expand(unexpanded_url, simple_variables)
+ else
+ persisted_environment&.external_url
+ end
+ end
+
def has_environment?
environment.present?
end
@@ -198,20 +209,23 @@ module Ci
# All variables, including those dependent on other variables
def variables
- variables = simple_variables
- variables += persisted_environment.predefined_variables if persisted_environment.present?
- variables
+ simple_variables.concat(persisted_environment_variables)
end
def merge_request
- merge_requests = MergeRequest.includes(:merge_request_diff)
- .where(source_branch: ref,
- source_project: pipeline.project)
- .reorder(iid: :asc)
-
- merge_requests.find do |merge_request|
- merge_request.commits_sha.include?(pipeline.sha)
- end
+ return @merge_request if defined?(@merge_request)
+
+ @merge_request ||=
+ begin
+ merge_requests = MergeRequest.includes(:merge_request_diff)
+ .where(source_branch: ref,
+ source_project: pipeline.project)
+ .reorder(iid: :desc)
+
+ merge_requests.find do |merge_request|
+ merge_request.commits_sha.include?(pipeline.sha)
+ end
+ end
end
def repo_url
@@ -335,7 +349,7 @@ module Ci
end
def has_expiring_artifacts?
- artifacts_expire_at.present?
+ artifacts_expire_at.present? && artifacts_expire_at > Time.now
end
def keep_artifacts!
@@ -462,6 +476,18 @@ module Ci
variables.concat(legacy_variables)
end
+ def persisted_environment_variables
+ return [] unless persisted_environment
+
+ variables = persisted_environment.predefined_variables
+
+ if url = environment_url
+ variables << { key: 'CI_ENVIRONMENT_URL', value: url, public: true }
+ end
+
+ variables
+ end
+
def legacy_variables
variables = [
{ key: 'CI_BUILD_ID', value: id.to_s, public: true },
@@ -491,5 +517,11 @@ module Ci
ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size])
end
+
+ def update_project_statistics_after_save
+ if previous_changes.include?('artifacts_size')
+ update_project_statistics
+ end
+ end
end
end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
new file mode 100644
index 00000000000..9b536af672b
--- /dev/null
+++ b/app/models/ci/legacy_stage.rb
@@ -0,0 +1,64 @@
+module Ci
+ # Currently this is artificial object, constructed dynamically
+ # We should migrate this object to actual database record in the future
+ class LegacyStage
+ include StaticModel
+
+ attr_reader :pipeline, :name
+
+ delegate :project, to: :pipeline
+
+ def initialize(pipeline, name:, status: nil, warnings: nil)
+ @pipeline = pipeline
+ @name = name
+ @status = status
+ @warnings = warnings
+ end
+
+ def groups
+ @groups ||= statuses.ordered.latest
+ .sort_by(&:sortable_name).group_by(&:group_name)
+ .map do |group_name, grouped_statuses|
+ Ci::Group.new(self, name: group_name, jobs: grouped_statuses)
+ end
+ end
+
+ def to_param
+ name
+ end
+
+ def statuses_count
+ @statuses_count ||= statuses.count
+ end
+
+ def status
+ @status ||= statuses.latest.status
+ end
+
+ def detailed_status(current_user)
+ Gitlab::Ci::Status::Stage::Factory
+ .new(self, current_user)
+ .fabricate!
+ end
+
+ def statuses
+ @statuses ||= pipeline.statuses.where(stage: name)
+ end
+
+ def builds
+ @builds ||= pipeline.builds.where(stage: name)
+ end
+
+ def success?
+ status.to_s == 'success'
+ end
+
+ def has_warnings?
+ if @warnings.is_a?(Integer)
+ @warnings > 0
+ else
+ statuses.latest.failed_but_allowed.any?
+ end
+ end
+ end
+end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 425ca9278eb..9ddecba5183 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -11,9 +11,7 @@ module Ci
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
- has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
- has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
-
+ has_many :stages
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
has_many :builds, foreign_key: :commit_id
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id
@@ -25,8 +23,11 @@ module Ci
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus'
- has_many :manual_actions, -> { latest.manual_actions }, foreign_key: :commit_id, class_name: 'Ci::Build'
- has_many :artifacts, -> { latest.with_artifacts_not_expired }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :artifacts, -> { latest.with_artifacts_not_expired.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
+
+ has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
+ has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
delegate :id, to: :project, prefix: true
@@ -162,21 +163,21 @@ module Ci
where.not(duration: nil).sum(:duration)
end
- def stage(name)
- stage = Ci::Stage.new(self, name: name)
- stage unless stage.statuses_count.zero?
- end
-
def stages_count
statuses.select(:stage).distinct.count
end
- def stages_name
+ def stages_names
statuses.order(:stage_idx).distinct.
pluck(:stage, :stage_idx).map(&:first)
end
- def stages
+ def legacy_stage(name)
+ stage = Ci::LegacyStage.new(self, name: name)
+ stage unless stage.statuses_count.zero?
+ end
+
+ def legacy_stages
# TODO, this needs refactoring, see gitlab-ce#26481.
stages_query = statuses
@@ -191,7 +192,7 @@ module Ci
.pluck('sg.stage', status_sql, "(#{warnings_sql})")
stages_with_statuses.map do |stage|
- Ci::Stage.new(self, Hash[%i[name status warnings].zip(stage)])
+ Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)])
end
end
@@ -291,12 +292,14 @@ module Ci
end
end
- def config_builds_attributes
+ def stage_seeds
return [] unless config_processor
- config_processor.
- builds_for_ref(ref, tag?, trigger_requests.first).
- sort_by { |build| build[:stage_idx] }
+ @stage_seeds ||= config_processor.stage_seeds(self)
+ end
+
+ def has_stage_seeds?
+ stage_seeds.any?
end
def has_warnings?
@@ -304,7 +307,7 @@ module Ci
end
def config_processor
- return nil unless ci_yaml_file
+ return unless ci_yaml_file
return @config_processor if defined?(@config_processor)
@config_processor ||= begin
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 9bda3186c30..59570924c8d 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -1,64 +1,11 @@
module Ci
- # Currently this is artificial object, constructed dynamically
- # We should migrate this object to actual database record in the future
- class Stage
- include StaticModel
+ class Stage < ActiveRecord::Base
+ extend Ci::Model
- attr_reader :pipeline, :name
+ belongs_to :project
+ belongs_to :pipeline
- delegate :project, to: :pipeline
-
- def initialize(pipeline, name:, status: nil, warnings: nil)
- @pipeline = pipeline
- @name = name
- @status = status
- @warnings = warnings
- end
-
- def groups
- @groups ||= statuses.ordered.latest
- .sort_by(&:sortable_name).group_by(&:group_name)
- .map do |group_name, grouped_statuses|
- Ci::Group.new(self, name: group_name, jobs: grouped_statuses)
- end
- end
-
- def to_param
- name
- end
-
- def statuses_count
- @statuses_count ||= statuses.count
- end
-
- def status
- @status ||= statuses.latest.status
- end
-
- def detailed_status(current_user)
- Gitlab::Ci::Status::Stage::Factory
- .new(self, current_user)
- .fabricate!
- end
-
- def statuses
- @statuses ||= pipeline.statuses.where(stage: name)
- end
-
- def builds
- @builds ||= pipeline.builds.where(stage: name)
- end
-
- def success?
- status.to_s == 'success'
- end
-
- def has_warnings?
- if @warnings.is_a?(Integer)
- @warnings > 0
- else
- statuses.latest.failed_but_allowed.any?
- end
- end
+ has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
+ has_many :builds, foreign_key: :commit_id
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index dbc0a22829e..20206d57c4c 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -14,7 +14,7 @@ class Commit
participant :committer
participant :notes_with_associations
- attr_accessor :project
+ attr_accessor :project, :author
DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
@@ -114,16 +114,16 @@ class Commit
#
# Usually, the commit title is the first line of the commit message.
# In case this first line is longer than 100 characters, it is cut off
- # after 80 characters and ellipses (`&hellp;`) are appended.
+ # after 80 characters + `...`
def title
- full_title.length > 100 ? full_title[0..79] << "…" : full_title
+ return full_title if full_title.length < 100
+
+ full_title.truncate(81, separator: ' ', omission: '…')
end
# Returns the full commits title
def full_title
- return @full_title if @full_title
-
- @full_title =
+ @full_title ||=
if safe_message.blank?
no_commit_message
else
@@ -131,19 +131,14 @@ class Commit
end
end
- # Returns the commits description
- #
- # cut off, ellipses (`&hellp;`) are prepended to the commit message.
+ # Returns full commit message if title is truncated (greater than 99 characters)
+ # otherwise returns commit message without first line
def description
- title_end = safe_message.index("\n")
- @description ||=
- if (!title_end && safe_message.length > 100) || (title_end && title_end > 100)
- "…" << safe_message[80..-1]
- else
- safe_message.split("\n", 2)[1].try(:chomp)
- end
- end
+ return safe_message if full_title.length >= 100
+ safe_message.split("\n", 2)[1].try(:chomp)
+ end
+
def description?
description.present?
end
@@ -177,7 +172,7 @@ class Commit
if RequestStore.active?
key = "commit_author:#{author_email.downcase}"
# nil is a valid value since no author may exist in the system
- if RequestStore.store.has_key?(key)
+ if RequestStore.store.key?(key)
@author = RequestStore.store[key]
else
@author = find_author_by_any_email
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index fe63728ea23..55c16f7e1fd 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -5,10 +5,10 @@ class CommitStatus < ActiveRecord::Base
self.table_name = 'ci_builds'
+ belongs_to :user
belongs_to :project
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
- belongs_to :user
delegate :commit, to: :pipeline
delegate :sha, :short_sha, to: :pipeline
@@ -18,7 +18,7 @@ class CommitStatus < ActiveRecord::Base
validates :name, presence: true
alias_attribute :author, :user
-
+
scope :failed_but_allowed, -> do
where(allow_failure: true, status: [:failed, :canceled])
end
@@ -83,14 +83,15 @@ class CommitStatus < ActiveRecord::Base
next if transition.loopback?
commit_status.run_after_commit do
- pipeline.try do |pipeline|
+ if pipeline
if complete? || manual?
PipelineProcessWorker.perform_async(pipeline.id)
else
PipelineUpdateWorker.perform_async(pipeline.id)
end
- ExpireJobCacheWorker.perform_async(commit_status.id)
end
+
+ ExpireJobCacheWorker.perform_async(commit_status.id)
end
end
@@ -126,6 +127,11 @@ class CommitStatus < ActiveRecord::Base
false
end
+ # To be overriden when inherrited from
+ def retryable?
+ false
+ end
+
def stuck?
false
end
diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb
new file mode 100644
index 00000000000..c62c7e1e936
--- /dev/null
+++ b/app/models/concerns/editable.rb
@@ -0,0 +1,7 @@
+module Editable
+ extend ActiveSupport::Concern
+
+ def is_edited?
+ last_edited_at.present? && last_edited_at != created_at
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 075ec575f9d..ea10d004c9c 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -15,6 +15,7 @@ module Issuable
include Taskable
include TimeTrackable
include Importable
+ include Editable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 62eaec2407f..47e71c58557 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -8,32 +8,44 @@ module ProtectedRef
validates :project, presence: true
delegate :matching, :matches?, :wildcard?, to: :ref_matcher
+ end
+
+ def commit
+ project.commit(self.name)
+ end
+
+ class_methods do
+ def protected_ref_access_levels(*types)
+ types.each do |type|
+ has_many :"#{type}_access_levels", dependent: :destroy
+
+ validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }
- def self.protected_ref_accessible_to?(ref, user, action:)
+ accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true
+ end
+ end
+
+ def protected_ref_accessible_to?(ref, user, action:)
access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.check_access(user)
end
end
- def self.developers_can?(action, ref)
+ def developers_can?(action, ref)
access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.access_level == Gitlab::Access::DEVELOPER
end
end
- def self.access_levels_for_ref(ref, action:)
+ def access_levels_for_ref(ref, action:)
self.matching(ref).map(&:"#{action}_access_levels").flatten
end
- def self.matching(ref_name, protected_refs: nil)
+ def matching(ref_name, protected_refs: nil)
ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs)
end
end
- def commit
- project.commit(self.name)
- end
-
private
def ref_matcher
diff --git a/app/models/conversational_development_index/card.rb b/app/models/conversational_development_index/card.rb
new file mode 100644
index 00000000000..e8f09dc9161
--- /dev/null
+++ b/app/models/conversational_development_index/card.rb
@@ -0,0 +1,26 @@
+module ConversationalDevelopmentIndex
+ class Card
+ attr_accessor :metric, :title, :description, :feature, :blog, :docs
+
+ def initialize(metric:, title:, description:, feature:, blog:, docs: nil)
+ self.metric = metric
+ self.title = title
+ self.description = description
+ self.feature = feature
+ self.blog = blog
+ self.docs = docs
+ end
+
+ def instance_score
+ metric.instance_score(feature)
+ end
+
+ def leader_score
+ metric.leader_score(feature)
+ end
+
+ def percentage_score
+ metric.percentage_score(feature)
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/conversational_development_index/idea_to_production_step.rb
new file mode 100644
index 00000000000..6e1753c9f30
--- /dev/null
+++ b/app/models/conversational_development_index/idea_to_production_step.rb
@@ -0,0 +1,19 @@
+module ConversationalDevelopmentIndex
+ class IdeaToProductionStep
+ attr_accessor :metric, :title, :features
+
+ def initialize(metric:, title:, features:)
+ self.metric = metric
+ self.title = title
+ self.features = features
+ end
+
+ def percentage_score
+ sum = features.sum do |feature|
+ metric.percentage_score(feature)
+ end
+
+ sum / features.size.to_f
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb
new file mode 100644
index 00000000000..f42f516f99a
--- /dev/null
+++ b/app/models/conversational_development_index/metric.rb
@@ -0,0 +1,21 @@
+module ConversationalDevelopmentIndex
+ class Metric < ActiveRecord::Base
+ include Presentable
+
+ self.table_name = 'conversational_development_index_metrics'
+
+ def instance_score(feature)
+ self["instance_#{feature}"]
+ end
+
+ def leader_score(feature)
+ self["leader_#{feature}"]
+ end
+
+ def percentage_score(feature)
+ return 100 if leader_score(feature).zero?
+
+ 100 * instance_score(feature) / leader_score(feature)
+ end
+ end
+end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 304179c0a97..85e7901dfee 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -4,7 +4,7 @@ class Deployment < ActiveRecord::Base
belongs_to :project, required: true, validate: true
belongs_to :environment, required: true, validate: true
belongs_to :user
- belongs_to :deployable, polymorphic: true
+ belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :sha, presence: true
validates :ref, presence: true
diff --git a/app/models/event.rb b/app/models/event.rb
index 46e89388bc1..fad6ff03927 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -14,6 +14,30 @@ class Event < ActiveRecord::Base
DESTROYED = 10
EXPIRED = 11 # User left project due to expiry
+ ACTIONS = HashWithIndifferentAccess.new(
+ created: CREATED,
+ updated: UPDATED,
+ closed: CLOSED,
+ reopened: REOPENED,
+ pushed: PUSHED,
+ commented: COMMENTED,
+ merged: MERGED,
+ joined: JOINED,
+ left: LEFT,
+ destroyed: DESTROYED,
+ expired: EXPIRED
+ ).freeze
+
+ TARGET_TYPES = HashWithIndifferentAccess.new(
+ issue: Issue,
+ milestone: Milestone,
+ merge_request: MergeRequest,
+ note: Note,
+ project: Project,
+ snippet: Snippet,
+ user: User
+ ).freeze
+
RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour
delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true
@@ -23,7 +47,7 @@ class Event < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :project
- belongs_to :target, polymorphic: true
+ belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
# For Hash only
serialize :data # rubocop:disable Cop/ActiverecordSerialize
@@ -55,6 +79,14 @@ class Event < ActiveRecord::Base
def limit_recent(limit = 20, offset = nil)
recent.limit(limit).offset(offset)
end
+
+ def actions
+ ACTIONS.keys
+ end
+
+ def target_types
+ TARGET_TYPES.keys
+ end
end
def visible_to_user?(user = nil)
diff --git a/app/models/group.rb b/app/models/group.rb
index be944da5a67..5bb2cdc5eff 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -222,6 +222,16 @@ class Group < Namespace
User.where(id: members_with_parents.select(:user_id))
end
+ def max_member_access_for_user(user)
+ return GroupMember::OWNER if user.admin?
+
+ members_with_parents.
+ where(user_id: user).
+ reorder(access_level: :desc).
+ first&.
+ access_level || GroupMember::NO_ACCESS
+ end
+
def mattermost_team_params
max_length = 59
diff --git a/app/models/issue.rb b/app/models/issue.rb
index a88dbb3e065..693cc21bb40 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -251,9 +251,9 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user]
+ json[:subscribed] = subscribed?(options[:user], project) if options.key?(:user) && options[:user]
- if options.has_key?(:labels)
+ if options.key?(:labels)
json[:labels] = labels.as_json(
project: project,
only: [:id, :title, :description, :color, :priority],
diff --git a/app/models/key.rb b/app/models/key.rb
index b7956052c3f..cb8f10f6d55 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,7 +1,6 @@
require 'digest/md5'
class Key < ActiveRecord::Base
- include AfterCommitQueue
include Sortable
LAST_USED_AT_REFRESH_TIME = 1.day.to_i
@@ -25,10 +24,10 @@ class Key < ActiveRecord::Base
delegate :name, :email, to: :user, prefix: true
- after_create :add_to_shell
- after_create :notify_user
+ after_commit :add_to_shell, on: :create
+ after_commit :notify_user, on: :create
after_create :post_create_hook
- after_destroy :remove_from_shell
+ after_commit :remove_from_shell, on: :destroy
after_destroy :post_destroy_hook
def key=(value)
@@ -93,6 +92,6 @@ class Key < ActiveRecord::Base
end
def notify_user
- run_after_commit { NotificationService.new.new_key(self) }
+ NotificationService.new.new_key(self)
end
end
diff --git a/app/models/label.rb b/app/models/label.rb
index 074239702f8..955d6b4079b 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -172,7 +172,7 @@ class Label < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:priority] = priority(options[:project]) if options.has_key?(:project)
+ json[:priority] = priority(options[:project]) if options.key?(:project)
end
end
diff --git a/app/models/label_link.rb b/app/models/label_link.rb
index 51b5c2b1f4c..d68e1f54317 100644
--- a/app/models/label_link.rb
+++ b/app/models/label_link.rb
@@ -1,7 +1,7 @@
class LabelLink < ActiveRecord::Base
include Importable
- belongs_to :target, polymorphic: true
+ belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :label
validates :target, presence: true, unless: :importing?
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index 007eed5600a..b0625c52b62 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -6,8 +6,7 @@ class LfsObjectsProject < ActiveRecord::Base
validates :lfs_object_id, uniqueness: { scope: [:project_id], message: "already exists in project" }
validates :project_id, presence: true
- after_create :update_project_statistics
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics, on: [:create, :destroy]
private
diff --git a/app/models/list.rb b/app/models/list.rb
index fbd19acd1f5..918275be142 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -2,7 +2,7 @@ class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
- enum list_type: { label: 1, closed: 2 }
+ enum list_type: { backlog: 0, label: 1, closed: 2 }
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
@@ -28,7 +28,7 @@ class List < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- if options.has_key?(:label)
+ if options.key?(:label)
json[:label] = label.as_json(
project: board.project,
only: [:id, :title, :description, :color]
diff --git a/app/models/member.rb b/app/models/member.rb
index 7228e82e978..788a32dd8e3 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -8,7 +8,7 @@ class Member < ActiveRecord::Base
belongs_to :created_by, class_name: "User"
belongs_to :user
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
delegate :name, :username, :email, to: :user, prefix: true
@@ -200,6 +200,10 @@ class Member < ActiveRecord::Base
source_type
end
+ def access_field
+ access_level
+ end
+
def invite?
self.invite_token.present?
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 28e10bc6172..47040f95533 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -25,10 +25,6 @@ class GroupMember < Member
source
end
- def access_field
- access_level
- end
-
# Because source_type is `Namespace`...
def real_source_type
'Group'
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index b3a91feb091..c0e17f4bfc8 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -79,10 +79,6 @@ class ProjectMember < Member
end
end
- def access_field
- access_level
- end
-
def project
source
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index aebee06d560..b48d73dcae7 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -6,6 +6,7 @@ class Namespace < ActiveRecord::Base
include Gitlab::ShellAdapter
include Gitlab::CurrentSettings
include Routable
+ include AfterCommitQueue
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -242,7 +243,9 @@ class Namespace < ActiveRecord::Base
# Remove namespace directroy async with delay so
# GitLab has time to remove all projects first
- GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ run_after_commit do
+ GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 832c68243fb..244bf169c29 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -13,6 +13,7 @@ class Note < ActiveRecord::Base
include AfterCommitQueue
include ResolvableNote
include IgnorableColumn
+ include Editable
ignore_column :original_discussion_id
@@ -40,7 +41,7 @@ class Note < ActiveRecord::Base
participant :author
belongs_to :project
- belongs_to :noteable, polymorphic: true, touch: true
+ belongs_to :noteable, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index e4726e62e93..42412a9a44b 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -4,7 +4,7 @@ class NotificationSetting < ActiveRecord::Base
default_value_for :level, NotificationSetting.levels[:global]
belongs_to :user
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :project, foreign_key: 'source_id'
validates :user, presence: true
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index f2f2fc1e32a..5d798247863 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,7 +1,7 @@
class PagesDomain < ActiveRecord::Base
belongs_to :project
- validates :domain, hostname: true
+ validates :domain, hostname: { allow_numeric_hostname: true }
validates :domain, uniqueness: { case_sensitive: false }
validates :certificate, certificate: true, allow_nil: true, allow_blank: true
validates :key, certificate_key: true, allow_nil: true, allow_blank: true
@@ -98,7 +98,7 @@ class PagesDomain < ActiveRecord::Base
def validate_pages_domain
return unless domain
- if domain.downcase.ends_with?(".#{Settings.pages.host}".downcase)
+ if domain.downcase.ends_with?(Settings.pages.host.downcase)
self.errors.add(:domain, "*.#{Settings.pages.host} is restricted")
end
end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index ae9f71e7747..6e13f9b2089 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -15,11 +15,10 @@ class PersonalAccessToken < ActiveRecord::Base
scope :without_impersonation, -> { where(impersonation: false) }
validates :scopes, presence: true
- validate :validate_api_scopes
+ validate :validate_scopes
def revoke!
- self.revoked = true
- self.save
+ update!(revoked: true)
end
def active?
@@ -28,9 +27,9 @@ class PersonalAccessToken < ActiveRecord::Base
protected
- def validate_api_scopes
- unless scopes.all? { |scope| Gitlab::Auth::API_SCOPES.include?(scope.to_sym) }
- errors.add :scopes, "can only contain API scopes"
+ def validate_scopes
+ unless scopes.all? { |scope| Gitlab::Auth::AVAILABLE_SCOPES.include?(scope.to_sym) }
+ errors.add :scopes, "can only contain available scopes"
end
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 446329557d5..4c394646787 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -63,16 +63,6 @@ class Project < ActiveRecord::Base
# update visibility_level of forks
after_update :update_forks_visibility_level
- def update_forks_visibility_level
- return unless visibility_level < visibility_level_was
-
- forks.each do |forked_project|
- if forked_project.visibility_level > visibility_level
- forked_project.visibility_level = visibility_level
- forked_project.save!
- end
- end
- end
after_validation :check_pending_delete
@@ -165,7 +155,7 @@ class Project < ActiveRecord::Base
has_many :todos, dependent: :destroy
has_many :notification_settings, dependent: :destroy, as: :source
- has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
+ has_one :import_data, dependent: :delete, class_name: 'ProjectImportData'
has_one :project_feature, dependent: :destroy
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
has_many :container_repositories, dependent: :destroy
@@ -298,8 +288,16 @@ class Project < ActiveRecord::Base
scope :excluding_project, ->(project) { where.not(id: project) }
state_machine :import_status, initial: :none do
+ event :import_schedule do
+ transition [:none, :finished, :failed] => :scheduled
+ end
+
+ event :force_import_start do
+ transition [:none, :finished, :failed] => :started
+ end
+
event :import_start do
- transition [:none, :finished] => :started
+ transition scheduled: :started
end
event :import_finish do
@@ -307,18 +305,23 @@ class Project < ActiveRecord::Base
end
event :import_fail do
- transition started: :failed
+ transition [:scheduled, :started] => :failed
end
event :import_retry do
transition failed: :started
end
+ state :scheduled
state :started
state :finished
state :failed
- after_transition any => :finished, do: :reset_cache_and_import_attrs
+ after_transition [:none, :finished, :failed] => :scheduled do |project, _|
+ project.run_after_commit { add_import_job }
+ end
+
+ after_transition started: :finished, do: :reset_cache_and_import_attrs
end
class << self
@@ -471,9 +474,15 @@ class Project < ActiveRecord::Base
end
def reset_cache_and_import_attrs
- ProjectCacheWorker.perform_async(self.id)
+ run_after_commit do
+ ProjectCacheWorker.perform_async(self.id)
+ end
+
+ remove_import_data
+ end
- self.import_data&.destroy
+ def remove_import_data
+ import_data&.destroy
end
def import_url=(value)
@@ -530,9 +539,17 @@ class Project < ActiveRecord::Base
end
def import_in_progress?
+ import_started? || import_scheduled?
+ end
+
+ def import_started?
import? && import_status == 'started'
end
+ def import_scheduled?
+ import_status == 'scheduled'
+ end
+
def import_failed?
import_status == 'failed'
end
@@ -1037,6 +1054,17 @@ class Project < ActiveRecord::Base
!!repository.exists?
end
+ def update_forks_visibility_level
+ return unless visibility_level < visibility_level_was
+
+ forks.each do |forked_project|
+ if forked_project.visibility_level > visibility_level
+ forked_project.visibility_level = visibility_level
+ forked_project.save!
+ end
+ end
+ end
+
def create_wiki
ProjectWiki.new(self, self.owner).wiki
true
@@ -1045,6 +1073,10 @@ class Project < ActiveRecord::Base
false
end
+ def wiki
+ @wiki ||= ProjectWiki.new(self, self.owner)
+ end
+
def jira_tracker_active?
jira_tracker? && jira_service.active
end
@@ -1167,10 +1199,6 @@ class Project < ActiveRecord::Base
end
end
- def wiki
- @wiki ||= ProjectWiki.new(self, self.owner)
- end
-
def running_or_pending_build_count(force: false)
Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do
builds.running_or_pending.count(:all)
@@ -1226,6 +1254,7 @@ class Project < ActiveRecord::Base
{ key: 'CI_PROJECT_ID', value: id.to_s, public: true },
{ key: 'CI_PROJECT_NAME', value: path, public: true },
{ key: 'CI_PROJECT_PATH', value: path_with_namespace, public: true },
+ { key: 'CI_PROJECT_PATH_SLUG', value: path_with_namespace.parameterize, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true },
{ key: 'CI_PROJECT_URL', value: web_url, public: true }
]
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 28b7d5ad072..5f0d0802ac9 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,14 +2,7 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :merge_access_levels, dependent: :destroy
- has_many :push_access_levels, dependent: :destroy
-
- validates :merge_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
- validates :push_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
-
- accepts_nested_attributes_for :push_access_levels
- accepts_nested_attributes_for :merge_access_levels
+ protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
def self.protected?(project, ref_name)
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index 83964095516..f38109c0e52 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :create_access_levels, dependent: :destroy
-
- validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
-
- accepts_nested_attributes_for :create_access_levels
+ protected_ref_access_levels :create
def self.protected?(project, ref_name)
self.matching(ref_name, protected_refs: project.protected_tags).present?
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index 99812bcde53..964175ddab8 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -1,5 +1,5 @@
class RedirectRoute < ActiveRecord::Base
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 07e0b3bae4f..7460515fea8 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -946,6 +946,8 @@ class Repository
end
def is_ancestor?(ancestor_id, descendant_id)
+ return false if ancestor_id.nil? || descendant_id.nil?
+
Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
if is_enabled
raw_repository.is_ancestor?(ancestor_id, descendant_id)
@@ -1102,7 +1104,7 @@ class Repository
blob = blob_at(sha, path)
return unless blob
- blob.load_all_data!(self)
+ blob.load_all_data!
blob.data
end
diff --git a/app/models/route.rb b/app/models/route.rb
index be77b8b51a5..97e8a6ad9e9 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,5 +1,5 @@
class Route < ActiveRecord::Base
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index eed3ca7e179..edde7bedbab 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -2,7 +2,7 @@ class SentNotification < ActiveRecord::Base
serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
belongs_to :project
- belongs_to :noteable, polymorphic: true
+ belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :recipient, class_name: "User"
validates :project, :recipient, presence: true
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 882e2fa0594..54014df43b0 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -8,8 +8,10 @@ class Snippet < ActiveRecord::Base
include Awardable
include Mentionable
include Spammable
+ include Editable
cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description
cache_markdown_field :content
# Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets.
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index dd21ee15c6c..56a115d1db4 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -4,8 +4,7 @@ class SpamLog < ActiveRecord::Base
validates :user, presence: true
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def text
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 17869c8bac2..2f0c9640744 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -1,7 +1,7 @@
class Subscription < ActiveRecord::Base
belongs_to :user
belongs_to :project
- belongs_to :subscribable, polymorphic: true
+ belongs_to :subscribable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user, :subscribable, presence: true
diff --git a/app/models/todo.rb b/app/models/todo.rb
index b011001b235..696d139af74 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
- belongs_to :target, polymorphic: true, touch: true
+ belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 13987931b05..f194d7bdb80 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -2,7 +2,7 @@ class Upload < ActiveRecord::Base
# Upper limit for foreground checksum processing
CHECKSUM_THRESHOLD = 100.megabytes
- belongs_to :model, polymorphic: true
+ belongs_to :model, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :size, presence: true
validates :path, presence: true
diff --git a/app/models/user.rb b/app/models/user.rb
index e6eb9d09656..5d128e4b390 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -66,7 +66,7 @@ class User < ActiveRecord::Base
#
# Namespace for personal projects
- has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id
+ has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id, autosave: true
# Profile
has_many :keys, -> do
@@ -809,6 +809,11 @@ class User < ActiveRecord::Base
system_hook_service.execute_hooks_for(self, :destroy)
end
+ def delete_async(deleted_by:, params: {})
+ block if params[:hard_delete]
+ DeleteUserWorker.perform_async(deleted_by.id, id, params)
+ end
+
def notification_service
NotificationService.new
end
diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb
index 0949c6ef083..2d05fdd3e54 100644
--- a/app/models/user_agent_detail.rb
+++ b/app/models/user_agent_detail.rb
@@ -1,5 +1,5 @@
class UserAgentDetail < ActiveRecord::Base
- belongs_to :subject, polymorphic: true
+ belongs_to :subject, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true
diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb
new file mode 100644
index 00000000000..ebab213e6be
--- /dev/null
+++ b/app/policies/deploy_key_policy.rb
@@ -0,0 +1,11 @@
+class DeployKeyPolicy < BasePolicy
+ def rules
+ return unless @user
+
+ can! :update_deploy_key if @user.admin?
+
+ if @subject.private? && @user.project_deploy_keys.exists?(id: @subject.id)
+ can! :update_deploy_key
+ end
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 87398303c68..fb07298c6c2 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -4,22 +4,25 @@ class GroupPolicy < BasePolicy
return unless @user
globally_viewable = @subject.public? || (@subject.internal? && !@user.external?)
- member = @subject.users_with_parents.include?(@user)
- owner = @user.admin? || @subject.has_owner?(@user)
- master = owner || @subject.has_master?(@user)
+ access_level = @subject.max_member_access_for_user(@user)
+ owner = access_level >= GroupMember::OWNER
+ master = access_level >= GroupMember::MASTER
+ reporter = access_level >= GroupMember::REPORTER
can_read = false
can_read ||= globally_viewable
- can_read ||= member
- can_read ||= @user.admin?
+ can_read ||= access_level >= GroupMember::GUEST
can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
can! :read_group if can_read
+ if reporter
+ can! :admin_label
+ end
+
# Only group masters and group owners can create new projects
if master
can! :create_projects
can! :admin_milestones
- can! :admin_label
end
# Only group owner and administrators can admin group
@@ -31,7 +34,7 @@ class GroupPolicy < BasePolicy
can! :create_subgroup if @user.can_create_group
end
- if globally_viewable && @subject.request_access_enabled && !member
+ if globally_viewable && @subject.request_access_enabled && access_level == GroupMember::NO_ACCESS
can! :request_access
end
end
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index cf8ff92617f..bc5c4f32f79 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -1,5 +1,10 @@
class ProjectSnippetPolicy < BasePolicy
def rules
+ # We have to check both project feature visibility and a snippet visibility and take the stricter one
+ # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573
+ return unless @subject.project.feature_available?(:snippets, @user)
+ return unless Ability.allowed?(@user, :read_project, @subject.project)
+
can! :read_project_snippet if @subject.public?
return unless @user
diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/conversational_development_index/metric_presenter.rb
new file mode 100644
index 00000000000..bb65ba2646b
--- /dev/null
+++ b/app/presenters/conversational_development_index/metric_presenter.rb
@@ -0,0 +1,144 @@
+module ConversationalDevelopmentIndex
+ class MetricPresenter < Gitlab::View::Presenter::Simple
+ def cards
+ [
+ Card.new(
+ metric: subject,
+ title: 'Issues',
+ description: 'created per active user',
+ feature: 'issues',
+ blog: 'https://www2.deloitte.com/content/dam/Deloitte/se/Documents/technology-media-telecommunications/deloitte-digital-collaboration.pdf'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Comments',
+ description: 'created per active user',
+ feature: 'notes',
+ blog: 'http://conversationaldevelopment.com/why/'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Milestones',
+ description: 'created per active user',
+ feature: 'milestones',
+ blog: 'http://conversationaldevelopment.com/shorten-cycle/',
+ docs: help_page_path('user/project/milestones/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Boards',
+ description: 'created per active user',
+ feature: 'boards',
+ blog: 'http://jpattonassociates.com/user-story-mapping/',
+ docs: help_page_path('user/project/issue_board')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Merge Requests',
+ description: 'per active user',
+ feature: 'merge_requests',
+ blog: 'https://8thlight.com/blog/uncle-bob/2013/02/01/The-Humble-Craftsman.html',
+ docs: help_page_path('user/project/merge_requests/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Pipelines',
+ description: 'created per active user',
+ feature: 'ci_pipelines',
+ blog: 'https://martinfowler.com/bliki/ContinuousDelivery.html',
+ docs: help_page_path('ci/README')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Environments',
+ description: 'created per active user',
+ feature: 'environments',
+ blog: 'https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/',
+ docs: help_page_path('ci/environments')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Deployments',
+ description: 'created per active user',
+ feature: 'deployments',
+ blog: 'https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Monitoring',
+ description: 'fraction of all projects',
+ feature: 'projects_prometheus_active',
+ blog: 'https://prometheus.io/docs/introduction/overview/',
+ docs: help_page_path('user/project/integrations/prometheus')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Service Desk',
+ description: 'issues created per active user',
+ feature: 'service_desk_issues',
+ blog: 'http://blogs.forrester.com/kate_leggett/17-01-30-top_trends_for_customer_service_in_2017_operations_become_smarter_and_more_strategic',
+ docs: 'https://docs.gitlab.com/ee/user/project/service_desk.html'
+ )
+ ]
+ end
+
+ def idea_to_production_steps
+ [
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Idea',
+ features: %w(issues)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Issue',
+ features: %w(issues notes)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Plan',
+ features: %w(milestones boards)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Code',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Commit',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Test',
+ features: %w(ci_pipelines)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Review',
+ features: %w(ci_pipelines environments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Staging',
+ features: %w(environments deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Production',
+ features: %w(deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Feedback',
+ features: %w(projects_prometheus_active service_desk_issues)
+ )
+ ]
+ end
+
+ def average_percentage_score
+ cards.sum(&:percentage_score) / cards.size.to_f
+ end
+ end
+end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 0db9e31031c..8bf35953d29 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -110,12 +110,24 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def closing_issues_links
- markdown issues_sentence(project, closing_issues), pipeline: :gfm, author: author, project: project
+ markdown(
+ issues_sentence(project, closing_issues),
+ pipeline: :gfm,
+ author: author,
+ project: project,
+ issuable_state_filter_enabled: true
+ )
end
def mentioned_issues_links
mentioned_issues = issues_mentioned_but_not_closing(current_user)
- markdown issues_sentence(project, mentioned_issues), pipeline: :gfm, author: author, project: project
+ markdown(
+ issues_sentence(project, mentioned_issues),
+ pipeline: :gfm,
+ author: author,
+ project: project,
+ issuable_state_filter_enabled: true
+ )
end
def assign_to_closing_issues_link
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 070b0c35e36..229311eb6ee 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -11,7 +11,7 @@ module Projects
end
def enabled_keys
- @enabled_keys ||= project.deploy_keys
+ @enabled_keys ||= project.deploy_keys.includes(:projects)
end
def any_keys_enabled?
@@ -23,11 +23,7 @@ module Projects
end
def available_project_keys
- @available_project_keys ||= current_user.project_deploy_keys - enabled_keys
- end
-
- def any_available_project_keys_enabled?
- available_project_keys.any?
+ @available_project_keys ||= current_user.project_deploy_keys.includes(:projects) - enabled_keys
end
def key_available?(deploy_key)
@@ -37,17 +33,13 @@ module Projects
def available_public_keys
return @available_public_keys if defined?(@available_public_keys)
- @available_public_keys ||= DeployKey.are_public - enabled_keys
+ @available_public_keys ||= DeployKey.are_public.includes(:projects) - enabled_keys
# Public keys that are already used by another accessible project are already
# in @available_project_keys.
@available_public_keys -= available_project_keys
end
- def any_available_public_keys_enabled?
- available_public_keys.any?
- end
-
def as_json
serializer = DeployKeySerializer.new
opts = { user: current_user }
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index dde17aa68b8..cb55c98f7c6 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -1,14 +1,39 @@
class BuildArtifactEntity < Grape::Entity
include RequestAwareEntity
- expose :name do |build|
- build.name
+ expose :name do |job|
+ job.name
end
- expose :path do |build|
+ expose :artifacts_expired?, as: :expired
+ expose :artifacts_expire_at, as: :expire_at
+
+ expose :path do |job|
download_namespace_project_job_artifacts_path(
- build.project.namespace,
- build.project,
- build)
+ project.namespace,
+ project,
+ job)
+ end
+
+ expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job|
+ keep_namespace_project_job_artifacts_path(
+ project.namespace,
+ project,
+ job)
+ end
+
+ expose :browse_path do |job|
+ browse_namespace_project_job_artifacts_path(
+ project.namespace,
+ project,
+ job)
+ end
+
+ private
+
+ alias_method :job, :object
+
+ def project
+ job.project
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
new file mode 100644
index 00000000000..0063920e603
--- /dev/null
+++ b/app/serializers/build_details_entity.rb
@@ -0,0 +1,50 @@
+class BuildDetailsEntity < BuildEntity
+ expose :coverage, :erased_at, :duration
+ expose :tag_list, as: :tags
+
+ expose :user, using: UserEntity
+
+ expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity
+ expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :update_build, project) } do |build|
+ erase_namespace_project_job_path(project.namespace, project, build)
+ end
+
+ expose :artifacts, using: BuildArtifactEntity
+ expose :runner, using: RunnerEntity
+ expose :pipeline, using: PipelineEntity
+
+ expose :merge_request, if: -> (*) { can?(current_user, :read_merge_request, build.merge_request) } do
+ expose :iid do |build|
+ build.merge_request.iid
+ end
+
+ expose :path do |build|
+ namespace_project_merge_request_path(project.namespace, project, build.merge_request)
+ end
+ end
+
+ expose :new_issue_path, if: -> (*) { can?(request.current_user, :create_issue, project) && build.failed? } do |build|
+ new_namespace_project_issue_path(project.namespace, project, issue: build_failed_issue_options)
+ end
+
+ expose :raw_path do |build|
+ raw_namespace_project_build_path(project.namespace, project, build)
+ end
+
+ private
+
+ def build_failed_issue_options
+ {
+ title: "Build Failed ##{build.id}",
+ description: namespace_project_job_url(project.namespace, project, build)
+ }
+ end
+
+ def current_user
+ request.current_user
+ end
+
+ def project
+ build.project
+ end
+end
diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb
index 05dd8270e92..c01efa9dd5c 100644
--- a/app/serializers/build_entity.rb
+++ b/app/serializers/build_entity.rb
@@ -8,7 +8,7 @@ class BuildEntity < Grape::Entity
path_to(:namespace_project_job, build)
end
- expose :retry_path do |build|
+ expose :retry_path, if: -> (*) { build&.retryable? } do |build|
path_to(:retry_namespace_project_job, build)
end
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index d75a83d0fa5..068013c8829 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -11,4 +11,11 @@ class DeployKeyEntity < Grape::Entity
expose :projects, using: ProjectEntity do |deploy_key|
deploy_key.projects.select { |project| options[:user].can?(:read_project, project) }
end
+ expose :can_edit
+
+ private
+
+ def can_edit
+ options[:user].can?(:update_deploy_key, object)
+ end
end
diff --git a/app/serializers/group_entity.rb b/app/serializers/group_entity.rb
new file mode 100644
index 00000000000..4f506f7e745
--- /dev/null
+++ b/app/serializers/group_entity.rb
@@ -0,0 +1,46 @@
+class GroupEntity < Grape::Entity
+ include ActionView::Helpers::NumberHelper
+ include RequestAwareEntity
+ include MembersHelper
+ include GroupsHelper
+
+ expose :id, :name, :path, :description, :visibility
+ expose :web_url
+ expose :full_name, :full_path
+ expose :parent_id
+ expose :created_at, :updated_at
+
+ expose :permissions do
+ expose :human_group_access do |group, options|
+ group.group_members.find_by(user_id: request.current_user)&.human_access
+ end
+ end
+
+ expose :edit_path do |group|
+ edit_group_path(group)
+ end
+
+ expose :leave_path do |group|
+ leave_group_group_members_path(group)
+ end
+
+ expose :can_edit do |group|
+ can?(request.current_user, :admin_group, group)
+ end
+
+ expose :has_subgroups do |group|
+ GroupsFinder.new(request.current_user, parent: group).execute.any?
+ end
+
+ expose :number_projects_with_delimiter do |group|
+ number_with_delimiter(GroupProjectsFinder.new(group: group, current_user: request.current_user).execute.count)
+ end
+
+ expose :number_users_with_delimiter do |group|
+ number_with_delimiter(group.users.count)
+ end
+
+ expose :avatar_url do |group|
+ group_icon(group)
+ end
+end
diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb
new file mode 100644
index 00000000000..26e8566828b
--- /dev/null
+++ b/app/serializers/group_serializer.rb
@@ -0,0 +1,19 @@
+class GroupSerializer < BaseSerializer
+ entity GroupEntity
+
+ def with_pagination(request, response)
+ tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
+ end
+
+ def paginated?
+ @paginator.present?
+ end
+
+ def represent(resource, opts = {})
+ if paginated?
+ super(@paginator.paginate(resource), opts)
+ else
+ super(resource, opts)
+ end
+ end
+end
diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
index f7eb75395b5..7bb981041cc 100644
--- a/app/serializers/merge_request_entity.rb
+++ b/app/serializers/merge_request_entity.rb
@@ -29,7 +29,7 @@ class MergeRequestEntity < IssuableEntity
expose :merge_commit_sha
expose :merge_commit_message
- expose :head_pipeline, with: PipelineEntity, as: :pipeline
+ expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline
# Booleans
expose :work_in_progress?, as: :work_in_progress
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
new file mode 100644
index 00000000000..130968a44c1
--- /dev/null
+++ b/app/serializers/pipeline_details_entity.rb
@@ -0,0 +1,7 @@
+class PipelineDetailsEntity < PipelineEntity
+ expose :details do
+ expose :legacy_stages, as: :stages, using: StageEntity
+ expose :artifacts, using: BuildArtifactEntity
+ expose :manual_actions, using: BuildActionEntity
+ end
+end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 486f8c36fbd..6d1fd9d459f 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -7,6 +7,8 @@ class PipelineEntity < Grape::Entity
expose :coverage
expose :source
+ expose :created_at, :updated_at
+
expose :path do |pipeline|
namespace_project_pipeline_path(
pipeline.project.namespace,
@@ -14,15 +16,6 @@ class PipelineEntity < Grape::Entity
pipeline)
end
- expose :details do
- expose :detailed_status, as: :status, with: StatusEntity
- expose :duration
- expose :finished_at
- expose :stages, using: StageEntity
- expose :artifacts, using: BuildArtifactEntity
- expose :manual_actions, using: BuildActionEntity
- end
-
expose :flags do
expose :latest?, as: :latest
expose :stuck?, as: :stuck
@@ -31,6 +24,12 @@ class PipelineEntity < Grape::Entity
expose :can_cancel?, as: :cancelable
end
+ expose :details do
+ expose :detailed_status, as: :status, with: StatusEntity
+ expose :duration
+ expose :finished_at
+ end
+
expose :ref do
expose :name do |pipeline|
pipeline.ref
@@ -47,7 +46,6 @@ class PipelineEntity < Grape::Entity
end
expose :commit, using: CommitEntity
- expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
expose :retry_path, if: -> (*) { can_retry? } do |pipeline|
retry_namespace_project_pipeline_path(pipeline.project.namespace,
@@ -61,7 +59,7 @@ class PipelineEntity < Grape::Entity
pipeline.id)
end
- expose :created_at, :updated_at
+ expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
private
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index e37af63774c..661bf17983c 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,7 +1,7 @@
class PipelineSerializer < BaseSerializer
InvalidResourceError = Class.new(StandardError)
- entity PipelineEntity
+ entity PipelineDetailsEntity
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
@@ -13,14 +13,15 @@ class PipelineSerializer < BaseSerializer
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
+
resource = resource.preload([
:retryable_builds,
:cancelable_statuses,
:trigger_requests,
:project,
- { pending_builds: :project },
- { manual_actions: :project },
- { artifacts: :project }
+ :manual_actions,
+ :artifacts,
+ { pending_builds: :project }
])
end
diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb
new file mode 100644
index 00000000000..ed7dacc2dbd
--- /dev/null
+++ b/app/serializers/runner_entity.rb
@@ -0,0 +1,18 @@
+class RunnerEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :description
+
+ expose :edit_path,
+ if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner|
+ edit_namespace_project_runner_path(project.namespace, project, runner)
+ end
+
+ private
+
+ alias_method :runner, :object
+
+ def project
+ request.project
+ end
+end
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
index fd9ff115eab..68f6a8619e5 100644
--- a/app/services/boards/create_service.rb
+++ b/app/services/boards/create_service.rb
@@ -12,6 +12,7 @@ module Boards
def create_board!
board = project.boards.create
+ board.lists.create(list_type: :backlog)
board.lists.create(list_type: :closed)
board
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 533e6787855..418fa9afd6e 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -3,7 +3,7 @@ module Boards
class ListService < BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
- issues = without_board_labels(issues) unless list
+ issues = without_board_labels(issues) unless movable_list?
issues = with_list_label(issues) if movable_list?
issues.order_by_position_and_priority
end
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index c579ed4c869..df2a01a69e5 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -2,6 +2,8 @@ module Boards
module Lists
class ListService < BaseService
def execute(board)
+ board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
+
board.lists
end
end
diff --git a/app/services/ci/create_pipeline_builds_service.rb b/app/services/ci/create_pipeline_builds_service.rb
deleted file mode 100644
index 70fb2c5e38f..00000000000
--- a/app/services/ci/create_pipeline_builds_service.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-module Ci
- class CreatePipelineBuildsService < BaseService
- attr_reader :pipeline
-
- def execute(pipeline)
- @pipeline = pipeline
-
- new_builds.map do |build_attributes|
- create_build(build_attributes)
- end
- end
-
- delegate :project, to: :pipeline
-
- private
-
- def create_build(build_attributes)
- build_attributes = build_attributes.merge(
- pipeline: pipeline,
- project: project,
- ref: pipeline.ref,
- tag: pipeline.tag,
- user: current_user,
- trigger_request: trigger_request
- )
- build = pipeline.builds.create(build_attributes)
-
- # Create the environment before the build starts. This sets its slug and
- # makes it available as an environment variable
- project.environments.find_or_create_by(name: build.expanded_environment_name) if
- build.has_environment?
-
- build
- end
-
- def new_builds
- @new_builds ||= pipeline.config_builds_attributes.
- reject { |build| existing_build_names.include?(build[:name]) }
- end
-
- def existing_build_names
- @existing_build_names ||= pipeline.builds.pluck(:name)
- end
-
- def trigger_request
- return @trigger_request if defined?(@trigger_request)
-
- @trigger_request ||= pipeline.trigger_requests.first
- end
- end
-end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 13baa63220d..769749c9925 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -43,20 +43,22 @@ module Ci
return pipeline
end
- unless pipeline.config_builds_attributes.present?
- return error('No builds for this pipeline.')
+ unless pipeline.has_stage_seeds?
+ return error('No stages / jobs for this pipeline.')
end
Ci::Pipeline.transaction do
update_merge_requests_head_pipeline if pipeline.save
- Ci::CreatePipelineBuildsService
+ Ci::CreatePipelineStagesService
.new(project, current_user)
.execute(pipeline)
end
cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
+ pipeline_created_counter.increment(source: source)
+
pipeline.tap(&:process!)
end
@@ -131,5 +133,9 @@ module Ci
pipeline.drop if save
pipeline
end
+
+ def pipeline_created_counter
+ @pipeline_created_counter ||= Gitlab::Metrics.counter(:pipelines_created_count, "Pipelines created count")
+ end
end
end
diff --git a/app/services/ci/create_pipeline_stages_service.rb b/app/services/ci/create_pipeline_stages_service.rb
new file mode 100644
index 00000000000..f2c175adee6
--- /dev/null
+++ b/app/services/ci/create_pipeline_stages_service.rb
@@ -0,0 +1,20 @@
+module Ci
+ class CreatePipelineStagesService < BaseService
+ def execute(pipeline)
+ pipeline.stage_seeds.each do |seed|
+ seed.user = current_user
+
+ seed.create! do |build|
+ ##
+ # Create the environment before the build starts. This sets its slug and
+ # makes it available as an environment variable
+ #
+ if build.has_environment?
+ environment_name = build.expanded_environment_name
+ project.environments.find_or_create_by(name: environment_name)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index f51e9fd1d54..6372e5755db 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -1,7 +1,7 @@
module Ci
class RetryBuildService < ::BaseService
CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
- allow_failure stage stage_idx trigger_request
+ allow_failure stage_id stage stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list].freeze
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index ab4c02a97a0..a5ae4927412 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -17,18 +17,18 @@ class CompareService
start_branch_name) do |commit|
break unless commit
- compare(commit.sha, target_project, target_branch, straight)
+ compare(commit.sha, target_project, target_branch, straight: straight)
end
end
private
- def compare(source_sha, target_project, target_branch, straight)
+ def compare(source_sha, target_project, target_branch, straight:)
raw_compare = Gitlab::Git::Compare.new(
target_project.repository.raw_repository,
target_branch,
source_sha,
- straight
+ straight: straight
)
Compare.new(raw_compare, target_project, straight: straight)
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 47f9b2c621c..46823418bb0 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -1,71 +1,59 @@
-class CreateDeploymentService < BaseService
- def execute(deployable = nil)
+class CreateDeploymentService
+ attr_reader :job
+
+ delegate :expanded_environment_name,
+ :environment_url,
+ :project,
+ to: :job
+
+ def initialize(job)
+ @job = job
+ end
+
+ def execute
return unless executable?
ActiveRecord::Base.transaction do
- @deployable = deployable
+ environment.external_url = environment_url if environment_url
+ environment.fire_state_event(action)
- @environment = environment
- @environment.external_url = expanded_url if expanded_url
- @environment.fire_state_event(action)
+ return unless environment.save
+ return if environment.stopped?
- return unless @environment.save
- return if @environment.stopped?
-
- deploy.tap do |deployment|
- deployment.update_merge_request_metrics!
- end
+ deploy.tap(&:update_merge_request_metrics!)
end
end
private
def executable?
- project && name.present?
+ project && job.environment.present? && environment
end
def deploy
project.deployments.create(
- environment: @environment,
- ref: params[:ref],
- tag: params[:tag],
- sha: params[:sha],
- user: current_user,
- deployable: @deployable,
- on_stop: options[:on_stop])
+ environment: environment,
+ ref: job.ref,
+ tag: job.tag,
+ sha: job.sha,
+ user: job.user,
+ deployable: job,
+ on_stop: on_stop)
end
def environment
- @environment ||= project.environments.find_or_create_by(name: expanded_name)
- end
-
- def expanded_name
- ExpandVariables.expand(name, variables)
- end
-
- def expanded_url
- return unless url
-
- @expanded_url ||= ExpandVariables.expand(url, variables)
- end
-
- def name
- params[:environment]
- end
-
- def url
- options[:url]
+ @environment ||= job.persisted_environment
end
- def options
- params[:options] || {}
+ def environment_options
+ @environment_options ||= job.options&.dig(:environment) || {}
end
- def variables
- params[:variables] || []
+ def on_stop
+ environment_options[:on_stop]
end
def action
- options[:action] || 'start'
+ environment_options[:action] || 'start'
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index e94ab3e64db..e77a3e3eac1 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -148,7 +148,7 @@ class IssuableBaseService < BaseService
execute(params[:description], issuable)
# Avoid a description already set on an issuable to be overwritten by a nil
- params[:description] = description if params.has_key?(:description)
+ params[:description] = description if params.key?(:description)
params.merge!(command_params)
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 3a58f6c065d..26906ae7167 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -1,22 +1,38 @@
module Members
class CreateService < BaseService
+ DEFAULT_LIMIT = 100
+
def initialize(source, current_user, params = {})
@source = source
@current_user = current_user
@params = params
+ @error = nil
end
def execute
- return false if params[:user_ids].blank?
+ return error('No users specified.') if params[:user_ids].blank?
+
+ user_ids = params[:user_ids].split(',').uniq
+
+ return error("Too many users specified (limit is #{user_limit})") if
+ user_limit && user_ids.size > user_limit
@source.add_users(
- params[:user_ids].split(','),
+ user_ids,
params[:access_level],
expires_at: params[:expires_at],
current_user: current_user
)
- true
+ success
+ end
+
+ private
+
+ def user_limit
+ limit = params.fetch(:limit, DEFAULT_LIMIT)
+
+ limit && limit < 0 ? nil : limit
end
end
end
diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb
new file mode 100644
index 00000000000..d726db4e99b
--- /dev/null
+++ b/app/services/metrics_service.rb
@@ -0,0 +1,33 @@
+require 'prometheus/client/formats/text'
+
+class MetricsService
+ CHECKS = [
+ Gitlab::HealthChecks::DbCheck,
+ Gitlab::HealthChecks::RedisCheck,
+ Gitlab::HealthChecks::FsShardsCheck
+ ].freeze
+
+ def prometheus_metrics_text
+ Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path)
+ end
+
+ def health_metrics_text
+ metrics = CHECKS.flat_map(&:metrics)
+
+ formatter.marshal(metrics)
+ end
+
+ def metrics_text
+ "#{health_metrics_text}#{prometheus_metrics_text}"
+ end
+
+ private
+
+ def formatter
+ @formatter ||= Gitlab::HealthChecks::PrometheusTextFormat.new
+ end
+
+ def multiprocess_metrics_path
+ @multiprocess_metrics_path ||= Rails.root.join(ENV['prometheus_multiproc_dir']).freeze
+ end
+end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 535d93385e6..e874a2d8789 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -48,15 +48,14 @@ module Projects
save_project_and_import_data(import_data)
- @project.import_start if @project.import?
-
after_create_actions if @project.persisted?
if @project.errors.empty?
- @project.add_import_job if @project.import?
+ @project.import_schedule if @project.import?
else
fail(error: @project.errors.full_messages.join(', '))
end
+
@project
rescue ActiveRecord::RecordInvalid => e
message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} "
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 06d8d143231..e2b2660ea71 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -7,11 +7,9 @@ module Projects
DELETED_FLAG = '+deleted'.freeze
def async_execute
- project.transaction do
- project.update_attribute(:pending_delete, true)
- job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
- Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
- end
+ project.update_attribute(:pending_delete, true)
+ job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
+ Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
end
def execute
@@ -62,7 +60,11 @@ module Projects
if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path)
log_info("Repository \"#{path}\" moved to \"#{new_path}\"")
- GitlabShellWorker.perform_in(5.minutes, :remove_repository, project.repository_storage_path, new_path)
+
+ project.run_after_commit do
+ # self is now project
+ GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage_path, new_path)
+ end
else
false
end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index a7e13648b54..b6b411d2185 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -92,26 +92,20 @@ module SlashCommands
desc 'Assign'
explanation do |users|
- "Assigns #{users.map(&:to_reference).to_sentence}." if users.any?
+ "Assigns #{users.first.to_reference}." if users.any?
end
params '@user'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
parse_params do |assignee_param|
- users = extract_references(assignee_param, :user)
-
- if users.empty?
- users = User.where(username: assignee_param.split(' ').map(&:strip))
- end
-
- users
+ extract_users(assignee_param)
end
command :assign do |users|
next if users.empty?
if issuable.is_a?(Issue)
- @updates[:assignee_ids] = users.map(&:id)
+ @updates[:assignee_ids] = [users.last.id]
else
@updates[:assignee_id] = users.last.id
end
@@ -459,6 +453,18 @@ module SlashCommands
end
end
+ def extract_users(params)
+ return [] if params.nil?
+
+ users = extract_references(params, :user)
+
+ if users.empty?
+ users = User.where(username: params.split(' ').map(&:strip))
+ end
+
+ users
+ end
+
def find_labels(labels_param)
extract_references(labels_param, :label) |
LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
new file mode 100644
index 00000000000..17857ca62f2
--- /dev/null
+++ b/app/services/submit_usage_ping_service.rb
@@ -0,0 +1,41 @@
+class SubmitUsagePingService
+ URL = 'https://version.gitlab.com/usage_data'.freeze
+
+ include Gitlab::CurrentSettings
+
+ def execute
+ return false unless current_application_settings.usage_ping_enabled?
+
+ response = HTTParty.post(
+ URL,
+ body: Gitlab::UsageData.to_json(force_refresh: true),
+ headers: { 'Content-type' => 'application/json' }
+ )
+
+ store_metrics(response)
+
+ true
+ rescue HTTParty::Error => e
+ Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
+
+ false
+ end
+
+ private
+
+ def store_metrics(response)
+ return unless response['conv_index'].present?
+
+ ConversationalDevelopmentIndex::Metric.create!(
+ response['conv_index'].slice(
+ 'leader_issues', 'instance_issues', 'leader_notes', 'instance_notes',
+ 'leader_milestones', 'instance_milestones', 'leader_boards', 'instance_boards',
+ 'leader_merge_requests', 'instance_merge_requests', 'leader_ci_pipelines',
+ 'instance_ci_pipelines', 'leader_environments', 'instance_environments',
+ 'leader_deployments', 'instance_deployments', 'leader_projects_prometheus_active',
+ 'instance_projects_prometheus_active', 'leader_service_desk_issues',
+ 'instance_service_desk_issues'
+ )
+ )
+ end
+end
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index facf21a7f5c..ab532a1fdcf 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -16,7 +16,7 @@ module Users
def record_activity
Gitlab::UserActivities.record(@author.id)
- Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}")
+ Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})")
end
end
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 9eb6a600f6b..673afb8b5b9 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -6,12 +6,27 @@ module Users
@current_user = current_user
end
+ # Synchronously destroys +user+
+ #
+ # The operation will fail if the user is the sole owner of any groups. To
+ # force the groups to be destroyed, pass `delete_solo_owned_groups: true` in
+ # +options+.
+ #
+ # The user's contributions will be migrated to a global ghost user. To
+ # force the contributions to be destroyed, pass `hard_delete: true` in
+ # +options+.
+ #
+ # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform
+ # a hard deletion without destroying solo-owned groups, pass
+ # `delete_solo_owned_groups: false, hard_delete: true` in +options+.
def execute(user, options = {})
+ delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete])
+
unless Ability.allowed?(current_user, :destroy_user, user)
raise Gitlab::Access::AccessDeniedError, "#{current_user} tried to destroy user #{user}!"
end
- if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present?
+ if !delete_solo_owned_groups && user.solo_owned_groups.present?
user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user'
return user
end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
new file mode 100644
index 00000000000..00c2888d224
--- /dev/null
+++ b/app/uploaders/file_mover.rb
@@ -0,0 +1,63 @@
+class FileMover
+ attr_reader :secret, :file_name, :model, :update_field
+
+ def initialize(file_path, model, update_field = :description)
+ @secret = File.split(File.dirname(file_path)).last
+ @file_name = File.basename(file_path)
+ @model = model
+ @update_field = update_field
+ end
+
+ def execute
+ move
+ uploader.record_upload if update_markdown
+ end
+
+ private
+
+ def move
+ FileUtils.mkdir_p(File.dirname(file_path))
+ FileUtils.move(temp_file_path, file_path)
+ end
+
+ def update_markdown
+ updated_text = model.read_attribute(update_field).gsub(temp_file_uploader.to_markdown, uploader.to_markdown)
+ model.update_attribute(update_field, updated_text)
+
+ true
+ rescue
+ revert
+
+ false
+ end
+
+ def temp_file_path
+ return @temp_file_path if @temp_file_path
+
+ temp_file_uploader.retrieve_from_store!(file_name)
+
+ @temp_file_path = temp_file_uploader.file.path
+ end
+
+ def file_path
+ return @file_path if @file_path
+
+ uploader.retrieve_from_store!(file_name)
+
+ @file_path = uploader.file.path
+ end
+
+ def uploader
+ @uploader ||= PersonalFileUploader.new(model, secret)
+ end
+
+ def temp_file_uploader
+ @temp_file_uploader ||= PersonalFileUploader.new(nil, secret)
+ end
+
+ def revert
+ Rails.logger.warn("Markdown not updated, file move reverted for #{model}")
+
+ FileUtils.move(file_path, temp_file_path)
+ end
+end
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index 7e94218c23d..652277e3b78 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -13,6 +13,13 @@ class FileUploader < GitlabUploader
)
end
+ # Not using `GitlabUploader.base_dir` because all project namespaces are in
+ # the `public/uploads` dir.
+ #
+ def self.base_dir
+ root_dir
+ end
+
# Returns the part of `store_dir` that can change based on the model's current
# path
#
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index 02afddb8c6a..e4e6d6f46b1 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -3,16 +3,28 @@ class GitlabUploader < CarrierWave::Uploader::Base
File.join(CarrierWave.root, upload_record.path)
end
- def self.base_dir
+ def self.root_dir
'uploads'
end
- delegate :base_dir, to: :class
+ # When object storage is used, keep the `root_dir` as `base_dir`.
+ # The files aren't really in folders there, they just have a name.
+ # The files that contain user input in their name, also contain a hash, so
+ # the names are still unique
+ #
+ # This method is overridden in the `FileUploader`
+ def self.base_dir
+ return root_dir unless file_storage?
+
+ File.join(root_dir, 'system')
+ end
- def file_storage?
- storage.is_a?(CarrierWave::Storage::File)
+ def self.file_storage?
+ self.storage == CarrierWave::Storage::File
end
+ delegate :base_dir, :file_storage?, to: :class
+
def file_cache_storage?
cache_storage.is_a?(CarrierWave::Storage::File)
end
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index 95a891111e1..02589959c2f 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -12,4 +12,20 @@ class LfsObjectUploader < GitlabUploader
def filename
model.oid[4..-1]
end
+
+ def work_dir
+ File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work')
+ end
+
+ private
+
+ # To prevent LFS files from moving across filesystems, override the default
+ # implementation:
+ # http://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L181-L183
+ def workfile_path(for_file = original_filename)
+ # To be safe, keep this directory outside of the the cache directory
+ # because calling CarrierWave.clean_cache_files! will remove any files in
+ # the cache directory.
+ File.join(work_dir, @cache_id, version_name.to_s, for_file)
+ end
end
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
index 969b0a20d38..7f857765fbf 100644
--- a/app/uploaders/personal_file_uploader.rb
+++ b/app/uploaders/personal_file_uploader.rb
@@ -10,6 +10,10 @@ class PersonalFileUploader < FileUploader
end
def self.model_path(model)
- File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
+ if model
+ File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
+ else
+ File.join("/#{base_dir}", 'temp')
+ end
end
end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 4c127f29250..feb4f04d7b7 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -6,8 +6,6 @@ module RecordsUploads
before :remove, :destroy_upload
end
- private
-
# After storing an attachment, create a corresponding Upload record
#
# NOTE: We're ignoring the argument passed to this callback because we want
@@ -15,13 +13,16 @@ module RecordsUploads
# `Tempfile` object the callback gets.
#
# Called `after :store`
- def record_upload(_tempfile)
+ def record_upload(_tempfile = nil)
+ return unless model
return unless file_storage?
return unless file.exists?
Upload.record(self)
end
+ private
+
# Before removing an attachment, destroy any Upload records at the same path
#
# Called `before :remove`
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index e1b4e34cd2b..d552704df88 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -232,7 +232,7 @@
= f.number_field :container_registry_token_expire_delay, class: 'form-control'
%fieldset
- %legend Metrics
+ %legend Metrics - Influx
%p
Setup InfluxDB to measure a wide variety of statistics like the time spent
in running SQL queries. These settings require a
@@ -297,6 +297,21 @@
results in fewer but larger UDP packets being sent.
%fieldset
+ %legend Metrics - Prometheus
+ %p
+ Setup Prometheus to measure a variety of statistics that partially overlap and complement Influx based metrics.
+ This setting requires a
+ = link_to 'restart', help_page_path('administration/restart_gitlab')
+ to take effect.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction')
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :prometheus_metrics_enabled do
+ = f.check_box :prometheus_metrics_enabled
+ Enable Prometheus Metrics
+
+ %fieldset
%legend Background Jobs
%p
These settings require a restart to take effect.
diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml
index ac36bb5bb17..e5842bd1ea0 100644
--- a/app/views/admin/background_jobs/show.html.haml
+++ b/app/views/admin/background_jobs/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "Background Jobs"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title Background Jobs
diff --git a/app/views/admin/conversational_development_index/_callout.html.haml b/app/views/admin/conversational_development_index/_callout.html.haml
new file mode 100644
index 00000000000..33a4dab1e00
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_callout.html.haml
@@ -0,0 +1,13 @@
+.prepend-top-default
+.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
+ .bordered-box.landing.content-block
+ %button.btn.btn-default.close.js-close-callout{ type: 'button',
+ 'aria-label' => 'Dismiss ConvDev introduction' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
+ .user-callout-copy
+ %h4
+ Introducing Your Conversational Development Index
+ %p
+ Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.
+ .svg-container.convdev
+ = custom_icon('convdev_overview')
diff --git a/app/views/admin/conversational_development_index/_card.html.haml b/app/views/admin/conversational_development_index/_card.html.haml
new file mode 100644
index 00000000000..6c8688e06ae
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_card.html.haml
@@ -0,0 +1,25 @@
+.convdev-card-wrapper
+ .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" }
+ .convdev-card-title
+ %h3
+ = card.title
+ .text-light
+ = card.description
+ .card-scores
+ .card-score
+ .card-score-value
+ = format_score(card.instance_score)
+ .card-score-name You
+ .card-score
+ .card-score-value
+ = format_score(card.leader_score)
+ .card-score-name Lead
+ .card-score-big
+ = number_to_percentage(card.percentage_score, precision: 1)
+ .card-buttons
+ - if card.blog
+ %a{ href: card.blog }
+ = icon('info-circle', 'aria-hidden' => 'true')
+ - if card.docs
+ %a{ href: card.docs }
+ = icon('question-circle', 'aria-hidden' => 'true')
diff --git a/app/views/admin/conversational_development_index/_disabled.html.haml b/app/views/admin/conversational_development_index/_disabled.html.haml
new file mode 100644
index 00000000000..975d7df3da6
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_disabled.html.haml
@@ -0,0 +1,9 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_index')
+ %h4 Usage ping is not enabled
+ %p
+ ConvDev is only shown when the
+ = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics'), target: '_blank'
+ is enabled. Enable usage ping to get an overview of how you are using GitLab from a feature perspective
+ = link_to 'Enable usage ping', admin_application_settings_path(anchor: 'usage-statistics'), class: 'btn btn-primary'
diff --git a/app/views/admin/conversational_development_index/_no_data.html.haml b/app/views/admin/conversational_development_index/_no_data.html.haml
new file mode 100644
index 00000000000..b23d2b5ec3a
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_no_data.html.haml
@@ -0,0 +1,7 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_data')
+ %h4 Data is still calculating...
+ %p
+ In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.
+ = link_to 'Learn more', help_page_path('user/admin_area/monitoring/convdev'), target: '_blank'
diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml
new file mode 100644
index 00000000000..833d4c612f8
--- /dev/null
+++ b/app/views/admin/conversational_development_index/show.html.haml
@@ -0,0 +1,35 @@
+- @no_container = true
+- page_title 'ConvDev Index'
+
+= render 'admin/monitoring/head'
+
+.container
+ - if show_callout?('convdev_intro_callout_dismissed')
+ = render 'callout'
+
+ .prepend-top-default
+ - if !current_application_settings.usage_ping_enabled
+ = render 'disabled'
+ - elsif @metric.blank?
+ = render 'no_data'
+ - else
+ .convdev
+ .convdev-header
+ %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
+ = number_to_percentage(@metric.average_percentage_score, precision: 1)
+ .convdev-header-subtitle
+ index
+ %br
+ score
+ = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/admin_area/monitoring/convdev')
+
+ .convdev-cards.card-container
+ - @metric.cards.each do |card|
+ = render 'card', card: card
+
+ .convdev-steps.visible-lg
+ - @metric.idea_to_production_steps.each_with_index do |step, index|
+ .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" }
+ = custom_icon("i2p_step_#{index + 1}")
+ %h4.convdev-step-title
+ = step.title
diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml
new file mode 100644
index 00000000000..3a59282e578
--- /dev/null
+++ b/app/views/admin/deploy_keys/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Edit Deploy Key'
+%h3.page-title Edit public deploy key
+%hr
+
+%div
+ = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn-save btn'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 007da8c1d29..92370034baa 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -31,4 +31,6 @@
%span.cgray
added #{time_ago_with_tooltip(deploy_key.created_at)}
%td
- = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key pull-right'
+ .pull-right
+ = link_to 'Edit', edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
+ = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key'
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index a064efc231f..13f5259698f 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -1,31 +1,10 @@
-- page_title "New Deploy Key"
+- page_title 'New Deploy Key'
%h3.page-title New public deploy key
%hr
%div
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f|
- = form_errors(@deploy_key)
-
- .form-group
- = f.label :title, class: "control-label"
- .col-sm-10= f.text_field :title, class: 'form-control'
- .form-group
- = f.label :key, class: "control-label"
- .col-sm-10
- %p.light
- Paste a machine public key here. Read more about how to generate it
- = link_to "here", help_page_path("ssh/README")
- = f.text_area :key, class: "form-control thin_area", rows: 5
- .form-group
- .control-label
- .col-sm-10
- = f.label :can_push do
- = f.check_box :can_push
- %strong Write access allowed
- %p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
-
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Create', class: "btn-create btn"
- = link_to "Cancel", admin_deploy_keys_path, class: "btn btn-cancel"
-
+ = f.submit 'Create', class: 'btn-create btn'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 4deccf4aa93..f16f59623f7 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "Health Check"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
@@ -10,11 +10,10 @@
%p
Access token is
%code#health-check-token= current_application_settings.health_check_access_token
- = button_to reset_health_check_token_admin_application_settings_path,
- method: :put, class: 'btn btn-default',
- data: { confirm: 'Are you sure you want to reset the health check token?' } do
- = icon('spinner')
- Reset health check access token
+ .prepend-top-10
+ = button_to "Reset health check access token", reset_health_check_token_admin_application_settings_path,
+ method: :put, class: 'btn btn-default',
+ data: { confirm: 'Are you sure you want to reset the health check token?' }
%p.light
Health information can be retrieved from the following endpoints. More information is available
= link_to 'here', help_page_path('user/admin_area/monitoring/health_check')
diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml
index 5e585ce789b..487f1cf5c4f 100644
--- a/app/views/admin/logs/show.html.haml
+++ b/app/views/admin/logs/show.html.haml
@@ -3,7 +3,7 @@
- loggers = [Gitlab::GitLogger, Gitlab::AppLogger,
Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger]
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%ul.nav-links.log-tabs
diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/monitoring/_head.html.haml
index b3530915068..901e30275fd 100644
--- a/app/views/admin/background_jobs/_head.html.haml
+++ b/app/views/admin/monitoring/_head.html.haml
@@ -3,6 +3,10 @@
= render 'shared/nav_scroll'
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
+ = nav_link(controller: :conversational_development_index) do
+ = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do
+ %span
+ ConvDev Index
= nav_link(controller: :system_info) do
= link_to admin_system_info_path, title: 'System Info' do
%span
diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml
index c7b63d9de98..b7db18b2d32 100644
--- a/app/views/admin/requests_profiles/index.html.haml
+++ b/app/views/admin/requests_profiles/index.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title 'Requests Profiles'
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index f118804cace..e242e851b4d 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -17,12 +17,10 @@
.pull-left
%p
You can reset runners registration token by pressing a button below.
- %p
- = button_to reset_runners_token_admin_application_settings_path,
+ .prepend-top-10
+ = button_to "Reset runners registration token", reset_runners_token_admin_application_settings_path,
method: :put, class: 'btn btn-default',
- data: { confirm: 'Are you sure you want to reset registration token?' } do
- = icon('spinner')
- Reset runners registration token
+ data: { confirm: 'Are you sure you want to reset registration token?' }
.bs-callout
%p
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 9b9559c7fe5..fd0281e4961 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "System Info"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
.prepend-top-default
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 46d2e3b3de1..4cf4a57ba18 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -34,9 +34,15 @@
- if user.access_locked?
%li
= link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' }
- - if user.can_be_removed? && can?(current_user, :destroy_user, user)
+ - if can?(current_user, :destroy_user, user)
%li.divider
+ - if user.can_be_removed?
+ %li
+ = link_to 'Remove user', admin_user_path(user),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" },
+ method: :delete
%li
- = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" },
- class: 'btn btn-remove btn-block',
- method: :delete
+ = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" },
+ class: 'btn btn-remove btn-block',
+ method: :delete
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 89d0bbb7126..b556ff056c0 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -177,7 +177,7 @@
%p Deleting a user has the following effects:
= render 'users/deletion_guidance', user: @user
%br
- = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
+ = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
- else
- if @user.solo_owned_groups.present?
%p
@@ -188,3 +188,22 @@
- else
%p
You don't have access to delete this user.
+
+ .panel.panel-danger
+ .panel-heading
+ Remove user and contributions
+ .panel-body
+ - if can?(current_user, :destroy_user, @user)
+ %p
+ This option deletes the user and any contributions that
+ would usually be moved to the
+ = succeed "." do
+ = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
+ As well as the user's personal projects, groups owned solely by
+ the user, and projects in them, will also be removed. Commits
+ to other projects are unaffected.
+ %br
+ = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
+ - else
+ %p
+ You don't have access to delete this user.
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 6c3bf1a2b3b..168e6272d8e 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,6 +1,9 @@
.js-groups-list-holder
- %ul.content-list
- - @group_members.each do |group_member|
- = render 'shared/groups/group', group: group_member.group, group_member: group_member
-
- = paginate @group_members, theme: 'gitlab'
+ #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' }
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 73ab2c95ff9..f9b45a539a1 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -2,7 +2,10 @@
- header_title "Groups", dashboard_groups_path
= render 'dashboard/groups_head'
-- if @group_members.empty?
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
+- if @groups.empty?
= render 'empty_state'
- else
= render 'groups'
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 2890ae7173b..5e63a61e21b 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -9,7 +9,7 @@
= render "projects/last_push"
%div{ class: container_class }
- - if show_user_callout?
+ - if show_callout?('user_callout_dismissed')
= render 'shared/user_callout'
- if @projects.any? || params[:name]
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 9e354987401..1ef0d524dbb 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -33,6 +33,7 @@
= webpack_bundle_tag "runtime"
= webpack_bundle_tag "common"
+ = webpack_bundle_tag "locale"
= webpack_bundle_tag "main"
= webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled
= webpack_bundle_tag "test" if Rails.env.test?
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 9db98451f1d..249253f4906 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -36,10 +36,7 @@
%li
= link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
- - if current_user.can_create_project?
- %li
- = link_to new_project_path, title: 'New project', aria: { label: "New project" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('plus fw')
+ = render 'layouts/header/new_dropdown'
- if Gitlab::Sherlock.enabled?
%li
= link_to sherlock_transactions_path, title: 'Sherlock Transactions',
@@ -74,12 +71,12 @@
@#{current_user.username}
%li.divider
%li
- = link_to "Profile", current_user, class: 'profile-link', aria: { label: "Profile" }, data: { user: current_user.username }
+ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
- = link_to "Settings", profile_path, aria: { label: "Settings" }
+ = link_to "Settings", profile_path
%li.divider
%li
- = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link", aria: { label: "Sign out" }
+ = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- else
%li
%div
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
new file mode 100644
index 00000000000..c7302414386
--- /dev/null
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -0,0 +1,45 @@
+%li.header-new.dropdown
+ = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
+ = icon('plus fw')
+ = icon('caret-down')
+ .dropdown-menu-nav.dropdown-menu-align-right
+ %ul
+ - if @group
+ - create_group_project = can?(current_user, :create_projects, @group)
+ - create_group_subgroup = can?(current_user, :create_subgroup, @group)
+ - if create_group_project || create_group_subgroup
+ %li.dropdown-bold-header This group
+ - if create_group_project
+ %li.header-new-group-project
+ = link_to 'New project', new_project_path(namespace_id: @group.id)
+ - if create_group_subgroup
+ %li
+ = link_to 'New subgroup', new_group_path(parent_id: @group.id)
+ %li.divider
+ %li.dropdown-bold-header GitLab
+
+ - if @project && @project.persisted?
+ - create_project_issue = can?(current_user, :create_issue, @project)
+ - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+ - create_project_snippet = can?(current_user, :create_project_snippet, @project)
+ - if create_project_issue || merge_project || create_project_snippet
+ %li.dropdown-bold-header This project
+ - if create_project_issue
+ %li
+ = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project)
+ - if merge_project
+ %li
+ = link_to 'New merge request', new_namespace_project_merge_request_path(merge_project.namespace, merge_project)
+ - if create_project_snippet
+ %li.header-new-project-snippet
+ = link_to 'New snippet', new_namespace_project_snippet_path(@project.namespace, @project)
+ %li.divider
+ %li.dropdown-bold-header GitLab
+ - if current_user.can_create_project?
+ %li
+ = link_to 'New project', new_project_path
+ - if current_user.can_create_group?
+ %li
+ = link_to 'New group', new_group_path
+ %li
+ = link_to 'New snippet', new_snippet_path
diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml
index 86779eeaf15..6df0adfd742 100644
--- a/app/views/layouts/nav/_admin.html.haml
+++ b/app/views/layouts/nav/_admin.html.haml
@@ -9,8 +9,8 @@
= link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
%span
Overview
- = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
- = link_to admin_system_info_path, title: 'Monitoring' do
+ = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do
+ = link_to admin_conversational_development_index_path, title: 'Monitoring' do
%span
Monitoring
= nav_link(controller: :broadcast_messages) do
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 98b75cea03f..57971205e0e 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,9 +1,8 @@
- header_title "Snippets", snippets_path
- content_for :page_specific_javascripts do
- - if @snippet&.persisted? && current_user
+ - if @snippet && current_user
:javascript
- window.uploads_path = "#{upload_path('personal_snippet', @snippet)}";
- window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}";
+ window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
= render template: "layouts/application"
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 4a1438aa68e..fcfd350f0da 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -49,10 +49,10 @@
.form-group
= f.label :email, class: "label-light"
- - if @user.ldap_user? && @user.ldap_email?
+ - if @user.external_email?
= f.text_field :email, class: "form-control", required: true, readonly: true
%span.help-block.light
- Your email address was automatically set based on the LDAP server.
+ Your email address was automatically set based on your #{email_provider_label} account.
- else
- if @user.temp_oauth_email?
= f.text_field :email, class: "form-control", required: true, value: nil
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index 3feb11645a0..c748ccf65e6 100644
--- a/app/views/projects/_find_file_link.html.haml
+++ b/app/views/projects/_find_file_link.html.haml
@@ -1,3 +1,3 @@
= link_to namespace_project_find_file_path(@project.namespace, @project, @ref), class: 'btn btn-grouped shortcuts-find-file', rel: 'nofollow' do
= icon('search')
- %span Find file
+ %span= _('Find file')
diff --git a/app/views/projects/_head.html.haml b/app/views/projects/_head.html.haml
index db08b77c8e0..dba84838a52 100644
--- a/app/views/projects/_head.html.haml
+++ b/app/views/projects/_head.html.haml
@@ -4,17 +4,14 @@
.nav-links.sub-nav.scrolling-tabs
%ul{ class: container_class }
= nav_link(path: 'projects#show') do
- = link_to project_path(@project), title: 'Project home', class: 'shortcuts-project' do
- %span
- Home
+ = link_to project_path(@project), title: _('Project home'), class: 'shortcuts-project' do
+ %span= _('Home')
= nav_link(path: 'projects#activity') do
- = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
- %span
- Activity
+ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
+ %span= _('Activity')
- if can?(current_user, :read_cycle_analytics, @project)
= nav_link(path: 'cycle_analytics#show') do
- = link_to project_cycle_analytics_path(@project), title: 'Cycle Analytics', class: 'shortcuts-project-cycle-analytics' do
- %span
- Cycle Analytics
+ = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do
+ %span= _('Cycle Analytics')
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 9a9fca78df3..873b3045ea9 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -14,7 +14,7 @@
- if forked_from_project = @project.forked_from_project
%p
- Forked from
+ #{ s_('ForkedFromProjectPath|Forked from') }
= link_to project_path(forked_from_project) do
= forked_from_project.namespace.try(:name)
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index e8b1940af2d..f1ef50d2de2 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -15,4 +15,4 @@
.pull-right
= link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
- Create merge request
+ #{ _('Create merge request') }
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index d0698285f84..07445434cf3 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -9,12 +9,6 @@
%li
%a.js-md-preview-button{ href: "#md-preview-holder", tabindex: -1 }
Preview
-
- - if defined?(@issue) && @issue.confidential?
- %li.confidential-issue-warning
- = icon('warning')
- %span This is a confidential issue. Your comment will not be visible to the public.
-
%li.pull-right
.toolbar-group
= markdown_toolbar_button({ icon: "bold fw", data: { "md-tag" => "**" }, title: "Add bold text" })
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index 7f470b890ba..40978583e8b 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -3,18 +3,18 @@
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3.page-title Create New Directory
+ %h3.page-title= _('Create New Directory')
.modal-body
= form_tag namespace_project_create_dir_path(@project.namespace, @project, @id), method: :post, remote: false, class: 'form-horizontal js-create-dir-form js-quick-submit js-requires-input' do
.form-group
- = label_tag :dir_name, 'Directory name', class: 'control-label'
+ = label_tag :dir_name, _('Directory name'), class: 'control-label'
.col-sm-10
= text_field_tag :dir_name, params[:dir_name], required: true, class: 'form-control'
- = render 'shared/new_commit_form', placeholder: "Add new directory"
+ = render 'shared/new_commit_form', placeholder: _("Add new directory")
.form-actions
- = submit_tag "Create directory", class: 'btn btn-create'
+ = submit_tag _("Create directory"), class: 'btn btn-create'
= link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- unless can?(current_user, :push_code, @project)
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
index 4252f27d007..013f1c267c8 100644
--- a/app/views/projects/blob/_viewer.html.haml
+++ b/app/views/projects/blob/_viewer.html.haml
@@ -1,13 +1,19 @@
- hidden = local_assigns.fetch(:hidden, false)
- render_error = viewer.render_error
-- load_async = local_assigns.fetch(:load_async, viewer.load_async?)
+- load_async = local_assigns.fetch(:load_async, viewer.load_async? && render_error.nil?)
- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async
.blob-viewer{ data: { type: viewer.type, url: viewer_url }, class: ('hidden' if hidden) }
- - if load_async
- = render viewer.loading_partial_path, viewer: viewer
- - elsif render_error
+ - if render_error
= render 'projects/blob/render_error', viewer: viewer
+ - elsif load_async
+ = render viewer.loading_partial_path, viewer: viewer
- else
- viewer.prepare!
+
+ -# In the rare case where the first kilobyte of the file looks like text,
+ -# but the file turns out to actually be binary after loading all data,
+ -# we fall back on the binary Download viewer.
+ - viewer = BlobViewer::Download.new(viewer.blob) if viewer.binary_detected_after_load?
+
= render viewer.partial_path, viewer: viewer
diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml
index efec69662f3..6684ecfce81 100644
--- a/app/views/projects/boards/_show.html.haml
+++ b/app/views/projects/boards/_show.html.haml
@@ -26,6 +26,7 @@
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
+ ":board-id" => "boardId",
":key" => "_uid" }
= render "projects/boards/components/sidebar"
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
index bc5c727bf0d..55c4d51be14 100644
--- a/app/views/projects/boards/components/_board.html.haml
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -1,8 +1,11 @@
-.board{ ":class" => '{ "is-draggable": !list.preset }',
+.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded }',
":data-id" => "list.id" }
.board-inner
- %header.board-header{ ":class" => '{ "has-border": list.label }', ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
+ %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
%h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' }
+ %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
+ ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }",
+ "aria-hidden": "true" }
%span.has-tooltip{ ":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" } }
{{ list.title }}
@@ -10,13 +13,13 @@
%span.board-issue-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }}
- if can?(current_user, :admin_issue, @project)
- %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button",
+ %button.btn.btn-small.btn-default.pull-right.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
"aria-label" => "New issue",
"title" => "New issue",
data: { placement: "top", container: "body" } }
- = icon("plus")
+ = icon("plus", class: "js-no-trigger-collapse")
- if can?(current_user, :admin_list, @project)
%board-delete{ "inline-template" => true,
":list" => "list",
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index d90d4a27cd6..3cf91bf07f7 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -2,29 +2,29 @@
- if !project.empty_repo? && can?(current_user, :download_code, project)
.project-action-button.dropdown.inline>
- %button.btn{ 'data-toggle' => 'dropdown' }
+ %button.btn.has-tooltip{ title: 'Download', 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') }
= icon('download')
= icon("caret-down")
- %span.sr-only
- Select Archive Format
+ %span.sr-only= _('Select Archive Format')
%ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
- %li.dropdown-header Source code
+ %li.dropdown-header
+ #{ _('Source code') }
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download zip
+ %span= _('Download zip')
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar.gz
+ %span= _('Download tar.gz')
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar.bz2
+ %span= _('Download tar.bz2')
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar
+ %span= _('Download tar')
- if pipeline
- artifacts = pipeline.builds.latest.with_artifacts
@@ -39,4 +39,5 @@
%li
= link_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download '#{job.name}'
+ %span
+ #{ s_('DownloadArtifacts|Download') } '#{job.name}'
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index 67de8699b2e..312c349da3a 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -1,6 +1,6 @@
- if current_user
.project-action-button.dropdown.inline
- %a.btn.dropdown-toggle{ href: '#', "data-toggle" => "dropdown" }
+ %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: 'Create new...', 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => 'Create new...' }
= icon('plus')
= icon("caret-down")
%ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown
@@ -12,19 +12,19 @@
%li
= link_to new_namespace_project_issue_path(@project.namespace, @project) do
= icon('exclamation-circle fw')
- New issue
+ #{ _('New issue') }
- if merge_project
%li
= link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project) do
= icon('tasks fw')
- New merge request
+ #{ _('New merge request') }
- if can_create_snippet
%li
= link_to new_namespace_project_snippet_path(@project.namespace, @project) do
= icon('file-text-o fw')
- New snippet
+ #{ _('New snippet') }
- if can_create_issue || merge_project || can_create_snippet
%li.divider
@@ -33,20 +33,20 @@
%li
= link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do
= icon('file fw')
- New file
+ #{ _('New file') }
%li
= link_to new_namespace_project_branch_path(@project.namespace, @project) do
= icon('code-fork fw')
- New branch
+ #{ _('New branch') }
%li
= link_to new_namespace_project_tag_path(@project.namespace, @project) do
= icon('tags fw')
- New tag
+ #{ _('New tag') }
- elsif current_user && current_user.already_forked?(@project)
%li
= link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do
= icon('file fw')
- New file
+ #{ _('New file') }
- elsif can?(current_user, :fork_project, @project)
%li
- continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master'),
@@ -56,4 +56,4 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('file fw')
- New file
+ #{ _('New file') }
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 851fe44a86d..7a08bb37494 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,14 +1,14 @@
- unless @project.empty_repo?
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn has-tooltip' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn has-tooltip' do
= custom_icon('icon_fork')
- %span Fork
+ %span= s_('GoToYourFork|Fork')
- else
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: 'Fork project', class: 'btn' do
+ = link_to new_namespace_project_fork_path(@project.namespace, @project), class: 'btn' do
= custom_icon('icon_fork')
- %span Fork
+ %span= s_('CreateNewFork|Fork')
.count-with-arrow
%span.arrow
- = link_to namespace_project_forks_path(@project.namespace, @project), title: 'Forks', class: 'count' do
+ = link_to namespace_project_forks_path(@project.namespace, @project), title: n_('Forks', @project.forks_count), class: 'count' do
= @project.forks_count
diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml
index a5a9e4d0621..de2d61d4aa3 100644
--- a/app/views/projects/buttons/_koding.html.haml
+++ b/app/views/projects/buttons/_koding.html.haml
@@ -1,3 +1,3 @@
- if koding_enabled? && current_user && @repository.koding_yml && can_push_branch?(@project, @project.default_branch)
= link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank', rel: 'noopener noreferrer' do
- Run in IDE (Koding)
+ _('Run in IDE (Koding)')
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index d57eb2cbfbc..58413e2fc52 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -2,19 +2,19 @@
= link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star', method: :post, remote: true } do
- if current_user.starred?(@project)
= icon('star')
- %span.starred Unstar
+ %span.starred= _('Unstar')
- else
= icon('star-o')
- %span Star
+ %span= s_('StarProject|Star')
.count-with-arrow
%span.arrow
%span.count.star-count
= @project.star_count
- else
- = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do
+ = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: _('You must sign in to star a project') do
= icon('star')
- Star
+ #{ s_('StarProject|Star') }
.count-with-arrow
%span.arrow
%span.count
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 0aef5822f81..aab50310234 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -72,8 +72,8 @@
Pipeline
= link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id)
= ci_label_for_status(last_pipeline.status)
- - if last_pipeline.stages.any?
- with #{"stage".pluralize(last_pipeline.stages.count)}
+ - if last_pipeline.stages_count.nonzero?
+ with #{"stage".pluralize(last_pipeline.stages_count)}
.mr-widget-pipeline-graph
= render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph'
in
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 3350a0ec152..7a03c3561af 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -31,12 +31,12 @@
= preserve(markdown(commit.description, pipeline: :single_line, author: commit.author))
.commiter
= commit_author_link(commit, avatar: false, size: 24)
- committed
+ #{ _('committed') }
#{time_ago_with_tooltip(commit.committed_date)}
.commit-actions.flex-row.hidden-xs
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha btn btn-transparent"
- = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard")
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"))
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 88c7d7bc44b..d3380c917e4 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -2,8 +2,11 @@
- commits, hidden = limited_commits(@commits)
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits|
- %li.commit-header #{day.strftime('%d %b, %Y')} #{pluralize(commits.count, 'commit')}
- %li.commits-row
+ %li.commit-header.js-commit-header{ data: { day: day } }
+ %span.day= day.strftime('%d %b, %Y')
+ %span.commits-count= pluralize(commits.count, 'commit')
+
+ %li.commits-row{ data: { day: day } }
%ul.content-list.commit-list
= render commits, project: project, ref: ref
diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml
index dd6797f10c0..ebeaab863bc 100644
--- a/app/views/projects/commits/_head.html.haml
+++ b/app/views/projects/commits/_head.html.haml
@@ -5,32 +5,32 @@
%ul{ class: (container_class) }
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do
= link_to project_files_path(@project) do
- Files
+ #{ _('Files') }
= nav_link(controller: [:commit, :commits]) do
= link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- Commits
+ #{ _('Commits') }
= nav_link(html_options: {class: branches_tab_class}) do
= link_to namespace_project_branches_path(@project.namespace, @project) do
- Branches
+ #{ _('Branches') }
= nav_link(controller: [:tags, :releases]) do
= link_to namespace_project_tags_path(@project.namespace, @project) do
- Tags
+ #{ _('Tags') }
= nav_link(path: 'graphs#show') do
= link_to namespace_project_graph_path(@project.namespace, @project, current_ref) do
- Contributors
+ #{ _('Contributors') }
= nav_link(controller: %w(network)) do
= link_to namespace_project_network_path(@project.namespace, @project, current_ref) do
- Graph
+ #{ s_('ProjectNetworkGraph|Graph') }
= nav_link(controller: :compare) do
= link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: current_ref) do
- Compare
+ #{ _('Compare') }
= nav_link(path: 'graphs#charts') do
= link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref) do
- Charts
+ #{ _('Charts') }
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 74255167352..7000b289f75 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -2,7 +2,6 @@
- page_title "Cycle Analytics"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('locale')
= page_specific_javascript_bundle_tag('cycle_analytics')
= render "projects/head"
diff --git a/app/views/projects/deploy_keys/_deploy_key.html.haml b/app/views/projects/deploy_keys/_deploy_key.html.haml
deleted file mode 100644
index ec8fc4c9ee8..00000000000
--- a/app/views/projects/deploy_keys/_deploy_key.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-%li
- .pull-left.append-right-10.hidden-xs
- = icon "key", class: "key-icon"
- .deploy-key-content.key-list-item-info
- %strong.title
- = deploy_key.title
- .description
- = deploy_key.fingerprint
- - if deploy_key.can_push?
- .write-access-allowed
- Write access allowed
- .deploy-key-content.prepend-left-default.deploy-key-projects
- - deploy_key.projects.each do |project|
- - if can?(current_user, :read_project, project)
- = link_to namespace_project_path(project.namespace, project), class: "label deploy-project-label" do
- = project.name_with_namespace
- .deploy-key-content
- %span.key-created-at
- created #{time_ago_with_tooltip(deploy_key.created_at)}
- .visible-xs-block.visible-sm-block
- - if @deploy_keys.key_available?(deploy_key)
- = link_to enable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-sm prepend-left-10", method: :put do
- Enable
- - else
- - if deploy_key.destroyed_when_orphaned? && deploy_key.almost_orphaned?
- = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: "You are going to remove deploy key. Are you sure?" }, method: :put, class: "btn btn-warning btn-sm prepend-left-10" do
- Remove
- - else
- = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-warning btn-sm prepend-left-10", method: :put do
- Disable
diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/projects/deploy_keys/_form.html.haml
index 1421da72418..edaa3a1119e 100644
--- a/app/views/projects/deploy_keys/_form.html.haml
+++ b/app/views/projects/deploy_keys/_form.html.haml
@@ -2,7 +2,7 @@
= form_errors(@deploy_keys.new_key)
.form-group
= f.label :title, class: "label-light"
- = f.text_field :title, class: 'form-control', autofocus: true, required: true
+ = f.text_field :title, class: 'form-control', required: true
.form-group
= f.label :key, class: "label-light"
= f.text_area :key, class: "form-control", rows: 5, required: true
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index 74756b58439..6e038ffd9c0 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -1,13 +1,15 @@
-.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0
+- expanded = Rails.env.test?
+%section.settings
+ .settings-header
+ %h4
Deploy Keys
+ %button.btn.js-settings-toggle
+ = expanded ? 'Close' : 'Expand'
%p
Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.
- .col-lg-9
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
%h5.prepend-top-0
Create a new deploy key for this project
= render @deploy_keys.form_partial_path
- .col-lg-9.col-lg-offset-3
%hr
- #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } }
+ #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } }
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
new file mode 100644
index 00000000000..37219f8d7ae
--- /dev/null
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Edit Deploy Key'
+%h3.page-title Edit Deploy Key
+%hr
+
+%div
+ = form_for [@project.namespace.becomes(Namespace), @project, @deploy_key], html: { class: 'form-horizontal js-requires-input' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn-save btn'
+ = link_to 'Cancel', namespace_project_settings_repository_path(@project.namespace, @project), class: 'btn btn-cancel'
diff --git a/app/views/projects/deploy_keys/new.html.haml b/app/views/projects/deploy_keys/new.html.haml
deleted file mode 100644
index 01fab3008a7..00000000000
--- a/app/views/projects/deploy_keys/new.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- page_title "New Deploy Key"
-%h3.page-title New Deploy Key
-%hr
-
-= render 'form'
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 31fd982c522..4502c397d29 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,16 +1,17 @@
-.branch-commit
- - if deployment.ref
- .icon-container
- = deployment.tag? ? icon('tag') : icon('code-fork')
- = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
- .icon-container.commit-icon
- = custom_icon("icon_commit")
- = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-sha"
+.table-mobile-content
+ .branch-commit
+ - if deployment.ref
+ %span.icon-container
+ = deployment.tag? ? icon('tag') : icon('code-fork')
+ = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
+ .icon-container.commit-icon
+ = custom_icon("icon_commit")
+ = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-sha"
- %p.commit-title
- %span
- - if commit_title = deployment.commit_title
- = author_avatar(deployment.commit, size: 20)
- = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message"
- - else
- Cant find HEAD commit for this branch
+ %p.commit-title.flex-truncate-parent
+ %span.flex-truncate-child
+ - if commit_title = deployment.commit_title
+ = author_avatar(deployment.commit, size: 20)
+ = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message"
+ - else
+ Cant find HEAD commit for this branch
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index 260c9023daf..d956cb2cc1a 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -1,22 +1,26 @@
-%tr.deployment
- %td
- %strong ##{deployment.iid}
+.gl-responsive-table-row.deployment{ role: 'row' }
+ .table-section.section-10{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } ID
+ %strong.table-mobile-content ##{deployment.iid}
- %td
+ .table-section.section-40{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Commit
= render 'projects/deployments/commit', deployment: deployment
- %td.build-column
+ .table-section.section-15.build-column{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Job
- if deployment.deployable
- = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do
+ = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link table-mobile-content' do
#{deployment.deployable.name} (##{deployment.deployable.id})
- if deployment.user
by
= user_avatar(user: deployment.user, size: 20)
- %td
- #{time_ago_with_tooltip(deployment.created_at)}
+ .table-section.section-15{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Created
+ %span.table-mobile-content= time_ago_with_tooltip(deployment.created_at)
- %td.hidden-xs
- .pull-right.btn-group
+ .table-section.section-20.environments-actions.table-button-footer{ role: 'gridcell' }
+ .btn-group.environment-action-buttons
= render 'projects/deployments/actions', deployment: deployment
= render 'projects/deployments/rollback', deployment: deployment
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 59844bc00cd..ec1c434a4b8 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -6,7 +6,7 @@
- elsif blob.truncated?
.nothing-here-block The file could not be displayed because it is too large.
- elsif blob.readable_text?
- - if !diff_file.repository.diffable?(blob)
+ - if !diff_file.diffable?
.nothing-here-block This diff was suppressed by a .gitattributes entry.
- elsif diff_file.collapsed?
- url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier))
diff --git a/app/views/projects/diffs/viewers/_text.html.haml b/app/views/projects/diffs/viewers/_text.html.haml
index e4b89671724..120d3540223 100644
--- a/app/views/projects/diffs/viewers/_text.html.haml
+++ b/app/views/projects/diffs/viewers/_text.html.haml
@@ -1,5 +1,5 @@
- blob = diff_file.blob
-- blob.load_all_data!(diff_file.repository)
+- blob.load_all_data!
- total_lines = blob.lines.size
- total_lines -= 1 if total_lines > 0 && blob.lines.last.blank?
- if diff_view == :parallel
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index f5549d7f4cd..c3dab68cea5 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -42,7 +42,7 @@
.col-md-9
.label-light
= label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level
- = link_to "(?)", help_page_path("public_access/public_access")
+ = link_to icon('question-circle'), help_page_path("public_access/public_access")
%span.help-block
.col-md-3.visibility-select-container
= render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
@@ -92,14 +92,14 @@
.form-group
= render 'shared/allow_request_access', form: f
- if Gitlab.config.lfs.enabled && current_user.admin?
- .row
+ .row.js-lfs-enabled
.col-md-9
= f.label :lfs_enabled, 'LFS', class: 'label-light'
%span.help-block
Git Large File Storage
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
.col-md-3
- = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control', data: { field: 'lfs_enabled' }
+ = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select', data: { field: 'lfs_enabled' }
- if Gitlab.config.registry.enabled
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 9e221240cf2..23aa4c29e69 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -3,7 +3,7 @@
= render "projects/pipelines/head"
%div{ class: container_class }
- .top-area.adjust
+ .row.top-area.adjust
.col-md-7
%h3.page-title= @environment.name
.col-md-5
@@ -28,14 +28,12 @@
= link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success"
- else
.table-holder
- %table.table.ci-table.environments
- %thead
- %tr
- %th ID
- %th Commit
- %th Job
- %th Created
- %th.hidden-xs
+ .ci-table.environments{ role: 'grid' }
+ .gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-10{ role: 'columnheader' } ID
+ .table-section.section-40{ role: 'columnheader' } Commit
+ .table-section.section-15{ role: 'columnheader' } Job
+ .table-section.section-15{ role: 'columnheader' } Created
= render @deployments
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index be0462f91cd..8a409541fe5 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -10,7 +10,7 @@
= link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
= @project.path
%li.file-finder
- %input#file_find.form-control.file-finder-input{ type: "text", placeholder: 'Find by path', autocomplete: 'off' }
+ %input#file_find.form-control.file-finder-input{ type: "text", placeholder: _('Find by path'), autocomplete: 'off' }
.tree-content-holder
.table-holder
diff --git a/app/views/projects/group_links/_index.html.haml b/app/views/projects/group_links/_index.html.haml
deleted file mode 100644
index debb0214d06..00000000000
--- a/app/views/projects/group_links/_index.html.haml
+++ /dev/null
@@ -1,53 +0,0 @@
-- page_title "Groups"
-.row.prepend-top-default
- .col-lg-3.settings-sidebar
- %h4.prepend-top-0
- Share project with other groups
- %p
- Projects can be stored in only one group at once. However you can share a project with other groups here.
- .col-lg-9
- = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do
- .form-group
- = label_tag :link_group_id, "Select a group to share with", class: "label-light"
- = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true)
- .form-group
- = label_tag :link_group_access, "Max access level", class: "label-light"
- .select-wrapper
- = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
- = icon('caret-down')
- .form-group
- = label_tag :expires_at, 'Access expiration date', class: 'label-light'
- .clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Select access expiration date', id: 'expires_at_groups'
- %i.clear-icon.js-clear-input
- .help-block
- On this date, all members in the group will automatically lose access to this project.
- = submit_tag "Share", class: "btn btn-create"
- .col-lg-9.col-lg-offset-3
- %hr
- .col-lg-9.col-lg-offset-3.append-bottom-default.enabled-groups
- %h5.prepend-top-0
- Groups you share with (#{@group_links.size})
- - if @group_links.present?
- %ul.well-list
- - @group_links.each do |group_link|
- - group = group_link.group
- %li
- .pull-left.append-right-10.hidden-xs
- = icon("folder-open-o", class: "settings-list-icon")
- .pull-left
- = link_to group do
- = group.full_name
- %br
- up to #{group_link.human_access}
- - if group_link.expires?
- ·
- %span{ class: ('text-warning' if group_link.expires_soon?) }
- expires in #{distance_of_time_in_words_to_now(group_link.expires_at)}
- .pull-right
- = link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: "btn btn-transparent" do
- %span.sr-only disable sharing
- = icon("trash")
- - else
- .settings-message.text-center
- There are no groups with access to your project, add one in the form above
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index c184e0e0022..9e4e6934ca9 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -1,7 +1,7 @@
%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } }
.issue-box
- - if @bulk_edit
- .issue-check
+ - if @can_bulk_update
+ .issue-check.hidden
= check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue"
.issue-info-container
.issue-title.title
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 60900e9d660..7183794ce72 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -1,5 +1,5 @@
- @no_container = true
-- @bulk_edit = can?(current_user, :admin_issue, @project)
+- @can_bulk_update = can?(current_user, :admin_issue, @project)
- page_title "Issues"
- new_issue_email = @project.new_issue_address(current_user)
@@ -20,6 +20,8 @@
.nav-controls
= link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
+ - if @can_bulk_update
+ = button_tag "Edit Issues", class: "btn btn-default js-bulk-update-toggle"
= link_to new_namespace_project_issue_path(@project.namespace,
@project,
issue: { assignee_id: issues_finder.assignee.try(:id),
@@ -30,6 +32,9 @@
New issue
= render 'shared/issuable/search_bar', type: :issues
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :issues
+
.issues-holder
= render 'issues'
- if new_issue_email
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 7bf271c2fc5..5f92d020eef 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -5,6 +5,13 @@
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
+- if defined?(@issue) && @issue.confidential?
+ .confidential-issue-warning{ data: { spy: 'affix' } }
+ %span.confidential-issue-text
+ #{confidential_icon(@issue)} This issue is confidential.
+ %a{ href: help_page_path('user/project/issues/confidential_issues'), target: '_blank' }
+ What are confidential issues?
+
.clearfix.detail-page-header
.issuable-header
.issuable-status-box.status-box.status-box-closed{ class: issue_button_visibility(@issue, false) }
@@ -19,7 +26,6 @@
= icon('angle-double-left')
.issuable-meta
- = confidential_icon(@issue)
= issuable_meta(@issue, @project, "Issue")
.issuable-actions
@@ -63,7 +69,7 @@
.wiki= markdown_field(@issue, :description)
%textarea.hidden.js-task-list-field= @issue.description
- = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
+ = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
// This element is filled in using JavaScript.
diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index f700b5c9455..09d4ddc243b 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -111,7 +111,7 @@
%span.stage-selection More
= icon('chevron-down')
%ul.dropdown-menu
- - @build.pipeline.stages.each do |stage|
+ - @build.pipeline.legacy_stages.each do |stage|
%li
%a.stage-item= stage.name
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 0d10dfcef70..987068dc18e 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -66,29 +66,24 @@
.controllers
- if @build.has_trace?
= link_to raw_namespace_project_job_path(@project.namespace, @project, @build),
- title: 'Open raw trace',
+ title: 'Show complete raw',
data: { placement: 'top', container: 'body' },
- class: 'js-raw-link-controller has-tooltip' do
- = icon('download')
+ class: 'js-raw-link-controller has-tooltip controllers-buttons' do
+ = icon('file-text-o')
- if can?(current_user, :update_build, @project) && @build.erasable?
= link_to erase_namespace_project_job_path(@project.namespace, @project, @build),
method: :post,
data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' },
- title: 'Erase Build',
- class: 'has-tooltip js-erase-link' do
+ title: 'Erase job log',
+ class: 'has-tooltip js-erase-link controllers-buttons' do
= icon('trash')
-
- %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button',
- disabled: true,
- title: 'Scroll Up',
- data: { placement: 'top', container: 'body'} }
- = custom_icon('scroll_up')
- %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button',
- disabled: true,
- title: 'Scroll Down',
- data: { placement: 'top', container: 'body'} }
- = custom_icon('scroll_down')
+ .has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
+ %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
+ = custom_icon('scroll_up')
+ .has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
+ %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
+ = custom_icon('scroll_down')
.bash.sticky.js-scroll-container
%code.js-build-output
.build-loader-animation.js-build-refresh
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 94b9577e9eb..c13110deb16 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -1,6 +1,6 @@
%li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } }
- - if @bulk_edit
- .issue-check
+ - if @can_bulk_update
+ .issue-check.hidden
= check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue"
.issue-info-container
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 2cb3045f83e..6d75a9f34a3 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -1,5 +1,5 @@
- @no_container = true
-- @bulk_edit = can?(current_user, :admin_merge_request, @project)
+- @can_bulk_update = can?(current_user, :admin_merge_request, @project)
- page_title "Merge Requests"
- unless @project.default_issues_tracker?
@@ -18,6 +18,8 @@
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
+ - if @can_bulk_update
+ = button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle"
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- if merge_project
= link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do
@@ -25,6 +27,9 @@
= render 'shared/issuable/search_bar', type: :merge_requests
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :merge_requests
+
.merge-requests-holder
= render 'merge_requests'
- else
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index e180cb8bad1..7b8be58554a 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -95,7 +95,7 @@
.form-group.project-visibility-level-holder
= f.label :visibility_level, class: 'label-light' do
Visibility Level
- = link_to icon('question-circle'), help_page_path("public_access/public_access")
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }
= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false
= f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 720957e8336..1cf286ddc40 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,22 +1,22 @@
%h2
%i.fa.fa-warning
- No repository
+ #{ _('No repository') }
%p.slead
- The repository for this project does not exist.
+ #{ _('The repository for this project does not exist.') }
%br
- This means you can not push code until you create an empty repository or import existing one.
+ #{ _('This means you can not push code until you create an empty repository or import existing one.') }
%hr
.no-repo-actions
= link_to namespace_project_repository_path(@project.namespace, @project), method: :post, class: 'btn btn-primary' do
- Create empty bare repository
+ #{ _('Create empty bare repository') }
%strong.prepend-left-10.append-right-10 or
= link_to new_namespace_project_import_path(@project.namespace, @project), class: 'btn' do
- Import repository
+ #{ _('Import repository') }
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to 'Remove project', project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
+ = link_to _('Remove project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 3e79dbec70c..9c42be4e0ff 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -37,8 +37,4 @@
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- - if note_editable
- = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
- = icon('pencil', class: 'link-highlight')
- = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
- = icon('trash-o', class: 'danger-highlight')
+ = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
new file mode 100644
index 00000000000..e0d45054854
--- /dev/null
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -0,0 +1,14 @@
+.dropdown.more-actions
+ = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body' } do
+ = icon('ellipsis-v', class: 'icon')
+ %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
+ %li
+ = button_tag 'Edit comment', class: 'js-note-edit btn btn-transparent'
+ %li.divider
+ %li
+ = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do
+ Report as abuse
+ - if note_editable
+ %li
+ = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do
+ %span.text-danger Delete comment
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index bbed10039af..25ae4e0e18f 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -6,28 +6,28 @@
= form_errors(@schedule)
.form-group
.col-md-9
- = f.label :description, 'Description', class: 'label-light'
- = f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: 'Provide a short description for this pipeline'
+ = f.label :description, _('Description'), class: 'label-light'
+ = f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: _('PipelineSchedules|Provide a short description for this pipeline')
.form-group
.col-md-9
- = f.label :cron, 'Interval Pattern', class: 'label-light'
+ = f.label :cron, _('Interval Pattern'), class: 'label-light'
#interval-pattern-input{ data: { initial_interval: @schedule.cron } }
.form-group
.col-md-9
- = f.label :cron_timezone, 'Cron Timezone', class: 'label-light'
- = dropdown_tag("Select a timezone", options: { toggle_class: 'btn js-timezone-dropdown', title: "Select a timezone", filter: true, placeholder: "Filter", data: { data: timezone_data } } )
+ = f.label :cron_timezone, _('Cron Timezone'), class: 'label-light'
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown', title: _("Select a timezone"), filter: true, placeholder: _("Filter"), data: { data: timezone_data } } )
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group
.col-md-9
- = f.label :ref, 'Target Branch', class: 'label-light'
- = dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown git-revision-dropdown-toggle', dropdown_class: 'git-revision-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
+ = f.label :ref, _('Target Branch'), class: 'label-light'
+ = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown git-revision-dropdown-toggle', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: _("Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group
.col-md-9
- = f.label :active, 'Activated', class: 'label-light'
+ = f.label :active, _('PipelineSchedules|Activated'), class: 'label-light'
%div
= f.check_box :active, required: false, value: @schedule.active?
Active
.footer-block.row-content-block
- = f.submit 'Save pipeline schedule', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', pipeline_schedules_path(@project), class: 'btn btn-cancel'
+ = f.submit _('Save pipeline schedule'), class: 'btn btn-create', tabindex: 3
+ = link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 7bde839e26f..2d3344a4aaf 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -13,12 +13,12 @@
= ci_icon_for_status(pipeline_schedule.last_pipeline.status)
%span ##{pipeline_schedule.last_pipeline.id}
- else
- None
+ = _("PipelineSchedules|None")
%td.next-run-cell
- if pipeline_schedule.active?
= time_ago_with_tooltip(pipeline_schedule.real_next_run)
- else
- Inactive
+ = _("PipelineSchedules|Inactive")
%td
- if pipeline_schedule.owner
= image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20"
@@ -27,11 +27,11 @@
%td
.pull-right.btn-group
- if can?(current_user, :update_pipeline_schedule, @project) && !pipeline_schedule.owned_by?(current_user)
- = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: 'Take Ownership', class: 'btn' do
- Take ownership
+ = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do
+ = s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
- = link_to edit_pipeline_schedule_path(pipeline_schedule), title: 'Edit', class: 'btn' do
+ = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn' do
= icon('pencil')
- if can?(current_user, :admin_pipeline_schedule, pipeline_schedule)
- = link_to pipeline_schedule_path(pipeline_schedule), title: 'Delete', method: :delete, class: 'btn btn-remove', data: { confirm: "Are you sure you want to cancel this pipeline?" } do
+ = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn btn-remove', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do
= icon('trash')
diff --git a/app/views/projects/pipeline_schedules/_table.html.haml b/app/views/projects/pipeline_schedules/_table.html.haml
index 25c7604eb24..d0c7ea77263 100644
--- a/app/views/projects/pipeline_schedules/_table.html.haml
+++ b/app/views/projects/pipeline_schedules/_table.html.haml
@@ -2,11 +2,11 @@
%table.table.ci-table
%thead
%tr
- %th Description
- %th Target
- %th Last Pipeline
- %th Next Run
- %th Owner
+ %th= _("Description")
+ %th= s_("PipelineSchedules|Target")
+ %th= _("Last Pipeline")
+ %th= s_("PipelineSchedules|Next Run")
+ %th= _("Owner")
%th
= render partial: "pipeline_schedule", collection: @schedules
diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml
index 2a1fb16876a..7fcb624a9dd 100644
--- a/app/views/projects/pipeline_schedules/_tabs.html.haml
+++ b/app/views/projects/pipeline_schedules/_tabs.html.haml
@@ -1,18 +1,18 @@
%ul.nav-links
%li{ class: active_when(scope.nil?) }>
= link_to schedule_path_proc.call(nil) do
- All
+ = s_("PipelineSchedules|All")
%span.badge.js-totalbuilds-count
= number_with_delimiter(all_schedules.count(:id))
%li{ class: active_when(scope == 'active') }>
= link_to schedule_path_proc.call('active') do
- Active
+ = s_("PipelineSchedules|Active")
%span.badge
= number_with_delimiter(all_schedules.active.count(:id))
%li{ class: active_when(scope == 'inactive') }>
= link_to schedule_path_proc.call('inactive') do
- Inactive
+ = s_("PipelineSchedules|Inactive")
%span.badge
= number_with_delimiter(all_schedules.inactive.count(:id))
diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml
index e16fe0b7a98..9b2a7b5821d 100644
--- a/app/views/projects/pipeline_schedules/edit.html.haml
+++ b/app/views/projects/pipeline_schedules/edit.html.haml
@@ -1,7 +1,7 @@
-- page_title "Edit", @schedule.description, "Pipeline Schedule"
+- page_title _("Edit"), @schedule.description, _("Pipeline Schedule")
%h3.page-title
- Edit Pipeline Schedule #{@schedule.id}
+ = _("Edit Pipeline Schedule %{id}") % { id: @schedule.id }
%hr
= render "form"
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 6751efaaf2f..4a96ee652d2 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -3,7 +3,7 @@
= webpack_bundle_tag 'schedules_index'
- @no_container = true
-- page_title "Pipeline Schedules"
+- page_title _("Pipeline Schedules")
= render "projects/pipelines/head"
%div{ class: container_class }
@@ -21,4 +21,4 @@
= render partial: "table"
- else
.light-well
- .nothing-here-block No schedules
+ .nothing-here-block= _("No schedules")
diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml
index b89e170ad3c..87390d4dd02 100644
--- a/app/views/projects/pipeline_schedules/new.html.haml
+++ b/app/views/projects/pipeline_schedules/new.html.haml
@@ -1,7 +1,7 @@
-- page_title "New Pipeline Schedule"
+- page_title _("New Pipeline Schedule")
%h3.page-title
- Schedule a new pipeline
+ = _("Schedule a new pipeline")
%hr
= render "form"
diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml
index a33da149c62..d2f0cb0806f 100644
--- a/app/views/projects/pipelines/_head.html.haml
+++ b/app/views/projects/pipelines/_head.html.haml
@@ -10,7 +10,7 @@
Pipelines
- if project_nav_tab? :builds
- = nav_link(controller: [:builds, :artifacts]) do
+ = nav_link(controller: [:jobs, :artifacts]) do
= link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
%span
Jobs
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 01cf2cc80e5..85550e8fd32 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -42,7 +42,7 @@
%th
%th Coverage
%th
- = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
+ = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage
- if failed_builds.present?
#js-tab-failures.build-failures.tab-pane
- failed_builds.each_with_index do |build, index|
diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml
index d080b6c83d4..cfae371e169 100644
--- a/app/views/projects/project_members/_index.html.haml
+++ b/app/views/projects/project_members/_index.html.haml
@@ -1,11 +1,12 @@
.row.prepend-top-default
.col-lg-3.settings-sidebar
%h4.prepend-top-0
- Members
+ Project members
- if can?(current_user, :admin_project_member, @project)
%p
- Add a new member to
+ You can add a new member to
%strong= @project.name
+ or share it with another group.
- else
%p
Members can be added by project
@@ -13,9 +14,20 @@
or
%i Owners
.col-lg-9
- .light.prepend-top-default
+ .light
- if can?(current_user, :admin_project_member, @project)
- = render "projects/project_members/new_project_member"
+ %ul.nav-links.project-member-tabs{ role: 'tablist' }
+ %li.active{ role: 'presentation' }
+ %a{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
+ - if @project.allowed_to_share_with_group?
+ %li{ role: 'presentation' }
+ %a{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group
+
+ .tab-content.project-member-tab-content
+ .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_project_member', tab_title: 'Add member'
+ .tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml
index 2b1c23f7dda..247c4bdbe2d 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -1,18 +1,19 @@
-= form_for @project_member, as: :project_member, url: namespace_project_project_members_path(@project.namespace, @project), html: { class: 'users-project-form' } do |f|
- .form-group
- = users_select_tag(:user_ids, multiple: true, class: "input-clamp", scope: :all, email_user: true, placeholder: "Search for members to update or invite")
- .help-block.append-bottom-10
- Search for members by name, username, or email, or invite new ones using their email address.
- .form-group
- = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select"
- .help-block.append-bottom-10
- = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
- about role permissions
- .form-group
- .clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
- %i.clear-icon.js-clear-input
- .help-block.append-bottom-10
- On this date, the member(s) will automatically lose access to this project.
- = f.submit "Add to project", class: "btn btn-create"
- = link_to "Import", import_namespace_project_project_members_path(@project.namespace, @project), class: "btn btn-default", title: "Import members from another project"
+.row
+ .col-sm-12
+ = form_for @project_member, as: :project_member, url: namespace_project_project_members_path(@project.namespace, @project), html: { class: 'users-project-form' } do |f|
+ .form-group
+ = label_tag :user_ids, "Select members to invite", class: "label-light"
+ = users_select_tag(:user_ids, multiple: true, class: "input-clamp", scope: :all, email_user: true, placeholder: "Search for members to update or invite")
+ .form-group
+ = label_tag :access_level, "Choose a role permission", class: "label-light"
+ = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select"
+ .help-block.append-bottom-10
+ = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
+ about role permissions
+ .form-group
+ .clearable-input
+ = label_tag :expires_at, 'Access expiration date', class: 'label-light'
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
+ %i.clear-icon.js-clear-input
+ = f.submit "Add to project", class: "btn btn-create"
+ = link_to "Import", import_namespace_project_project_members_path(@project.namespace, @project), class: "btn btn-default", title: "Import members from another project"
diff --git a/app/views/projects/project_members/_new_shared_group.html.haml b/app/views/projects/project_members/_new_shared_group.html.haml
new file mode 100644
index 00000000000..b7cc8dd7062
--- /dev/null
+++ b/app/views/projects/project_members/_new_shared_group.html.haml
@@ -0,0 +1,20 @@
+.row
+ .col-sm-12
+ = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do
+ .form-group
+ = label_tag :link_group_id, "Select a group to share with", class: "label-light"
+ = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true)
+ .form-group
+ = label_tag :link_group_access, "Max access level", class: "label-light"
+ .select-wrapper
+ = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
+ = icon('caret-down')
+ .help-block.append-bottom-10
+ = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
+ about role permissions
+ .form-group
+ = label_tag :expires_at, 'Access expiration date', class: 'label-light'
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Expiration date', id: 'expires_at_groups'
+ %i.clear-icon.js-clear-input
+ = submit_tag "Share", class: "btn btn-create"
diff --git a/app/views/projects/protected_branches/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml
index 2d8c519c025..9af67649741 100644
--- a/app/views/projects/protected_branches/_index.html.haml
+++ b/app/views/projects/protected_branches/_index.html.haml
@@ -1,20 +1,25 @@
+- expanded = Rails.env.test?
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('protected_branches')
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0
+%section.settings
+ .settings-header
+ %h4
Protected Branches
- %p Keep stable branches secure and force developers to use merge requests.
- %p.prepend-top-20
+ %button.btn.js-settings-toggle
+ = expanded ? 'Close' : 'Expand'
+ %p
+ Keep stable branches secure and force developers to use merge requests.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ %p
By default, protected branches are designed to:
%ul
%li prevent their creation, if not already created, from everybody except Masters
%li prevent pushes from everybody except Masters
%li prevent <strong>anyone</strong> from force pushing to the branch
%li prevent <strong>anyone</strong> from deleting the branch
- %p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
- .col-lg-9
+ %p Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
+
- if can? current_user, :admin_project, @project
= render 'projects/protected_branches/create_protected_branch'
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
index af9a080f0a2..dd5b346d922 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f|
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f|
.panel.panel-default
.panel-heading
%h3.panel-title
diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
index c8531f96f97..9b6923210f7 100644
--- a/app/views/projects/protected_tags/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/_dropdown.html.haml
@@ -2,7 +2,7 @@
= dropdown_tag('Select tag or create wildcard',
options: { toggle_class: 'js-protected-tag-select js-filter-submit wide git-revision-dropdown-toggle',
- filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tag",
+ filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tags",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_tag_name],
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do
+ %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" }
Create wildcard
%code
diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml
index 0bfb1ad191d..976e1d7e93f 100644
--- a/app/views/projects/protected_tags/_index.html.haml
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -1,17 +1,25 @@
+- expanded = Rails.env.test?
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('protected_tags')
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0
- Protected tags
- %p.prepend-top-20
- By default, Protected tags are designed to:
+%section.settings
+ .settings-header
+ %h4
+ Protected Tags
+ %button.btn.js-settings-toggle
+ = expanded ? 'Close' : 'Expand'
+ %p
+ Limit access to creating and updating tags.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ %p
+ By default, protected tags are designed to:
%ul
%li Prevent tag creation by everybody except Masters
%li Prevent <strong>anyone</strong> from updating the tag
%li Prevent <strong>anyone</strong> from deleting the tag
- .col-lg-9
+
+ %p Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags"), class: "underlined-link"}.
+
- if can? current_user, :admin_project, @project
= render 'projects/protected_tags/create_protected_tag'
diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml
index 54249ec0db1..f11ce0483a9 100644
--- a/app/views/projects/protected_tags/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -19,4 +19,4 @@
- if can_admin_project
%td
- = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml
index 728afd75b50..d432a5c9113 100644
--- a/app/views/projects/protected_tags/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/_tags_list.html.haml
@@ -1,4 +1,4 @@
-.panel.panel-default.protected-tags-list.js-protected-tags-list
+.panel.panel-default.protected-tags-list
- if @protected_tags.empty?
.panel-heading
%h3.panel-title
@@ -13,6 +13,8 @@
%col{ width: "25%" }
%col{ width: "25%" }
%col{ width: "50%" }
+ - if can_admin_project
+ %col
%thead
%tr
%th Protected tag (#{@protected_tags.size})
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
index 94c3612a449..16fc02fe9f4 100644
--- a/app/views/projects/protected_tags/show.html.haml
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -5,7 +5,7 @@
%h4.prepend-top-0.ref-name
= @protected_ref.name
- .col-lg-9
+ .col-lg-9.edit_protected_tag
%h5 Matching Tags
- if @matching_refs.present?
.table-responsive
diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml
index 20e1ad68244..343807b87cd 100644
--- a/app/views/projects/settings/members/show.html.haml
+++ b/app/views/projects/settings/members/show.html.haml
@@ -2,6 +2,3 @@
= render "projects/settings/head"
= render "projects/project_members/index"
-- if can?(current_user, :admin_project, @project)
- - if @project.allowed_to_share_with_group?
- = render "projects/group_links/index"
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 4e59033c4a3..40ea02abce9 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,10 +1,11 @@
- page_title "Repository"
+- @content_class = "limit-container-width" unless fluid_layout
= render "projects/settings/head"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('deploy_keys')
-= render @deploy_keys
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
+= render @deploy_keys
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 1ca464696ed..7447197ed89 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -17,24 +17,24 @@
%ul.nav
%li
= link_to project_files_path(@project) do
- Files (#{storage_counter(@project.statistics.total_repository_size)})
+ #{_('Files')} (#{storage_counter(@project.statistics.total_repository_size)})
%li
= link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- #{'Commit'.pluralize(@project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)})
- %li
+ #{n_('Commit', 'Commits', @project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)})
+ %l
= link_to namespace_project_branches_path(@project.namespace, @project) do
- #{'Branch'.pluralize(@repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
+ #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
%li
= link_to namespace_project_tags_path(@project.namespace, @project) do
- #{'Tag'.pluralize(@repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
+ #{n_('Tag', 'Tags', @repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
- if default_project_view != 'readme' && @repository.readme
%li
- = link_to 'Readme', readme_path(@project)
+ = link_to _('Readme'), readme_path(@project)
- if @repository.changelog
%li
- = link_to 'Changelog', changelog_path(@project)
+ = link_to _('Changelog'), changelog_path(@project)
- if @repository.license_blob
%li
@@ -42,43 +42,43 @@
- if @repository.contribution_guide
%li
- = link_to 'Contribution guide', contribution_guide_path(@project)
+ = link_to _('Contribution guide'), contribution_guide_path(@project)
- if @repository.gitlab_ci_yml
%li
- = link_to 'CI configuration', ci_configuration_path(@project)
+ = link_to _('CI configuration'), ci_configuration_path(@project)
- if current_user && can_push_branch?(@project, @project.default_branch)
- unless @repository.changelog
%li.missing
= link_to add_special_file_path(@project, file_name: 'CHANGELOG') do
- Add Changelog
+ #{ _('Add Changelog') }
- unless @repository.license_blob
%li.missing
= link_to add_special_file_path(@project, file_name: 'LICENSE') do
- Add License
+ #{ _('Add License') }
- unless @repository.contribution_guide
%li.missing
= link_to add_special_file_path(@project, file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide') do
- Add Contribution guide
+ #{ _('Add Contribution guide') }
- unless @repository.gitlab_ci_yml
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do
- Set up CI
+ #{ _('Set up CI') }
- if koding_enabled? && @repository.koding_yml.blank?
%li.missing
- = link_to 'Set up Koding', add_koding_stack_path(@project)
+ = link_to _('Set up Koding'), add_koding_stack_path(@project)
- if @repository.gitlab_ci_yml.blank? && @project.deployment_service.present?
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', branch_name: 'auto-deploy', context: 'autodeploy') do
- Set up auto deploy
+ #{ _('Set up auto deploy') }
%div{ class: container_class }
- if @project.archived?
.text-warning.center.prepend-top-20
%p
= icon("exclamation-triangle fw")
- Archived project! Repository is read-only
+ #{ _('Archived project! Repository is read-only') }
- view_path = default_project_view
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index 2e34803b143..7854e1305db 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -3,10 +3,10 @@
%table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
%thead
%tr
- %th Name
+ %th= s_('ProjectFileTree|Name')
%th.hidden-xs
- .pull-left Last commit
- %th.text-right Last Update
+ .pull-left= _('Last commit')
+ %th.text-right= _('Last Update')
- if @path.present?
%tr.tree-item
%td.tree-item-file-name
@@ -20,7 +20,7 @@
= render "projects/tree/readme", readme: tree.readme
- if can_edit_tree?
- = render 'projects/blob/upload', title: 'Upload New File', placeholder: 'Upload new file', button_title: 'Upload file', form_path: namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post
+ = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post
= render 'projects/blob/new_dir'
:javascript
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index e4d9e24f56e..abde2a48587 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,7 +1,7 @@
.tree-controls
= render 'projects/find_file_link'
- = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-grouped'
+ = link_to s_('Commits|History'), namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-grouped'
= render 'projects/buttons/download', project: @project, ref: @ref
@@ -19,7 +19,7 @@
- if current_user
%li
- if !on_top_of_branch?
- %span.btn.add-to-tree.disabled.has-tooltip{ title: "You can only add files when you are on a branch", data: { container: 'body' } }
+ %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } }
= icon('plus')
- else
%span.dropdown
@@ -30,15 +30,15 @@
%li
= link_to namespace_project_new_blob_path(@project.namespace, @project, @id) do
= icon('pencil fw')
- New file
+ #{ _('New file') }
%li
= link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
= icon('file fw')
- Upload file
+ #{ _('Upload file') }
%li
= link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
= icon('folder fw')
- New directory
+ #{ _('New directory') }
- elsif can?(current_user, :fork_project, @project)
%li
- continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @id),
@@ -48,7 +48,7 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('pencil fw')
- New file
+ #{ _('New file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to upload a file again.",
@@ -57,7 +57,7 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('file fw')
- Upload file
+ #{ _('Upload file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to create a new directory again.",
@@ -66,14 +66,14 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('folder fw')
- New directory
+ #{ _('New directory') }
%li.divider
%li
= link_to new_namespace_project_branch_path(@project.namespace, @project) do
= icon('code-fork fw')
- New branch
+ #{ _('New branch') }
%li
= link_to new_namespace_project_tag_path(@project.namespace, @project) do
= icon('tags fw')
- New tag
+ #{ _('New tag') }
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index f7e410e27b8..96a08f9f8be 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
-- page_title @path.presence || "Files", @ref
+- page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
= render "projects/commits/head"
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 9ce6a1aeef5..de52fd00157 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -1,16 +1,14 @@
- noteable = @sent_notification.noteable
-- noteable_type = @sent_notification.noteable_type.humanize(capitalize: false)
+- noteable_type = @sent_notification.noteable_type.titleize.downcase
- noteable_text = %(#{noteable.title} (#{noteable.to_reference}))
-
-- page_title "Unsubscribe", noteable_text, @sent_notification.noteable_type.humanize.pluralize, @sent_notification.project.name_with_namespace
-
+- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.name_with_namespace
%h3.page-title
- Unsubscribe from #{noteable_type} #{noteable_text}
+ Unsubscribe from #{noteable_type}
%p
= succeed '?' do
- Are you sure you want to unsubscribe from #{noteable_type}
+ Are you sure you want to unsubscribe from the #{noteable_type}:
= link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable])
%p
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 0992a65f7cd..0aad4d0714f 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -19,7 +19,7 @@
= text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' }
.input-group-btn
- = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard")
+ = clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard"))
:javascript
$('ul.clone-options-dropdown a').on('click',function(e){
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index bd994cdad01..c185e9b73ee 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -64,7 +64,7 @@
%a.js-subscribe-button{ data: { url: toggle_subscription_group_label_path(label.group, label) } }
Group level
- - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_group, label.project.group)
+ - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
= link_to promote_namespace_project_label_path(label.project.namespace, label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting this label will make this label available to all projects inside this group. Existing project labels with the same name will be merged. Are you sure?", toggle: "tooltip"}, method: :post do
%span.sr-only Promote to Group
= icon('level-up')
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index 07970ad9cba..aa93572bf94 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -1,5 +1,5 @@
.stage-cell
- - pipeline.stages.each do |stage|
+ - pipeline.legacy_stages.each do |stage|
- if stage.status
- detailed_status = stage.detailed_status(current_user)
- icon_status = "#{detailed_status.icon}_borderless"
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index ed6fc76c61e..b561e6dc248 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -1,8 +1,10 @@
- if cookies[:hide_no_password_message].blank? && !current_user.hide_no_password && current_user.require_password?
.no-password-message.alert.alert-warning
- You won't be able to pull or push project code via #{gitlab_config.protocol.upcase} until you #{link_to 'set a password', edit_profile_password_path} on your account
+ - set_password_link = link_to s_('SetPasswordToCloneLink|set a password'), edit_profile_password_path
+ - translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: set_password_link }
+ - set_password_message = _("You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account") % translation_params
.alert-link-group
- = link_to "Don't show again", profile_path(user: {hide_no_password: true}), method: :put
+ = link_to _("Don't show again"), profile_path(user: {hide_no_password: true}), method: :put
|
- = link_to 'Remind later', '#', class: 'hide-no-password-message'
+ = link_to _('Remind later'), '#', class: 'hide-no-password-message'
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index d663fa13d10..e7815e28017 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -1,8 +1,9 @@
- if cookies[:hide_no_ssh_message].blank? && !current_user.hide_no_ssh_key && current_user.require_ssh_key?
.no-ssh-key-message.alert.alert-warning
- You won't be able to pull or push project code via SSH until you #{link_to 'add an SSH key', profile_keys_path, class: 'alert-link'} to your profile
-
+ - add_ssh_key_link = link_to s_('MissingSSHKeyWarningLink|add an SSH key'), profile_keys_path, class: 'alert-link'
+ - ssh_message = _("You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile") % { add_ssh_key_link: add_ssh_key_link }
+ #{ ssh_message.html_safe }
.alert-link-group
- = link_to "Don't show again", profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link'
+ = link_to _("Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link'
|
- = link_to 'Remind later', '#', class: 'hide-no-ssh-message alert-link'
+ = link_to _('Remind later'), '#', class: 'hide-no-ssh-message alert-link'
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index 2029eb5824a..d52bb6b4dd7 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -6,9 +6,9 @@
- @options && @options.each do |key, value|
= hidden_field_tag key, value, id: nil
.dropdown
- = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown git-revision-dropdown-toggle" }
+ = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" }
.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
- = dropdown_title "Switch branch/tag"
- = dropdown_filter "Search branches and tags"
+ = dropdown_title _("Switch branch/tag")
+ = dropdown_filter _("Search branches and tags")
= dropdown_content
= dropdown_loading
diff --git a/app/views/shared/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml
index 8308baa7829..17ffcba69d8 100644
--- a/app/views/shared/_user_callout.html.haml
+++ b/app/views/shared/_user_callout.html.haml
@@ -1,4 +1,4 @@
-.user-callout
+.user-callout{ data: { uid: 'user_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss customize experience box' }
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
new file mode 100644
index 00000000000..e6075c3ae3a
--- /dev/null
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -0,0 +1,30 @@
+- form = local_assigns.fetch(:form)
+- deploy_key = local_assigns.fetch(:deploy_key)
+
+= form_errors(deploy_key)
+
+.form-group
+ = form.label :title, class: 'control-label'
+ .col-sm-10= form.text_field :title, class: 'form-control'
+
+.form-group
+ - if deploy_key.new_record?
+ = form.label :key, class: 'control-label'
+ .col-sm-10
+ %p.light
+ Paste a machine public key here. Read more about how to generate it
+ = link_to 'here', help_page_path('ssh/README')
+ = form.text_area :key, class: 'form-control thin_area', rows: 5
+ - else
+ = form.label :fingerprint, class: 'control-label'
+ .col-sm-10
+ = form.text_field :fingerprint, class: 'form-control', readonly: 'readonly'
+
+.form-group
+ .control-label
+ .col-sm-10
+ = form.label :can_push do
+ = form.check_box :can_push
+ %strong Write access allowed
+ %p.light.append-bottom-0
+ Allow this key to push to repository as well? (Default only allows pull access.)
diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 7ef0ae96be2..307d4919224 100644
--- a/app/views/shared/issuable/form/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -1,10 +1,11 @@
- project = local_assigns.fetch(:project)
-- issuable = local_assigns.fetch(:issuable)
+- model = local_assigns.fetch(:model)
+
- form = local_assigns.fetch(:form)
-- supports_slash_commands = issuable.new_record?
+- supports_slash_commands = model.new_record?
- if supports_slash_commands
- - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name)
+ - preview_url = preview_markdown_path(project, slash_commands_target_type: model.class.name)
- else
- preview_url = preview_markdown_path(project)
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 37589b634fa..760370a6984 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,10 +1,10 @@
-.dropdown.inline
+.dropdown.inline.js-group-filter-dropdown-wrap
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light
- - if @sort.present?
- = sort_options_hash[@sort]
- - else
- = sort_title_recently_created
+ %span.dropdown-label
+ - if @sort.present?
+ = sort_options_hash[@sort]
+ - else
+ = sort_title_recently_created
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right
%li
diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_convdev_no_data.svg
new file mode 100644
index 00000000000..ed32b2333e7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_data.svg
@@ -0,0 +1,40 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/>
+ <g transform="translate(214 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/>
+ <path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994c0 1.11.895 2.003 2 2.003.174 0 .343-.022.503-.063.162.04.33.063.506.063h7.98C66.1 92 67 91.105 67 90c0-1.112-.9-2-2.01-2H58z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(118 7)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g fill-rule="nonzero">
+ <path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/>
+ <path fill="#6B4FBB" d="M41.692 105.8C45.768 109.75 51.21 112 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16c-4.215 0-8.166-1.633-11.133-4.508l-4.175 4.31z"/>
+ </g>
+ <path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998C8.895 18 8 17.112 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(26 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988C4 164.42 7.58 168 12.005 168h89.99c4.42 0 8.005-3.586 8.005-8.006V12.006C110 7.58 106.42 4 101.995 4h-89.99C7.585 4 4 7.586 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(38 42)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M4 14h106v4H4z"/>
+ <path fill="#333" d="M35.724 138h9.696v-2.856h-2.856V122.76h-2.592c-1.08.648-2.136 1.08-3.792 1.392v2.184h2.856v8.808h-3.312V138zm17.736.288c-2.952 0-5.76-2.208-5.76-7.56 0-5.688 2.952-8.256 6.168-8.256 2.016 0 3.48.84 4.44 1.824l-1.848 2.112c-.528-.576-1.488-1.08-2.376-1.08-1.68 0-3.024 1.2-3.144 4.752.792-1.008 2.112-1.608 3.048-1.608 2.616 0 4.536 1.488 4.536 4.704 0 3.168-2.304 5.112-5.064 5.112zm-.072-2.64c1.056 0 1.92-.744 1.92-2.472 0-1.608-.84-2.208-1.992-2.208-.792 0-1.68.432-2.304 1.512.312 2.4 1.32 3.168 2.376 3.168zM63.9 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_convdev_no_index.svg
new file mode 100644
index 00000000000..95c00e81d10
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_index.svg
@@ -0,0 +1,67 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200">
+ <g fill="none" fill-rule="evenodd" transform="translate(3 11)">
+ <rect width="110" height="168" x="6" y="8" fill="#000" fill-opacity=".02" rx="10"/>
+ <g transform="translate(0 2)">
+ <rect width="110" height="168" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M2 10.006v147.988C2 162.42 5.58 166 10.005 166h89.99c4.42 0 8.005-3.586 8.005-8.006V10.006C108 5.58 104.42 2 99.995 2h-89.99C5.585 2 2 5.586 2 10.006zm-4 0C-2 3.376 3.377-2 10.005-2h89.99C106.628-2 112 3.37 112 10.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C3.372 170-2 164.63-2 157.994V10.006z"/>
+ <g transform="translate(19 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(67 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(36 40)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M2 12h106v4H2z"/>
+ <path fill="#333" d="M38.048 127.792c.792 0 1.68-.432 2.28-1.512-.312-2.4-1.296-3.168-2.376-3.168-1.032 0-1.92.744-1.92 2.472 0 1.608.864 2.208 2.016 2.208zm-.552 8.496c-2.016 0-3.504-.864-4.464-1.824l1.872-2.112c.504.576 1.464 1.08 2.352 1.08 1.704 0 3.024-1.2 3.144-4.752-.792 1.008-2.112 1.608-3.048 1.608-2.592 0-4.536-1.488-4.536-4.704 0-3.168 2.304-5.112 5.064-5.112 2.952 0 5.784 2.208 5.784 7.56 0 5.688-2.976 8.256-6.168 8.256zm13.488 0c-3.048 0-5.304-1.704-5.304-4.176 0-1.848 1.152-2.976 2.592-3.744v-.096c-1.176-.888-2.04-1.992-2.04-3.6 0-2.592 2.04-4.2 4.872-4.2 2.784 0 4.632 1.656 4.632 4.176 0 1.464-.936 2.64-1.992 3.336v.096c1.464.792 2.64 1.968 2.64 3.984 0 2.4-2.16 4.224-5.4 4.224zm.96-9.168c.6-.696.936-1.44.936-2.232 0-1.176-.696-1.968-1.848-1.968-.936 0-1.704.576-1.704 1.752 0 1.248 1.056 1.848 2.616 2.448zm-.888 6.72c1.176 0 2.04-.624 2.04-1.896 0-1.344-1.296-1.848-3.216-2.664-.672.624-1.176 1.488-1.176 2.424 0 1.344 1.08 2.136 2.352 2.136zm10.8-3.84c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(122)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(243)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_convdev_overview.svg
new file mode 100644
index 00000000000..2f31113bad7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_overview.svg
@@ -0,0 +1,64 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="208" height="127" viewBox="0 0 208 127" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <rect id="a" width="58" height="98" y="17" rx="6"/>
+ <rect id="b" width="58" height="98" x="3.5" y="17" rx="6"/>
+ <rect id="c" width="58" height="98.394" rx="6"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" transform="translate(1)">
+ <path fill="#000" fill-opacity=".06" fill-rule="nonzero" d="M16 11.06c0-1.39.56-2.69 1.534-3.635.398-.386.41-1.025.027-1.426-.382-.402-1.015-.414-1.413-.028C14.785 7.294 14 9.116 14 11.062c0 .556.448 1.007 1 1.007s1-.452 1-1.01zm6.432-5.043h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0H185c1.21 0 2.354.435 3.254 1.215.42.362 1.05.314 1.41-.108.36-.423.312-1.06-.107-1.422C188.297 4.612 186.694 4 185 4h-.568c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zM190 11.932v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm0 10.89v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.84c0 .555.448 1.007 1 1.007s1-.453 1-1.01v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008V44.6c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01zm0 10.888v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.007zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm-.24 21.446c-.42 1.304-1.353 2.385-2.572 2.985-.497.244-.703.847-.46 1.348.24.5.84.708 1.336.464 1.707-.84 3.013-2.35 3.598-4.178.17-.53-.12-1.098-.644-1.27-.526-.17-1.09.12-1.26.65zm-8.063 3.49h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.577-.116c-1.33-.295-2.48-1.13-3.19-2.3-.287-.474-.902-.623-1.373-.333-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215.54.12 1.073-.224 1.192-.768.12-.544-.222-1.082-.762-1.2zM16 105.292v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01zm0-10.89v-4.84c0-.555-.448-1.007-1-1.007s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.007zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01zm0-11.888v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-9.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.007v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01z"/>
+ <g transform="translate(74)">
+ <rect width="58" height="98" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#a"/>
+ <rect width="56" height="96" x="1" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 45.185)">
+ <path fill="#333" d="M.59 33.815h5.655V32.15H4.58v-7.225H3.066c-.63.378-1.246.63-2.212.812v1.274H2.52v5.14H.59v1.665zm10.093.168c-1.778 0-3.094-.994-3.094-2.436 0-1.078.67-1.736 1.51-2.184v-.056c-.685-.518-1.19-1.162-1.19-2.1 0-1.512 1.19-2.45 2.843-2.45 1.624 0 2.702.966 2.702 2.436 0 .854-.546 1.54-1.162 1.946v.055c.854.462 1.54 1.148 1.54 2.324 0 1.4-1.26 2.463-3.15 2.463zm.56-5.348c.35-.406.546-.84.546-1.302 0-.686-.407-1.148-1.08-1.148-.545 0-.993.336-.993 1.022 0 .728.616 1.078 1.526 1.428zm-.518 3.92c.686 0 1.19-.364 1.19-1.106 0-.785-.756-1.08-1.876-1.555-.393.364-.687.868-.687 1.414 0 .783.63 1.245 1.372 1.245zm6.3-2.24c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.063 2.282 2.883 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.463h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.883 2.27-2.883 1.315 0 2.28 1.064 2.28 2.884 0 1.835-.965 2.913-2.28 2.913zm0-1.148c.46 0 .84-.462.84-1.764 0-1.3-.38-1.735-.84-1.735-.463 0-.84.434-.84 1.736 0 1.303.377 1.765.84 1.765z"/>
+ <rect width="13" height="2" x="6" y=".815" fill="#FB722E" rx="1"/>
+ <path fill="#F0EDF8" d="M3 47.815c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="6.815" fill="#FEE1D3" rx="1"/>
+ </g>
+ <g transform="translate(10.81)">
+ <circle cx="18.19" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.19 34c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16zm0 2c-9.94 0-18-8.06-18-18s8.06-18 18-18 18 8.06 18 18-8.06 18-18 18z"/>
+ <g transform="translate(10 11)">
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M2.19 13.32L5.397 11h7.783c.566 0 1.01-.444 1.01-1V3c0-.55-.45-1-1.01-1H3.2c-.566 0-1.01.444-1.01 1v10.32zM6.045 13l-3.422 2.476C1.28 16.45.19 15.892.19 14.23V3c0-1.657 1.337-3 3.01-3h9.98c1.663 0 3.01 1.342 3.01 3v7c0 1.657-1.337 3-3.01 3H6.045z"/>
+ <rect width="4" height="2" x="5.19" y="4" fill="#6B4FBB" rx="1"/>
+ <rect width="6" height="2" x="5.19" y="7" fill="#6B4FBB" rx="1"/>
+ </g>
+ </g>
+ </g>
+ <g transform="translate(144.5)">
+ <rect width="58" height="98" x=".5" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#b"/>
+ <rect width="56" height="96" x="4.5" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(19 46.185)">
+ <path fill="#333" d="M4.01 33.746c1.793 0 3.305-.938 3.305-2.59 0-1.148-.742-1.876-1.764-2.17v-.056c.953-.406 1.485-1.05 1.485-1.974 0-1.554-1.232-2.436-3.066-2.436-1.093 0-1.99.434-2.8 1.134l1.035 1.26c.56-.49 1.036-.784 1.666-.784.7 0 1.093.364 1.093.98 0 .714-.504 1.19-2.1 1.19v1.456c1.932 0 2.394.49 2.394 1.274 0 .672-.574 1.05-1.442 1.05-.756 0-1.414-.378-1.946-.896l-.953 1.302c.644.756 1.652 1.26 3.094 1.26zm4.51-.168h6.257v-1.736h-1.792c-.42 0-1.036.056-1.484.112 1.443-1.512 2.843-3.108 2.843-4.606 0-1.708-1.19-2.828-2.94-2.828-1.274 0-2.1.476-2.982 1.414l1.12 1.106c.45-.476.94-.91 1.583-.91.77 0 1.26.476 1.26 1.344 0 1.26-1.596 2.786-3.864 4.928v1.176zm9.505-3.5c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.064 2.282 2.884 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.464h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.064 2.28 2.884 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="7.5" fill="#FB722E" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3.5 47.19c0-.556.455-1.005 1.006-1.005h17.988c.556 0 1.006.445 1.006 1.004 0 .553-.455 1.003-1.006 1.003H4.506c-.556 0-1.006-.446-1.006-1.004zm0 6.023c0-.555.455-1.004 1.006-1.004h17.988c.556 0 1.006.444 1.006 1.003 0 .554-.455 1.004-1.006 1.004H4.506c-.556 0-1.006-.446-1.006-1.004z"/>
+ <rect width="20" height="2.008" x="4" y="6.024" fill="#FEE1D3" rx="1.004"/>
+ </g>
+ <g transform="translate(14.413)">
+ <circle cx="18.087" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.087 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M18.087 24c3.313 0 6-2.686 6-6s-2.687-6-6-6c-3.314 0-6 2.686-6 6s2.686 6 6 6zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8c4.418 0 8 3.582 8 8s-3.582 8-8 8z"/>
+ <path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3c0 .278.11.528.292.71.18.18.43.29.706.29h3c.557 0 1-.448 1-1 0-.556-.447-1-1-1h-2z"/>
+ </g>
+ </g>
+ <rect width="58" height="98" x="3" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <g transform="translate(0 16.754)">
+ <use fill="#FFF" xlink:href="#c"/>
+ <rect width="56" height="96.394" x="1" y="1" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 29.618)">
+ <path fill="#333" d="M3.137 27.84c.462 0 .98-.253 1.33-.883-.182-1.4-.756-1.848-1.386-1.848-.6 0-1.12.433-1.12 1.44 0 .94.505 1.29 1.177 1.29zm-.322 4.955C1.64 32.795.77 32.29.21 31.73l1.093-1.23c.294.335.854.63 1.372.63.994 0 1.764-.7 1.834-2.773-.463.588-1.233.938-1.78.938-1.51 0-2.645-.868-2.645-2.744 0-1.847 1.344-2.98 2.954-2.98 1.72 0 3.373 1.287 3.373 4.41 0 3.317-1.736 4.815-3.598 4.815zm8.12 0c-1.722 0-3.36-1.288-3.36-4.41 0-3.318 1.722-4.816 3.598-4.816 1.176 0 2.03.49 2.59 1.063l-1.078 1.232c-.308-.336-.868-.63-1.386-.63-.98 0-1.765.7-1.835 2.772.462-.588 1.232-.938 1.778-.938 1.526 0 2.646.867 2.646 2.743 0 1.848-1.345 2.982-2.955 2.982zm-.042-1.54c.616 0 1.12-.434 1.12-1.442 0-.938-.49-1.288-1.162-1.288-.46 0-.98.252-1.343.882.182 1.4.77 1.848 1.386 1.848zm6.132-2.128c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.065 2.282 2.885 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.463.84-1.765 0-1.302-.378-1.736-.84-1.736-.462 0-.84.433-.84 1.735s.378 1.764.84 1.764zm.308 4.815l4.928-9.464h1.19l-4.927 9.465h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.063 2.28 2.883 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="6.5" y=".314" fill="#FEE1D3" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3 46.627c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="5.627" fill="#FB722E" rx="1"/>
+ </g>
+ </g>
+ <g transform="translate(10.41)">
+ <circle cx="18.589" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.59 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" d="M17.05 19.262h3.367l.248-2.808H17.3l-.25 2.808zm-.177 2.008l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H13.59c-.554 0-1.003-.446-1.003-1.004 0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15c-.555 0-1.004-.445-1.004-1.004 0-.554.457-1.004 1.004-1.004h1.33l.106-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h3.365l.107-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h1.03c.554 0 1.003.446 1.003 1.004 0 .555-.455 1.004-1 1.004H22.8l-.25 2.808h1.037c.554 0 1.002.446 1.002 1.004 0 .554-.456 1.004-1.003 1.004h-1.214l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627h-3.367z"/>
+ <path fill="#6B4FBB" d="M17.05 19.262l-.177 2.008H14.74l.177-2.008h2.134zm-1.707-4.816h2.135l-.178 2.008h-2.135l.178-2.008zm5.5 0h2.135l-.178 2.008h-2.135l.178-2.008zm1.708 4.816l-.177 2.008H20.24l.177-2.008h2.134z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_1.svg b/app/views/shared/icons/_i2p_step_1.svg
new file mode 100644
index 00000000000..9dedcd5291a
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_1.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001-2.413.979-4.597 2.414-6.493 4.268-1.836 1.8-3.33 3.985-4.346 6.381-1.013 2.38-1.525 4.916-1.525 7.537 0 2.066.33 4.118.983 6.104.469 1.388 1.089 2.706 1.83 3.937-1.275 1.101-2.086 2.725-2.086 4.538 0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425-.665-1.105-1.221-2.289-1.642-3.533-.585-1.776-.881-3.618-.881-5.472 0-2.351.459-4.623 1.391-6.814.89-2.096 2.231-4.059 3.88-5.675 1.708-1.669 3.675-2.962 5.85-3.845 4.329-1.778 9.392-1.79 13.78.002 2.17.881 4.137 2.175 5.843 3.84 3.39 3.34 5.257 7.776 5.257 12.493.002 1.86-.294 3.705-.878 5.481-.579 1.75-1.443 3.406-2.569 4.923-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384h-10.815c-.553 0-1 .447-1 1s.447 1 1 1h11.739c.532 0 .971-.416.999-.947.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331 1.254-1.688 2.218-3.535 2.864-5.489.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92-1.897-1.851-4.081-3.287-6.49-4.265m-16.927 32.763c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m40 74h-4c-.553 0-1 .447-1 1s.447 1 1 1h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m42 70h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m38 10c.553 0 1-.447 1-1v-8c0-.553-.447-1-1-1s-1 .447-1 1v8c0 .553.447 1 1 1"/>
+ <path d="m20.828 15.828c.256 0 .512-.098.707-.293.391-.391.391-1.023 0-1.414l-5.656-5.656c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l5.656 5.656c.195.195.451.293.707.293"/>
+ <path d="m10 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m60.12 8.465l-5.656 5.656c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5.656-5.656c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0"/>
+ <path d="m74 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m43 66h-10c-.553 0-1 .447-1 1s.447 1 1 1h10c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_10.svg b/app/views/shared/icons/_i2p_step_10.svg
new file mode 100644
index 00000000000..dd6fd1457ff
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_10.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m5 43c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4"/>
+ <path d="m75 37h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 38c0 .345.178.665.47.848l8 5c.165.103.348.152.529.152.333 0 .659-.166.849-.47.293-.469.15-1.086-.317-1.378l-6.644-4.152 6.644-4.152c.468-.292.61-.909.317-1.378s-.908-.611-1.378-.317l-8 5c-.292.182-.47.502-.47.847"/>
+ <path d="m55 38c0-.345-.178-.665-.47-.848l-8-5c-.469-.294-1.086-.151-1.378.317-.293.469-.15 1.086.317 1.378l6.644 4.153-6.644 4.152c-.468.292-.61.909-.317 1.378.189.304.516.47.849.47.181 0 .364-.049.529-.152l8-5c.292-.183.47-.503.47-.848"/>
+ <path d="m41.803 26.05c-.525-.168-1.089.124-1.256.65l-7 22c-.167.525.124 1.088.65 1.256.101.032.202.047.303.047.424 0 .817-.271.953-.697l7-22c.167-.526-.124-1.088-.65-1.256"/>
+ <path d="m62 7c3.859 0 7 3.141 7 7v11c0 .553.447 1 1 1s1-.447 1-1v-11c0-4.963-4.04-9-9-9h-16.09c-.479-2.833-2.943-5-5.91-5-3.309 0-6 2.691-6 6s2.691 6 6 6c2.967 0 5.431-2.167 5.91-5h16.09m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m6 26c.553 0 1-.447 1-1v-11c0-3.859 3.141-7 7-7h11.09l-3.293 3.293c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5-5c.391-.391.391-1.023 0-1.414l-5-5c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l3.293 3.293h-11.09c-4.963 0-9 4.04-9 9v11c0 .553.447 1 1 1"/>
+ <path d="m36 64c-2.967 0-5.431 2.167-5.91 5h-16.09c-3.859 0-7-3.141-7-7v-11c0-.553-.447-1-1-1s-1 .447-1 1v11c0 4.963 4.04 9 9 9h16.09c.478 2.833 2.942 5 5.91 5 3.309 0 6-2.691 6-6s-2.691-6-6-6m0 10c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m70 50c-.553 0-1 .447-1 1v11c0 3.859-3.141 7-7 7h-11.09l3.293-3.293c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0l-5 5c-.391.391-.391 1.023 0 1.414l5 5c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414l-3.293-3.293h11.09c4.963 0 9-4.04 9-9v-11c0-.553-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_2.svg b/app/views/shared/icons/_i2p_step_2.svg
new file mode 100644
index 00000000000..b8805b90275
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_2.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m42.26 40.44c.558.073 1.045-.329 1.109-.877l2.625-22.444c.033-.283-.057-.567-.246-.781-.189-.214-.462-.336-.747-.336h-14c-.284 0-.555.121-.744.332-.19.212-.281.494-.25.776l3.454 31.575c-1.503 1.285-2.46 3.19-2.46 5.317 0 3.859 3.141 7 7 7s7-3.141 7-7-3.141-7-7-7c-.94 0-1.835.189-2.655.527l-3.23-29.527h11.761l-2.494 21.328c-.065.549.328 1.045.877 1.11m.741 13.562c0 2.757-2.243 5-5 5s-5-2.243-5-5 2.243-5 5-5 5 2.243 5 5"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.796-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_3.svg b/app/views/shared/icons/_i2p_step_3.svg
new file mode 100644
index 00000000000..6c783ed8289
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_3.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m12 8c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909 0 3.309 2.691 6 6 6s6-2.691 6-6c0-2.967-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.479 5-2.943 5-5.91m-10 0c0-2.206 1.794-4 4-4s4 1.794 4 4-1.794 4-4 4-4-1.794-4-4m8 60c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m21 6h54c.553 0 1-.447 1-1s-.447-1-1-1h-54c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m21 12h35c.553 0 1-.447 1-1s-.447-1-1-1h-35c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 24h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 32h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 44h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 52h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 64h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 70h-34c-.553 0-1 .447-1 1s.447 1 1 1h34c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_4.svg b/app/views/shared/icons/_i2p_step_4.svg
new file mode 100644
index 00000000000..af804c838e0
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_4.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m67.7 10h-6.751c-.507-5.598-5.221-10-10.949-10-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10h6.751c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2h-59.4c-1.269 0-2.3-.987-2.3-2.2v-57.6c0-1.213 1.031-2.2 2.3-2.2h15.15c.553 0 1-.447 1-1s-.447-1-1-1h-15.15c-2.371 0-4.3 1.884-4.3 4.2v57.6c0 2.316 1.929 4.2 4.3 4.2h59.4c2.371 0 4.3-1.884 4.3-4.2v-57.6c0-2.316-1.929-4.2-4.3-4.2m-17.7 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m21.293 29.29c-.391.391-.391 1.023 0 1.414l12.975 12.975-12.975 12.974c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l13.682-13.682c.391-.391.391-1.023 0-1.414l-13.682-13.681c-.391-.391-1.023-.391-1.414 0"/>
+ <path d="m54 59c.553 0 1-.447 1-1s-.447-1-1-1h-12c-.553 0-1 .447-1 1s.447 1 1 1h12"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_5.svg b/app/views/shared/icons/_i2p_step_5.svg
new file mode 100644
index 00000000000..e54f707019e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_5.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m48.949 37c-.507-5.598-5.221-10-10.949-10s-10.442 4.402-10.949 10h-13.05c-.553 0-1 .447-1 1s.447 1 1 1h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24c.553 0 1-.447 1-1s-.447-1-1-1h-12.24m-10.949 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.797-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_6.svg b/app/views/shared/icons/_i2p_step_6.svg
new file mode 100644
index 00000000000..c57baccc06b
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_6.svg
@@ -0,0 +1,15 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m14.267 7.32l-4.896 5.277-1.702-1.533c-.409-.369-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m31 9h44c.553 0 1-.447 1-1s-.447-1-1-1h-44c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m31 15h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 0c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 34.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m75 34h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m31 42h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 27c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 61.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m11 54c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m75 61h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 67h-24c-.553 0-1 .447-1 1s.447 1 1 1h24c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_7.svg b/app/views/shared/icons/_i2p_step_7.svg
new file mode 100644
index 00000000000..e9083de3afa
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_7.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="M73.236,23.749c-0.208-0.513-0.798-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+ <path d="m27.19 32.17c-.277-.479-.89-.643-1.366-.364l-12.654 7.326c-.309.179-.499.509-.499.865s.19.687.499.865l12.654 7.326c.157.092.33.135.5.135.345 0 .681-.179.866-.499.277-.478.113-1.09-.364-1.366l-11.159-6.461 11.159-6.461c.478-.276.642-.889.364-1.366"/>
+ <path d="m48.808 47.827c.186.32.521.499.866.499.17 0 .343-.043.5-.135l12.654-7.326c.309-.179.499-.509.499-.865s-.19-.687-.499-.865l-12.654-7.326c-.478-.278-1.09-.114-1.366.364-.277.478-.113 1.09.364 1.366l11.159 6.461-11.159 6.461c-.478.276-.642.889-.364 1.366"/>
+ <path d="m42.71 23.06l-11.312 33.23c-.179.522.102 1.091.624 1.269.106.037.216.054.322.054.416 0 .805-.262.946-.678l11.312-33.23c.179-.522-.102-1.091-.624-1.269-.523-.181-1.089.101-1.268.624"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_8.svg b/app/views/shared/icons/_i2p_step_8.svg
new file mode 100644
index 00000000000..62676b0e12e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_8.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m62.44 54.765l-9.912-11.09c.315-3.881.481-7.241.508-10.271-.029-13.871-3.789-23.05-13.413-32.746-.855-.859-2.411-.828-3.294.059-7.594 7.65-11.139 13.934-12.575 22.3-1.776.062-3.437.776-4.699 2.039-1.321 1.321-2.05 3.079-2.05 4.949s.729 3.628 2.051 4.949c1.321 1.322 3.079 2.051 4.949 2.051s3.628-.729 4.949-2.051c1.322-1.321 2.051-3.079 2.051-4.949 0-1.869-.729-3.627-2.051-4.949-.9-.9-2-1.517-3.205-1.824 1.373-7.859 4.764-13.818 11.999-21.11.128-.13.356-.158.456-.059 9.207 9.274 12.805 18.06 12.832 31.33-.026 3.079-.202 6.527-.536 10.54-.023.273.067.545.25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106-.062.063-.155.1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328c-.173-.329-.515-.535-.886-.535h-15.962c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072c-.07.07-.165.105-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338c-.029-.146.019-.301.049-.34l10.197-11.415c.367-.412.332-1.044-.08-1.412-.411-.366-1.042-.333-1.412.08l-10.229 11.453c-.448.554-.63 1.312-.474 2.084l4.544 17.396c.253.963 1.146 1.669 2.218 1.719h3.636c.581 0 1.187-.261 1.615-.693.114-.114.286-.286.406-.528l5.144-9.793h14.754l5.16 9.822c.396.697 1.124 1.143 2.01 1.192l3.712-.003c.604-.046 1.137-.285 1.544-.694.313-.316.504-.646.598-1.022l4.557-17.451c.143-.718-.039-1.476-.518-2.066m-33.435-24.765c0 1.335-.521 2.591-1.465 3.535s-2.2 1.465-3.535 1.465-2.591-.521-3.535-1.465-1.465-2.2-1.465-3.535.521-2.591 1.465-3.535 2.2-1.465 3.535-1.465 2.591.521 3.535 1.465 1.465 2.2 1.465 3.535"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_9.svg b/app/views/shared/icons/_i2p_step_9.svg
new file mode 100644
index 00000000000..e4285a14425
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_9.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m68 67c-1.725 0-3.36.541-4.723 1.545-2.298-4.02-6.592-6.545-11.277-6.545-2.734 0-5.359.853-7.555 2.43l-2.286-15.43h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003c.507-.039.974-.25 1.316-.595.264-.266.433-.559.514-.882l3.433-13.145c.12-.611-.037-1.258-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641-.023-10.507-2.871-17.462-10.162-24.806-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463-1.267.186-2.438.764-3.36 1.686-1.134 1.134-1.758 2.64-1.758 4.243s.624 3.109 1.758 4.242c1.133 1.134 2.639 1.758 4.242 1.758s3.109-.624 4.242-1.758c1.134-1.133 1.758-2.639 1.758-4.242s-.624-3.109-1.758-4.242c-.858-.859-1.932-1.424-3.098-1.648 1.095-5.538 3.637-9.855 8.83-15.14 6.874 6.924 9.561 13.485 9.581 23.392-.021 2.316-.151 4.903-.402 7.91-.023.273.067.544.25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15c-.17-.339-.516-.553-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588c.367-.412.332-1.044-.08-1.412-.411-.366-1.043-.333-1.412.08l-7.7 8.623c-.383.47-.54 1.116-.406 1.787l3.419 13.08c.216.829.98 1.438 1.907 1.48h2.735c.508 0 1.016-.218 1.391-.595.091-.09.242-.241.358-.475l3.804-7.597h1.228l-2.286 15.43c-2.196-1.577-4.821-2.43-7.555-2.43-4.685 0-8.979 2.53-11.277 6.545-1.363-1-2.998-1.545-4.723-1.545-4.411 0-8 3.589-8 8 0 .553.447 1 1 1h74c.553 0 1-.447 1-1 0-4.411-3.589-8-8-8m-36-44c0 1.068-.416 2.072-1.172 2.828-1.512 1.512-4.145 1.512-5.656 0-.756-.756-1.172-1.76-1.172-2.828s.416-2.072 1.172-2.828 1.76-1.172 2.828-1.172 2.072.416 2.828 1.172 1.172 1.76 1.172 2.828m-29.917 51c.478-2.834 2.949-5 5.917-5 1.638 0 3.17.652 4.313 1.836.231.24.562.35.895.29.327-.058.604-.274.739-.579 1.765-3.977 5.711-6.547 10.05-6.547 2.836 0 5.532 1.085 7.593 3.055.271.258.665.345 1.016.224.354-.122.61-.43.665-.8l2.588-17.479h4.275l2.589 17.479c.055.37.312.678.665.8s.745.035 1.016-.224c2.061-1.97 4.757-3.055 7.593-3.055 4.343 0 8.288 2.57 10.05 6.547.135.305.412.521.739.579.329.059.663-.051.895-.29 1.143-1.184 2.675-1.836 4.313-1.836 2.968 0 5.439 2.166 5.917 5h-71.834"/>
+</svg>
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
new file mode 100644
index 00000000000..a8a6d84128d
--- /dev/null
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -0,0 +1,53 @@
+- type = local_assigns.fetch(:type)
+
+%aside.issues-bulk-update.js-right-sidebar.right-sidebar.affix-top{ data: { "offset-top" => "50", "spy" => "affix" }, "aria-live" => "polite" }
+ .issuable-sidebar
+ = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do
+ .block
+ .filter-item.inline.update-issues-btn.pull-left
+ = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true
+ = button_tag "Cancel", class: "btn btn-default js-bulk-update-menu-hide pull-right"
+ .block
+ .title
+ Status
+ .filter-item
+ = dropdown_tag("Select status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
+ %ul
+ %li
+ %a{ href: "#", data: { id: "reopen" } } Open
+ %li
+ %a{ href: "#", data: { id: "close" } } Closed
+ .block
+ .title
+ Assignee
+ .filter-item
+ - if type == :issues
+ - field_name = "update[assignee_ids][]"
+ - else
+ - field_name = "update[assignee_id]"
+ = dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
+ placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
+ .block
+ .title
+ Milestone
+ .filter-item
+ = dropdown_tag("Select milestone", options: { title: "Assign milestone", toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } })
+ .block
+ .title
+ Labels
+ .filter-item.labels-filter
+ = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: "Apply a label", show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }, label_name: "Select labels", no_default_styles: true
+ .block
+ .title
+ Subscriptions
+ .filter-item
+ = dropdown_tag("Select subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
+ %ul
+ %li
+ %a{ href: "#", data: { id: "subscribe" } } Subscribe
+ %li
+ %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
+
+ = hidden_field_tag "update[issuable_ids]", []
+ = hidden_field_tag :state_event, params[:state_event]
+
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 6cd03f028a9..2cabbc8c560 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -6,10 +6,6 @@
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- - if @bulk_edit
- .check-all-holder
- = check_box_tag "check_all_issues", nil, false,
- class: "check_all_issues left"
.issues-other-filters
.filter-item.inline
- if params[:author_id].present?
@@ -36,35 +32,6 @@
.pull-right
= render 'shared/sort_dropdown'
- - if @bulk_edit
- .issues_bulk_update.hide
- = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
- .filter-item.inline
- = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "reopen" } } Open
- %li
- %a{ href: "#", data: {id: "close" } } Closed
- .filter-item.inline
- = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
- placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } })
- .filter-item.inline
- = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
- .filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
- .filter-item.inline
- = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "subscribe" } } Subscribe
- %li
- %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
-
- = hidden_field_tag 'update[issuable_ids]', []
- = hidden_field_tag :state_event, params[:state_event]
- .filter-item.inline
- = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
- has_labels = @labels && @labels.any?
.row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
- if has_labels
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 7748351b333..c016aa2abcd 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -17,7 +17,7 @@
= render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
-= render 'shared/issuable/form/description', issuable: issuable, form: form, project: project
+= render 'shared/form_elements/description', model: issuable, form: form, project: project
- if issuable.respond_to?(:confidential)
.form-group
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index 1cf662e29c4..34911fd2712 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -11,6 +11,8 @@
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"}
- dropdown_data.merge!(data_options)
+- label_name = local_assigns.fetch(:label_name, "Labels")
+- no_default_styles = local_assigns.fetch(:no_default_styles, false)
- classes << 'js-extra-options' if extra_options
- classes << 'js-filter-submit' if filter_submit
@@ -20,8 +22,9 @@
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect{ class: classes.join(' '), type: "button", data: dropdown_data }
- %span.dropdown-toggle-text{ class: ("is-default" if selected.nil? || selected.empty?) }
- = multi_label_name(selected, "Labels")
+ - apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles
+ %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) }
+ = multi_label_name(selected, label_name)
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create }
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index ad995cbe962..cf7ba52d840 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -1,25 +1,24 @@
- type = local_assigns.fetch(:type, :issues)
- page_context_word = type.to_s.humanize(capitalize: false)
- issuables = @issues || @merge_requests
+- closed_title = 'Filter by issues that are currently closed.'
%ul.nav-links.issues-state-filters
%li{ class: active_when(params[:state] == 'opened') }>
- = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened." do
+ %button.btn.btn-link{ id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", type: 'button', data: { state: 'opened' } }
#{issuables_state_counter_text(type, :opened)}
- if type == :merge_requests
%li{ class: active_when(params[:state] == 'merged') }>
- = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.' do
+ %button.btn.btn-link{ id: 'state-merged', title: 'Filter by merge requests that are currently merged.', type: 'button', data: { state: 'merged' } }
#{issuables_state_counter_text(type, :merged)}
- %li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.' do
- #{issuables_state_counter_text(type, :closed)}
- - else
- %li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-all', title: 'Filter by issues that are currently closed.' do
- #{issuables_state_counter_text(type, :closed)}
+ - closed_title = 'Filter by merge requests that are currently closed and unmerged.'
+
+ %li{ class: active_when(params[:state] == 'closed') }>
+ %button.btn.btn-link{ id: 'state-closed', title: closed_title, type: 'button', data: { state: 'closed' } }
+ #{issuables_state_counter_text(type, :closed)}
%li{ class: active_when(params[:state] == 'all') }>
- = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}." do
+ %button.btn.btn-link{ id: 'state-all', title: "Show all #{page_context_word}.", type: 'button', data: { state: 'all' } }
#{issuables_state_counter_text(type, :all)}
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index be9f9ee29c4..d3d290692a2 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -6,10 +6,9 @@
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- - if @bulk_edit
- .check-all-holder
- = check_box_tag "check_all_issues", nil, false,
- class: "check_all_issues left"
+ - if @can_bulk_update
+ .check-all-holder.hidden
+ = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
.issues-other-filters.filtered-search-wrapper
.filtered-search-box
- if type != :boards_modal && type != :boards
@@ -110,55 +109,11 @@
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
- - if @bulk_edit
- .issues_bulk_update.hide
- = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
- .filter-item.inline
- = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "reopen" } } Open
- %li
- %a{ href: "#", data: { id: "close" } } Closed
- .filter-item.inline
- - if type == :issues
- - field_name = "update[assignee_ids][]"
- - else
- - field_name = "update[assignee_id]"
-
- = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
- placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
- .filter-item.inline
- = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } })
- .filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }
- .filter-item.inline
- = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "subscribe" } } Subscribe
- %li
- %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
-
- = hidden_field_tag 'update[issuable_ids]', []
- = hidden_field_tag :state_event, params[:state_event]
- .filter-item.inline.update-issues-btn
- = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
-
- unless type === :boards_modal
:javascript
- new LabelsSelect();
- new MilestoneSelect();
- new IssueStatusSelect();
- new SubscriptionSelect();
-
$(document).off('page:restore').on('page:restore', function (event) {
if (gl.FilteredSearchManager) {
const filteredSearchManager = new gl.FilteredSearchManager();
filteredSearchManager.setup();
}
- Issuable.init();
- new gl.IssuableBulkActions({
- prefixId: 'issue_',
- });
});
diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml
index fb795ad1c72..d97fdf179d7 100644
--- a/app/views/shared/members/_access_request_buttons.html.haml
+++ b/app/views/shared/members/_access_request_buttons.html.haml
@@ -2,16 +2,17 @@
.project-action-button.inline
- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id))
- = link_to "Leave #{model_name}", polymorphic_path([:leave, source, :members]),
+ - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
+ = link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source) },
class: 'btn'
- elsif requester = source.requesters.find_by(user_id: current_user.id)
- = link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]),
+ = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: remove_member_message(requester) },
class: 'btn'
- elsif source.request_access_enabled && can?(current_user, :request_access, source)
- = link_to 'Request Access', polymorphic_path([:request_access, source, :members]),
+ = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
method: :post,
class: 'btn'
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index 1d072c16b32..e99d8d0973f 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -6,14 +6,14 @@
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
= icon('caret-down')
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
= icon("caret-down")
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 183ed34fba1..752932e6045 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -5,7 +5,7 @@
%button.close{ type: "button", "aria-label": "close", data: { dismiss: "modal" } }
%span{ "aria-hidden": "true" } } ×
%h4#custom-notifications-title.modal-title
- Custom notification events
+ #{ _('Custom notification events') }
.modal-body
.container-fluid
@@ -13,12 +13,11 @@
= hidden_setting_source_input(notification_setting)
.row
.col-lg-4
- %h4.prepend-top-0
- Notification events
+ %h4.prepend-top-0= _('Notification events')
%p
- Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out
- = succeed "." do
- %a{ href: help_page_path('workflow/notifications'), target: "_blank" } notification emails
+ - notification_link = link_to _('notification emails'), help_page_path('workflow/notifications'), target: '_blank'
+ - paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe }
+ #{ paragraph.html_safe }
.col-lg-8
- NotificationSetting::EMAIL_EVENTS.each_with_index do |event, index|
- field_id = "#{notifications_menu_identifier("modal", notification_setting)}_notification_setting[#{event}]"
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index cf0540afb38..fbc335f6176 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -7,7 +7,7 @@
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project)
-- updated_tooltip = time_ago_with_tooltip(project.updated_at)
+- updated_tooltip = time_ago_with_tooltip(project.last_activity_at)
%li.project-row{ class: css_class }
= cache(cache_key) do
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 0296597b294..8549cb91b03 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -3,7 +3,7 @@
= page_specific_javascript_bundle_tag('snippet')
.snippet-form-holder
- = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit" } do |f|
+ = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
.form-group
@@ -11,6 +11,8 @@
.col-sm-10
= f.text_field :title, class: 'form-control', required: true, autofocus: true
+ = render 'shared/form_elements/description', model: @snippet, project: @project, form: f
+
= render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet
.file-editor
@@ -23,6 +25,9 @@
.file-content.code
%pre#editor= @snippet.content
= f.hidden_field :content, class: 'snippet-file-content'
+ - if params[:files]
+ - params[:files].each_with_index do |file, index|
+ = hidden_field_tag "files[]", file, id: "files_#{index}"
.form-actions
- if @snippet.new_record?
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 501c09d71d5..813d8d69d8d 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -22,3 +22,9 @@
- if @snippet.updated_at != @snippet.created_at
= edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
+ - if @snippet.description.present?
+ .description
+ .wiki
+ = markdown_field(@snippet, :description)
+ %textarea.hidden.js-task-list-field
+ = @snippet.description
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index e8119642ab8..098a88c48c5 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -6,8 +6,5 @@
%span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- - if note_editable
- = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
- = icon('pencil', class: 'link-highlight')
- = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
- = icon('trash-o', class: 'danger-highlight')
+
+ = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index c239253c8d5..f246bd7a586 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -100,7 +100,7 @@
Snippets
%div{ class: container_class }
- - if @user == current_user && show_user_callout?
+ - if @user == current_user && show_callout?('user_callout_dismissed')
= render 'shared/user_callout'
.tab-content
#activity.tab-pane
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index e17add7421f..bf009dfab0f 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -11,15 +11,6 @@ class BuildSuccessWorker
private
def create_deployment(build)
- service = CreateDeploymentService.new(
- build.project, build.user,
- environment: build.environment,
- sha: build.sha,
- ref: build.ref,
- tag: build.tag,
- options: build.options.to_h[:environment],
- variables: build.variables)
-
- service.execute(build)
+ CreateDeploymentService.new(build).execute
end
end
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index 2f02235b0ac..0a55aab63fd 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -3,29 +3,17 @@ class GitlabUsagePingWorker
include Sidekiq::Worker
include CronjobQueue
- include HTTParty
def perform
- return unless current_application_settings.usage_ping_enabled
-
# Multiple Sidekiq workers could run this. We should only do this at most once a day.
return unless try_obtain_lease
- begin
- HTTParty.post(url,
- body: Gitlab::UsageData.to_json(force_refresh: true),
- headers: { 'Content-type' => 'application/json' }
- )
- rescue HTTParty::Error => e
- Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
- end
+ SubmitUsagePingService.new.execute
end
+ private
+
def try_obtain_lease
Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain
end
-
- def url
- 'https://version.gitlab.com/usage_data'
- end
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index c29571d3c62..89286595ca6 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -17,14 +17,15 @@ class PostReceive
post_received = Gitlab::GitPostReceive.new(project, identifier, changes)
if is_wiki
- # Nothing defined here yet.
+ process_wiki_changes(post_received)
else
process_project_changes(post_received)
- process_repository_update(post_received)
end
end
- def process_repository_update(post_received)
+ private
+
+ def process_project_changes(post_received)
changes = []
refs = Set.new
@@ -36,32 +37,27 @@ class PostReceive
return false
end
- changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref)
- refs << ref
- end
-
- hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, @user, changes, refs.to_a)
- SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks)
- end
-
- def process_project_changes(post_received)
- post_received.changes_refs do |oldrev, newrev, ref|
- @user ||= post_received.identify(newrev)
-
- unless @user
- log("Triggered hook for non-existing user \"#{post_received.identifier}\"")
- return false
- end
-
if Gitlab::Git.tag_ref?(ref)
GitTagPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute
elsif Gitlab::Git.branch_ref?(ref)
GitPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute
end
+
+ changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref)
+ refs << ref
end
+
+ after_project_changes_hooks(post_received, @user, refs.to_a, changes)
end
- private
+ def after_project_changes_hooks(post_received, user, refs, changes)
+ hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, user, changes, refs)
+ SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks)
+ end
+
+ def process_wiki_changes(post_received)
+ # Nothing defined here yet.
+ end
# To maintain backwards compatibility, we accept both gl_repository or
# repository paths as project identifiers. Our plan is to migrate to
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index efc99ec962a..a338523dc6b 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -1,4 +1,6 @@
class RepositoryForkWorker
+ ForkError = Class.new(StandardError)
+
include Sidekiq::Worker
include Gitlab::ShellAdapter
include DedicatedSidekiqQueue
@@ -8,29 +10,31 @@ class RepositoryForkWorker
source_path: source_path,
target_path: target_path)
- project = Project.find_by_id(project_id)
-
- unless project.present?
- logger.error("Project #{project_id} no longer exists!")
- return
- end
+ project = Project.find(project_id)
+ project.import_start
result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_path,
project.repository_storage_path, target_path)
- unless result
- logger.error("Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}")
- project.mark_import_as_failed('The project could not be forked.')
- return
- end
+ raise ForkError, "Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}" unless result
project.repository.after_import
-
- unless project.valid_repo?
- logger.error("Project #{project_id} had an invalid repository after fork")
- project.mark_import_as_failed('The forked repository is invalid.')
- return
- end
+ raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo?
project.import_finish
+ rescue ForkError => ex
+ fail_fork(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_fork(project, ex.message)
+ raise ForkError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def fail_fork(project, message)
+ Rails.logger.error(message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index b33ba2ed7c1..625476b7e01 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -1,4 +1,6 @@
class RepositoryImportWorker
+ ImportError = Class.new(StandardError)
+
include Sidekiq::Worker
include DedicatedSidekiqQueue
@@ -10,6 +12,8 @@ class RepositoryImportWorker
@project = Project.find(project_id)
@current_user = @project.creator
+ project.import_start
+
Gitlab::Metrics.add_event(:import_repository,
import_url: @project.import_url,
path: @project.path_with_namespace)
@@ -17,13 +21,23 @@ class RepositoryImportWorker
project.update_columns(import_jid: self.jid, import_error: nil)
result = Projects::ImportService.new(project, current_user).execute
-
- if result[:status] == :error
- project.mark_import_as_failed(result[:message])
- return
- end
+ raise ImportError, result[:message] if result[:status] == :error
project.repository.after_import
project.import_finish
+ rescue ImportError => ex
+ fail_import(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_import(project, ex.message)
+ raise ImportError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def fail_import(project, message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/changelogs/unreleased/12614-fix-long-message.yml b/changelogs/unreleased/12614-fix-long-message.yml
new file mode 100644
index 00000000000..94f8127c3c1
--- /dev/null
+++ b/changelogs/unreleased/12614-fix-long-message.yml
@@ -0,0 +1,4 @@
+---
+title: Fix long urls in the title of commit
+merge_request: 10938
+author: Alexander Randa
diff --git a/changelogs/unreleased/12910-snippets-description.yml b/changelogs/unreleased/12910-snippets-description.yml
new file mode 100644
index 00000000000..ac3d754fee1
--- /dev/null
+++ b/changelogs/unreleased/12910-snippets-description.yml
@@ -0,0 +1,4 @@
+---
+title: Support descriptions for snippets
+merge_request:
+author:
diff --git a/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml
new file mode 100644
index 00000000000..9c17c3b949c
--- /dev/null
+++ b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml
@@ -0,0 +1,4 @@
+---
+title: Introduce an Events API
+merge_request: 11755
+author:
diff --git a/changelogs/unreleased/23603-add-extra-functionality-for-the-top-right-button.yml b/changelogs/unreleased/23603-add-extra-functionality-for-the-top-right-button.yml
new file mode 100644
index 00000000000..77f8e31e16e
--- /dev/null
+++ b/changelogs/unreleased/23603-add-extra-functionality-for-the-top-right-button.yml
@@ -0,0 +1,4 @@
+---
+title: Add extra context-sensitive functionality for the top right menu button
+merge_request: 11632
+author:
diff --git a/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml b/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml
new file mode 100644
index 00000000000..dbd8a538d51
--- /dev/null
+++ b/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml
@@ -0,0 +1,4 @@
+---
+title: Automatically adjust project settings to match changes in project visibility
+merge_request: 11831
+author:
diff --git a/changelogs/unreleased/25426-group-dashboard-ui.yml b/changelogs/unreleased/25426-group-dashboard-ui.yml
new file mode 100644
index 00000000000..cc2bf62d07b
--- /dev/null
+++ b/changelogs/unreleased/25426-group-dashboard-ui.yml
@@ -0,0 +1,4 @@
+---
+title: Update Dashboard Groups UI with better support for subgroups
+merge_request:
+author:
diff --git a/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml
new file mode 100644
index 00000000000..af9fe3b5041
--- /dev/null
+++ b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml
@@ -0,0 +1,4 @@
+---
+title: Add $CI_ENVIRONMENT_URL to predefined variables for pipelines
+merge_request: 11695
+author:
diff --git a/changelogs/unreleased/27148-limit-bulk-create-memberships.yml b/changelogs/unreleased/27148-limit-bulk-create-memberships.yml
new file mode 100644
index 00000000000..ac4aba2f4e0
--- /dev/null
+++ b/changelogs/unreleased/27148-limit-bulk-create-memberships.yml
@@ -0,0 +1,4 @@
+---
+title: Limit non-administrators to adding 100 members at a time to groups and projects
+merge_request: 11940
+author:
diff --git a/changelogs/unreleased/27614-improve-instant-comments-exp.yml b/changelogs/unreleased/27614-improve-instant-comments-exp.yml
new file mode 100644
index 00000000000..4db676801f1
--- /dev/null
+++ b/changelogs/unreleased/27614-improve-instant-comments-exp.yml
@@ -0,0 +1,4 @@
+---
+title: Improve user experience around slash commands in instant comments
+merge_request: 11612
+author:
diff --git a/changelogs/unreleased/28607-forking-and-configuring-project-via-api-works-very-unreliable.yml b/changelogs/unreleased/28607-forking-and-configuring-project-via-api-works-very-unreliable.yml
new file mode 100644
index 00000000000..9cf8d745f92
--- /dev/null
+++ b/changelogs/unreleased/28607-forking-and-configuring-project-via-api-works-very-unreliable.yml
@@ -0,0 +1,4 @@
+---
+title: Confirm Project forking behaviour via the API
+merge_request:
+author:
diff --git a/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml
new file mode 100644
index 00000000000..2308a528580
--- /dev/null
+++ b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml
@@ -0,0 +1,4 @@
+---
+title: Allow users to be hard-deleted from the admin panel
+merge_request: 11874
+author:
diff --git a/changelogs/unreleased/28694-hard-delete-user-from-api.yml b/changelogs/unreleased/28694-hard-delete-user-from-api.yml
new file mode 100644
index 00000000000..ad46540495c
--- /dev/null
+++ b/changelogs/unreleased/28694-hard-delete-user-from-api.yml
@@ -0,0 +1,4 @@
+---
+title: Allow users to be hard-deleted from the API
+merge_request: 11853
+author:
diff --git a/changelogs/unreleased/29118-add-prometheus-instrumenting-to-gitlab-webapp.yml b/changelogs/unreleased/29118-add-prometheus-instrumenting-to-gitlab-webapp.yml
new file mode 100644
index 00000000000..99c55f128e3
--- /dev/null
+++ b/changelogs/unreleased/29118-add-prometheus-instrumenting-to-gitlab-webapp.yml
@@ -0,0 +1,4 @@
+---
+title: Add prometheus based metrics collection to gitlab webapp
+merge_request:
+author:
diff --git a/changelogs/unreleased/29690-rotate-otp-key-base.yml b/changelogs/unreleased/29690-rotate-otp-key-base.yml
new file mode 100644
index 00000000000..94d73a24758
--- /dev/null
+++ b/changelogs/unreleased/29690-rotate-otp-key-base.yml
@@ -0,0 +1,4 @@
+---
+title: Add a Rake task to aid in rotating otp_key_base
+merge_request: 11881
+author:
diff --git a/changelogs/unreleased/30378-simplified-repository-settings-page.yml b/changelogs/unreleased/30378-simplified-repository-settings-page.yml
new file mode 100644
index 00000000000..e8b87c8bb33
--- /dev/null
+++ b/changelogs/unreleased/30378-simplified-repository-settings-page.yml
@@ -0,0 +1,4 @@
+---
+title: Simplify project repository settings page
+merge_request: 11698
+author:
diff --git a/changelogs/unreleased/30469-convdev-index.yml b/changelogs/unreleased/30469-convdev-index.yml
new file mode 100644
index 00000000000..0bdd9c4a699
--- /dev/null
+++ b/changelogs/unreleased/30469-convdev-index.yml
@@ -0,0 +1,4 @@
+---
+title: Add ConvDev Index page to admin area
+merge_request: 11377
+author:
diff --git a/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml
new file mode 100644
index 00000000000..e71910dbd67
--- /dev/null
+++ b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml
@@ -0,0 +1,4 @@
+---
+title: Add slugify project path to CI enviroment variables
+merge_request: 11838
+author: Ivan Chernov
diff --git a/changelogs/unreleased/31633-animate-issue.yml b/changelogs/unreleased/31633-animate-issue.yml
new file mode 100644
index 00000000000..6df4135b09c
--- /dev/null
+++ b/changelogs/unreleased/31633-animate-issue.yml
@@ -0,0 +1,4 @@
+---
+title: animate adding issue to boards
+merge_request:
+author:
diff --git a/changelogs/unreleased/31757-single-click-on-filter-in-search-bar-to-activate-dropdown.yml b/changelogs/unreleased/31757-single-click-on-filter-in-search-bar-to-activate-dropdown.yml
new file mode 100644
index 00000000000..48b8a8507ec
--- /dev/null
+++ b/changelogs/unreleased/31757-single-click-on-filter-in-search-bar-to-activate-dropdown.yml
@@ -0,0 +1,4 @@
+---
+title: Single click on filter to open filtered search dropdown
+merge_request:
+author:
diff --git a/changelogs/unreleased/31840-add-a-rubocop-that-forbids-redirect_to-inside-a-controller-destroy-action-without-an-explicit-status.yml b/changelogs/unreleased/31840-add-a-rubocop-that-forbids-redirect_to-inside-a-controller-destroy-action-without-an-explicit-status.yml
new file mode 100644
index 00000000000..52bfe771e2b
--- /dev/null
+++ b/changelogs/unreleased/31840-add-a-rubocop-that-forbids-redirect_to-inside-a-controller-destroy-action-without-an-explicit-status.yml
@@ -0,0 +1,4 @@
+---
+title: Add a rubocop rule to check if a method 'redirect_to' is used without explicitly set 'status' in 'destroy' actions of controllers
+merge_request: 11749
+author: @blackst0ne
diff --git a/changelogs/unreleased/3191-deploy-keys-update.yml b/changelogs/unreleased/3191-deploy-keys-update.yml
new file mode 100644
index 00000000000..4100163e94f
--- /dev/null
+++ b/changelogs/unreleased/3191-deploy-keys-update.yml
@@ -0,0 +1,4 @@
+---
+title: Implement ability to update deploy keys
+merge_request: 10383
+author: Alexander Randa
diff --git a/changelogs/unreleased/31943-document-go-183.yml b/changelogs/unreleased/31943-document-go-183.yml
new file mode 100644
index 00000000000..201cd48f1ab
--- /dev/null
+++ b/changelogs/unreleased/31943-document-go-183.yml
@@ -0,0 +1,3 @@
+---
+title: Upgrade dependency to Go 1.8.3
+merge_request: 31943
diff --git a/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml b/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml
new file mode 100644
index 00000000000..f61aa0a6b6e
--- /dev/null
+++ b/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml
@@ -0,0 +1,4 @@
+---
+title: Increase individual diff collapse limit to 100 KB, and render limit to 200 KB
+merge_request:
+author:
diff --git a/changelogs/unreleased/32118-new-environment-btn-copy.yml b/changelogs/unreleased/32118-new-environment-btn-copy.yml
new file mode 100644
index 00000000000..16a51c3db6a
--- /dev/null
+++ b/changelogs/unreleased/32118-new-environment-btn-copy.yml
@@ -0,0 +1,4 @@
+---
+title: Make New environment empty state btn lowercase
+merge_request:
+author:
diff --git a/changelogs/unreleased/32642_last_commit_id_in_file_api.yml b/changelogs/unreleased/32642_last_commit_id_in_file_api.yml
new file mode 100644
index 00000000000..80435352e10
--- /dev/null
+++ b/changelogs/unreleased/32642_last_commit_id_in_file_api.yml
@@ -0,0 +1,4 @@
+---
+title: 'Introduce optimistic locking support via optional parameter last_commit_sha on File Update API'
+merge_request: 11694
+author: electroma
diff --git a/changelogs/unreleased/32715-fix-note-padding.yml b/changelogs/unreleased/32715-fix-note-padding.yml
deleted file mode 100644
index 867ed7eb171..00000000000
--- a/changelogs/unreleased/32715-fix-note-padding.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Make all notes use equal padding
-merge_request:
-author:
diff --git a/changelogs/unreleased/32720-emoji-spacing.yml b/changelogs/unreleased/32720-emoji-spacing.yml
new file mode 100644
index 00000000000..da3df0f9093
--- /dev/null
+++ b/changelogs/unreleased/32720-emoji-spacing.yml
@@ -0,0 +1,4 @@
+---
+title: Create equal padding for emoji
+merge_request:
+author:
diff --git a/changelogs/unreleased/32832-confidential-issue-overflow.yml b/changelogs/unreleased/32832-confidential-issue-overflow.yml
new file mode 100644
index 00000000000..7d3d3bfed2e
--- /dev/null
+++ b/changelogs/unreleased/32832-confidential-issue-overflow.yml
@@ -0,0 +1,5 @@
+---
+title: Remove overflow from comment form for confidential issues and vertically aligns
+ confidential issue icon
+merge_request:
+author:
diff --git a/changelogs/unreleased/32955-special-keywords.yml b/changelogs/unreleased/32955-special-keywords.yml
new file mode 100644
index 00000000000..0f9939ced8c
--- /dev/null
+++ b/changelogs/unreleased/32955-special-keywords.yml
@@ -0,0 +1,4 @@
+---
+title: Add all pipeline sources as special keywords to 'only' and 'except'
+merge_request: 11844
+author: Filip Krakowski
diff --git a/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml
new file mode 100644
index 00000000000..93037d6181e
--- /dev/null
+++ b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml
@@ -0,0 +1,4 @@
+---
+title: Use zopfli compression for frontend assets
+merge_request: 11798
+author:
diff --git a/changelogs/unreleased/32995-issue-contents-dynamically-replaced-with-stale-version-after-saving-or-refreshing-relative-external_url-only.yml b/changelogs/unreleased/32995-issue-contents-dynamically-replaced-with-stale-version-after-saving-or-refreshing-relative-external_url-only.yml
new file mode 100644
index 00000000000..5cd36a4e3e2
--- /dev/null
+++ b/changelogs/unreleased/32995-issue-contents-dynamically-replaced-with-stale-version-after-saving-or-refreshing-relative-external_url-only.yml
@@ -0,0 +1,4 @@
+---
+title: Fix incorrect ETag cache key when relative instance URL is used
+merge_request: 11964
+author:
diff --git a/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml
new file mode 100644
index 00000000000..3b98525167d
--- /dev/null
+++ b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml
@@ -0,0 +1,4 @@
+---
+title: Allow group reporters to manage group labels
+merge_request:
+author:
diff --git a/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml
new file mode 100644
index 00000000000..07dd0872d3b
--- /dev/null
+++ b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml
@@ -0,0 +1,4 @@
+---
+title: Add Chinese translation of Cycle Analytics Page to I18N
+merge_request: 11644
+author:Huang Tao
diff --git a/changelogs/unreleased/33308-use-pre-wrap-for-commit-messages.yml b/changelogs/unreleased/33308-use-pre-wrap-for-commit-messages.yml
new file mode 100644
index 00000000000..43e8f242947
--- /dev/null
+++ b/changelogs/unreleased/33308-use-pre-wrap-for-commit-messages.yml
@@ -0,0 +1,4 @@
+---
+title: Use pre-wrap for commit messages to keep lists indented
+merge_request:
+author:
diff --git a/changelogs/unreleased/33334-portuguese_brazil_translation_of_cycle_analytics_page.yml b/changelogs/unreleased/33334-portuguese_brazil_translation_of_cycle_analytics_page.yml
new file mode 100644
index 00000000000..a0e0458da16
--- /dev/null
+++ b/changelogs/unreleased/33334-portuguese_brazil_translation_of_cycle_analytics_page.yml
@@ -0,0 +1,4 @@
+---
+title: Add Portuguese Brazil of Cycle Analytics Page to I18N
+merge_request: 11920
+author:Huang Tao
diff --git a/changelogs/unreleased/33381-display-issue-state-in-mr-widget-issue-links.yml b/changelogs/unreleased/33381-display-issue-state-in-mr-widget-issue-links.yml
new file mode 100644
index 00000000000..4a7b02fec94
--- /dev/null
+++ b/changelogs/unreleased/33381-display-issue-state-in-mr-widget-issue-links.yml
@@ -0,0 +1,4 @@
+---
+title: Display issue state in issue links section of merge request widget
+merge_request: 12021
+author:
diff --git a/changelogs/unreleased/33383-bulgarian_translation_of_cycle_analytics_page.yml b/changelogs/unreleased/33383-bulgarian_translation_of_cycle_analytics_page.yml
new file mode 100644
index 00000000000..71bd5505be7
--- /dev/null
+++ b/changelogs/unreleased/33383-bulgarian_translation_of_cycle_analytics_page.yml
@@ -0,0 +1,4 @@
+---
+title: add bulgarian translation of cycle analytics page to I18N
+merge_request: 11958
+author: Lyubomir Vasilev
diff --git a/changelogs/unreleased/allow-reporters-to-promote-group-labels.yml b/changelogs/unreleased/allow-reporters-to-promote-group-labels.yml
new file mode 100644
index 00000000000..2364ce6d068
--- /dev/null
+++ b/changelogs/unreleased/allow-reporters-to-promote-group-labels.yml
@@ -0,0 +1,4 @@
+---
+title: Allow reporters to promote project labels to group labels
+merge_request:
+author:
diff --git a/changelogs/unreleased/allow_numeric_pages_domain.yml b/changelogs/unreleased/allow_numeric_pages_domain.yml
new file mode 100644
index 00000000000..10d9f26f88d
--- /dev/null
+++ b/changelogs/unreleased/allow_numeric_pages_domain.yml
@@ -0,0 +1,4 @@
+---
+title: Allow numeric pages domain
+merge_request: 11550
+author:
diff --git a/changelogs/unreleased/artifacts-keyboard-shortcuts.yml b/changelogs/unreleased/artifacts-keyboard-shortcuts.yml
new file mode 100644
index 00000000000..69569504c4f
--- /dev/null
+++ b/changelogs/unreleased/artifacts-keyboard-shortcuts.yml
@@ -0,0 +1,4 @@
+---
+title: Enabled keyboard shortcuts on artifacts pages
+merge_request:
+author:
diff --git a/changelogs/unreleased/auto-search-when-state-changed.yml b/changelogs/unreleased/auto-search-when-state-changed.yml
new file mode 100644
index 00000000000..2723beb8600
--- /dev/null
+++ b/changelogs/unreleased/auto-search-when-state-changed.yml
@@ -0,0 +1,4 @@
+---
+title: Perform filtered search when state tab is changed
+merge_request:
+author:
diff --git a/changelogs/unreleased/bvl-translate-project-pages.yml b/changelogs/unreleased/bvl-translate-project-pages.yml
new file mode 100644
index 00000000000..fb90aba08b4
--- /dev/null
+++ b/changelogs/unreleased/bvl-translate-project-pages.yml
@@ -0,0 +1,4 @@
+---
+title: Translate backend for Project & Repository pages
+merge_request: 11183
+author:
diff --git a/changelogs/unreleased/ce-31853-projects-shared-groups.yml b/changelogs/unreleased/ce-31853-projects-shared-groups.yml
new file mode 100644
index 00000000000..ffa3aed682d
--- /dev/null
+++ b/changelogs/unreleased/ce-31853-projects-shared-groups.yml
@@ -0,0 +1,4 @@
+---
+title: Remove duplication for sharing projects with groups in project settings
+merge_request:
+author:
diff --git a/changelogs/unreleased/dashboard-milestone-tabs-loading-async.yml b/changelogs/unreleased/dashboard-milestone-tabs-loading-async.yml
new file mode 100644
index 00000000000..357a623e0e8
--- /dev/null
+++ b/changelogs/unreleased/dashboard-milestone-tabs-loading-async.yml
@@ -0,0 +1,4 @@
+---
+title: Fixed dashboard milestone tabs not loading
+merge_request:
+author:
diff --git a/changelogs/unreleased/dm-blob-binaryness-change.yml b/changelogs/unreleased/dm-blob-binaryness-change.yml
new file mode 100644
index 00000000000..f3e3af26f12
--- /dev/null
+++ b/changelogs/unreleased/dm-blob-binaryness-change.yml
@@ -0,0 +1,5 @@
+---
+title: Detect if file that appears to be text in the first 1024 bytes is actually
+ binary afer loading all data
+merge_request:
+author:
diff --git a/changelogs/unreleased/dz-fix-submodule-subgroup.yml b/changelogs/unreleased/dz-fix-submodule-subgroup.yml
new file mode 100644
index 00000000000..20c7c9ce657
--- /dev/null
+++ b/changelogs/unreleased/dz-fix-submodule-subgroup.yml
@@ -0,0 +1,4 @@
+---
+title: Fix submodule link to then project under subgroup
+merge_request: 11906
+author:
diff --git a/changelogs/unreleased/environment-detail-view.yml b/changelogs/unreleased/environment-detail-view.yml
new file mode 100644
index 00000000000..c74f70ea86d
--- /dev/null
+++ b/changelogs/unreleased/environment-detail-view.yml
@@ -0,0 +1,4 @@
+---
+title: Make environment tables responsive
+merge_request:
+author:
diff --git a/changelogs/unreleased/expand-backlog-closed-lists-issue-boards.yml b/changelogs/unreleased/expand-backlog-closed-lists-issue-boards.yml
new file mode 100644
index 00000000000..4796f8e918b
--- /dev/null
+++ b/changelogs/unreleased/expand-backlog-closed-lists-issue-boards.yml
@@ -0,0 +1,4 @@
+---
+title: Expand/collapse backlog & closed lists in issue boards
+merge_request:
+author:
diff --git a/changelogs/unreleased/feature-gb-persist-pipeline-stages.yml b/changelogs/unreleased/feature-gb-persist-pipeline-stages.yml
new file mode 100644
index 00000000000..1404b342359
--- /dev/null
+++ b/changelogs/unreleased/feature-gb-persist-pipeline-stages.yml
@@ -0,0 +1,4 @@
+---
+title: Persist pipeline stages in the database
+merge_request: 11790
+author:
diff --git a/changelogs/unreleased/fix-backup-restore-resume.yml b/changelogs/unreleased/fix-backup-restore-resume.yml
new file mode 100644
index 00000000000..b7dfd451f5d
--- /dev/null
+++ b/changelogs/unreleased/fix-backup-restore-resume.yml
@@ -0,0 +1,4 @@
+---
+title: Make backup task to continue on corrupt repositories
+merge_request: 11962
+author:
diff --git a/changelogs/unreleased/fix-encoding-binary-issue.yml b/changelogs/unreleased/fix-encoding-binary-issue.yml
new file mode 100644
index 00000000000..ac9aff64a88
--- /dev/null
+++ b/changelogs/unreleased/fix-encoding-binary-issue.yml
@@ -0,0 +1,4 @@
+---
+title: Fix binary encoding error on MR diffs
+merge_request: 11929
+author:
diff --git a/changelogs/unreleased/fix_commits_page.yml b/changelogs/unreleased/fix_commits_page.yml
new file mode 100644
index 00000000000..a2afaf6e626
--- /dev/null
+++ b/changelogs/unreleased/fix_commits_page.yml
@@ -0,0 +1,4 @@
+---
+title: Fix duplication of commits header on commits page
+merge_request: 11006
+author: @blackst0ne
diff --git a/changelogs/unreleased/fixed-confidential-issue-bar.yml b/changelogs/unreleased/fixed-confidential-issue-bar.yml
new file mode 100644
index 00000000000..6a41590d0af
--- /dev/null
+++ b/changelogs/unreleased/fixed-confidential-issue-bar.yml
@@ -0,0 +1,4 @@
+---
+title: Make confidential issues more obviously confidential
+merge_request:
+author:
diff --git a/changelogs/unreleased/issuable-sidebar-edit-button-field-focus.yml b/changelogs/unreleased/issuable-sidebar-edit-button-field-focus.yml
new file mode 100644
index 00000000000..05d52fcad0f
--- /dev/null
+++ b/changelogs/unreleased/issuable-sidebar-edit-button-field-focus.yml
@@ -0,0 +1,4 @@
+---
+title: Fixed dropdown filter input not focusing after transition
+merge_request:
+author:
diff --git a/changelogs/unreleased/issue-23254.yml b/changelogs/unreleased/issue-23254.yml
new file mode 100644
index 00000000000..568a7a41c30
--- /dev/null
+++ b/changelogs/unreleased/issue-23254.yml
@@ -0,0 +1,4 @@
+---
+title: Fixed style on unsubscribe page
+merge_request:
+author: Gustav Ernberg
diff --git a/changelogs/unreleased/issue_27166_2.yml b/changelogs/unreleased/issue_27166_2.yml
new file mode 100644
index 00000000000..9b9906e03dd
--- /dev/null
+++ b/changelogs/unreleased/issue_27166_2.yml
@@ -0,0 +1,4 @@
+---
+title: Avoid repeated queries for pipeline builds on merge requests
+merge_request:
+author:
diff --git a/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml b/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml
new file mode 100644
index 00000000000..df4de9f4e21
--- /dev/null
+++ b/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml
@@ -0,0 +1,5 @@
+---
+title: Redirect to user's keys index instead of user's index after a key is deleted
+ in the admin
+merge_request: 10227
+author: Cyril Jouve
diff --git a/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml b/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml
new file mode 100644
index 00000000000..a321ed9d7d8
--- /dev/null
+++ b/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml
@@ -0,0 +1,4 @@
+---
+title: Allow manual bypass of auto_sign_in_with_provider with a new param
+merge_request: 10187
+author: Maxime Besson
diff --git a/changelogs/unreleased/mk-fix-git-over-http-rejections.yml b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml
new file mode 100644
index 00000000000..e75740e913f
--- /dev/null
+++ b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml
@@ -0,0 +1,4 @@
+---
+title: Fix Git-over-HTTP error statuses and improve error messages
+merge_request: 11398
+author:
diff --git a/changelogs/unreleased/pat-msg-on-auth-failure.yml b/changelogs/unreleased/pat-msg-on-auth-failure.yml
new file mode 100644
index 00000000000..c1b1528bb7a
--- /dev/null
+++ b/changelogs/unreleased/pat-msg-on-auth-failure.yml
@@ -0,0 +1,4 @@
+---
+title: Instruct user to use personal access token for Git over HTTP
+merge_request: 11986
+author: Robin Bobbitt
diff --git a/changelogs/unreleased/projects-api-import-status.yml b/changelogs/unreleased/projects-api-import-status.yml
new file mode 100644
index 00000000000..06603c0adec
--- /dev/null
+++ b/changelogs/unreleased/projects-api-import-status.yml
@@ -0,0 +1,4 @@
+---
+title: Expose import_status in Projects API
+merge_request: 11851
+author: Robin Bobbitt
diff --git a/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml
new file mode 100644
index 00000000000..161bce45601
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml
@@ -0,0 +1,4 @@
+---
+title: Fix LFS timeouts when trying to save large files
+merge_request:
+author:
diff --git a/changelogs/unreleased/sync-email-from-omniauth.yml b/changelogs/unreleased/sync-email-from-omniauth.yml
new file mode 100644
index 00000000000..ed14a95a5f1
--- /dev/null
+++ b/changelogs/unreleased/sync-email-from-omniauth.yml
@@ -0,0 +1,4 @@
+---
+title: Sync email address from specified omniauth provider
+merge_request: 11268
+author: Robin Bobbitt
diff --git a/changelogs/unreleased/zj-fix-pipeline-etag.yml b/changelogs/unreleased/zj-fix-pipeline-etag.yml
deleted file mode 100644
index 03ebef8c575..00000000000
--- a/changelogs/unreleased/zj-fix-pipeline-etag.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix issue where real time pipelines were not cached
-merge_request: 11615
-author:
diff --git a/changelogs/unreleased/zj-i18n-pipeline-schedules.yml b/changelogs/unreleased/zj-i18n-pipeline-schedules.yml
new file mode 100644
index 00000000000..51c82a16359
--- /dev/null
+++ b/changelogs/unreleased/zj-i18n-pipeline-schedules.yml
@@ -0,0 +1,4 @@
+---
+title: Allow translation of Pipeline Schedules
+merge_request:
+author:
diff --git a/changelogs/unreleased/zj-job-view-goes-real-time.yml b/changelogs/unreleased/zj-job-view-goes-real-time.yml
new file mode 100644
index 00000000000..376c9dfa65f
--- /dev/null
+++ b/changelogs/unreleased/zj-job-view-goes-real-time.yml
@@ -0,0 +1,4 @@
+---
+title: Job details page update real time
+merge_request: 11651
+author:
diff --git a/changelogs/unreleased/zj-prom-pipeline-count.yml b/changelogs/unreleased/zj-prom-pipeline-count.yml
new file mode 100644
index 00000000000..191e4f2f949
--- /dev/null
+++ b/changelogs/unreleased/zj-prom-pipeline-count.yml
@@ -0,0 +1,4 @@
+---
+title: Add prometheus metrics on pipeline creation
+merge_request:
+author:
diff --git a/changelogs/unreleased/zj-read-registry-pat.yml b/changelogs/unreleased/zj-read-registry-pat.yml
new file mode 100644
index 00000000000..d36159bbdf5
--- /dev/null
+++ b/changelogs/unreleased/zj-read-registry-pat.yml
@@ -0,0 +1,4 @@
+---
+title: Allow pulling of container images using personal access tokens
+merge_request: 11845
+author:
diff --git a/config.ru b/config.ru
index 065ce59932f..89aba462f19 100644
--- a/config.ru
+++ b/config.ru
@@ -15,6 +15,9 @@ if defined?(Unicorn)
end
end
+# set default directory for multiproces metrics gathering
+ENV['prometheus_multiproc_dir'] ||= 'tmp/prometheus_multiproc_dir'
+
require ::File.expand_path('../config/environment', __FILE__)
map ENV['RAILS_RELATIVE_URL_ROOT'] || "/" do
diff --git a/config/boot.rb b/config/boot.rb
index f2830ae3166..17a71148370 100644
--- a/config/boot.rb
+++ b/config/boot.rb
@@ -4,3 +4,15 @@ require 'rubygems'
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
+
+# Default Bootsnap configuration from https://github.com/Shopify/bootsnap#usage
+require 'bootsnap'
+Bootsnap.setup(
+ cache_dir: 'tmp/cache',
+ development_mode: ENV['RAILS_ENV'] == 'development',
+ load_path_cache: true,
+ autoload_paths_cache: true,
+ disable_trace: false,
+ compile_cache_iseq: true,
+ compile_cache_yaml: true
+)
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index d2aeb66ebf6..0b33783869b 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -337,6 +337,10 @@ production: &base
# showing GitLab's sign-in page (default: show the GitLab sign-in page)
# auto_sign_in_with_provider: saml
+ # Sync user's email address from the specified Omniauth provider every time the user logs
+ # in (default: nil). And consequently make this field read-only.
+ # sync_email_from_provider: cas3
+
# CAUTION!
# This allows users to login without having a user account first. Define the allowed providers
# using an array, e.g. ["saml", "twitter"], or as true/false to allow all providers or none.
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 45ea2040d23..8ddf8e4d2e4 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -156,6 +156,7 @@ Settings.omniauth['external_providers'] = [] if Settings.omniauth['external_prov
Settings.omniauth['block_auto_created_users'] = true if Settings.omniauth['block_auto_created_users'].nil?
Settings.omniauth['auto_link_ldap_user'] = false if Settings.omniauth['auto_link_ldap_user'].nil?
Settings.omniauth['auto_link_saml_user'] = false if Settings.omniauth['auto_link_saml_user'].nil?
+Settings.omniauth['sync_email_from_provider'] ||= nil
Settings.omniauth['providers'] ||= []
Settings.omniauth['cas3'] ||= Settingslogic.new({})
diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb
new file mode 100644
index 00000000000..a78711fe599
--- /dev/null
+++ b/config/initializers/forbid_sidekiq_in_transactions.rb
@@ -0,0 +1,49 @@
+module Sidekiq
+ module Worker
+ mattr_accessor :skip_transaction_check
+ self.skip_transaction_check = false
+
+ def self.skipping_transaction_check(&block)
+ skip_transaction_check = self.skip_transaction_check
+ self.skip_transaction_check = true
+ yield
+ ensure
+ self.skip_transaction_check = skip_transaction_check
+ end
+
+ module ClassMethods
+ module NoSchedulingFromTransactions
+ NESTING = ::Rails.env.test? ? 1 : 0
+
+ %i(perform_async perform_at perform_in).each do |name|
+ define_method(name) do |*args|
+ return super(*args) if Sidekiq::Worker.skip_transaction_check
+ return super(*args) unless ActiveRecord::Base.connection.open_transactions > NESTING
+
+ raise <<-MSG.strip_heredoc
+ `#{self}.#{name}` cannot be called inside a transaction as this can lead to
+ race conditions when the worker runs before the transaction is committed and
+ tries to access a model that has not been saved yet.
+
+ Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead.
+ MSG
+ end
+ end
+ end
+
+ prepend NoSchedulingFromTransactions
+ end
+ end
+end
+
+module ActiveRecord
+ class Base
+ module SkipTransactionCheckAfterCommit
+ def committed!(*)
+ Sidekiq::Worker.skipping_transaction_check { super }
+ end
+ end
+
+ prepend SkipTransactionCheckAfterCommit
+ end
+end
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 533663a2704..38c3711c6c7 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -62,6 +62,43 @@ de:
- :month
- :year
datetime:
+ # used in a custom scope that has been created to fix https://gitlab.com/gitlab-org/gitlab-ce/issues/32747
+ time_ago_in_words:
+ half_a_minute: vor einer halben Minute
+ less_than_x_seconds:
+ one: vor weniger als einer Sekunde
+ other: "vor weniger als %{count} Sekunden"
+ x_seconds:
+ one: vor einer Sekunde
+ other: "vor %{count} Sekunden"
+ less_than_x_minutes:
+ one: vor weniger als einer Minute
+ other: vor weniger als %{count} Minuten
+ x_minutes:
+ one: vor einer Minute
+ other: "vor %{count} Minuten"
+ about_x_hours:
+ one: vor etwa einer Stunde
+ other: "vor etwa %{count} Stunden"
+ x_days:
+ one: vor einem Tag
+ other: "vor %{count} Tagen"
+ about_x_months:
+ one: vor etwa einem Monat
+ other: "vor etwa %{count} Monaten"
+ x_months:
+ one: vor einem Monat
+ other: "vor %{count} Monaten"
+ about_x_years:
+ one: vor etwa einem Jahr
+ other: "vor etwa %{count} Jahren"
+ over_x_years:
+ one: vor mehr als einem Jahr
+ other: "vor mehr als %{count} Jahren"
+ almost_x_years:
+ one: vor fast einem Jahr
+ other: "vor fast %{count} Jahren"
+ # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words()
distance_in_words:
about_x_hours:
one: etwa eine Stunde
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 0f9dc39535d..d71c6eb5047 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -61,6 +61,7 @@ es:
- :month
- :year
datetime:
+ # used in a custom scope that has been created to fix https://gitlab.com/gitlab-org/gitlab-ce/issues/32747
time_ago_in_words:
half_a_minute: "hace medio minuto"
less_than_x_seconds:
@@ -96,6 +97,7 @@ es:
almost_x_years:
one: "hace casi 1 año"
other: "hace casi %{count} años"
+ # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words()
distance_in_words:
about_x_hours:
one: alrededor de 1 hora
diff --git a/config/routes.rb b/config/routes.rb
index 846054e6917..d909be38b42 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -38,10 +38,10 @@ Rails.application.routes.draw do
# Health check
get 'health_check(/:checks)' => 'health_check#index', as: :health_check
- scope path: '-', controller: 'health' do
- get :liveness
- get :readiness
- get :metrics
+ scope path: '-' do
+ get 'liveness' => 'health#liveness'
+ get 'readiness' => 'health#readiness'
+ resources :metrics, only: [:index]
end
# Koding route
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index ccfd85aed63..5427bab93ce 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -48,7 +48,7 @@ namespace :admin do
end
end
- resources :deploy_keys, only: [:index, :new, :create, :destroy]
+ resources :deploy_keys, only: [:index, :new, :create, :edit, :update, :destroy]
resources :hooks, only: [:index, :create, :edit, :update, :destroy] do
member do
@@ -72,6 +72,8 @@ namespace :admin do
resource :system_info, controller: 'system_info', only: [:show]
resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ }
+ get 'conversational_development_index' => 'conversational_development_index#show'
+
resources :projects, only: [:index]
scope(path: 'projects/*namespace_id',
diff --git a/config/routes/dashboard.rb b/config/routes/dashboard.rb
index 8e380a0b0ac..d2437285cdf 100644
--- a/config/routes/dashboard.rb
+++ b/config/routes/dashboard.rb
@@ -4,7 +4,13 @@ resource :dashboard, controller: 'dashboard', only: [] do
get :activity
scope module: :dashboard do
- resources :milestones, only: [:index, :show]
+ resources :milestones, only: [:index, :show] do
+ member do
+ get :merge_requests
+ get :participants
+ get :labels
+ end
+ end
resources :labels, only: [:index]
resources :groups, only: [:index]
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 14718e2f3c4..f95cc3101d3 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -73,7 +73,7 @@ constraints(ProjectUrlConstrainer.new) do
resource :mattermost, only: [:new, :create]
- resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create] do
+ resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do
member do
put :enable
put :disable
diff --git a/config/routes/snippets.rb b/config/routes/snippets.rb
index dae83734fe6..0a4ebac3ca3 100644
--- a/config/routes/snippets.rb
+++ b/config/routes/snippets.rb
@@ -2,6 +2,9 @@ resources :snippets, concerns: :awardable do
member do
get :raw
post :mark_as_spam
+ end
+
+ collection do
post :preview_markdown
end
diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb
index b315186b178..a49e244af1a 100644
--- a/config/routes/uploads.rb
+++ b/config/routes/uploads.rb
@@ -1,6 +1,6 @@
scope path: :uploads do
# Note attachments and User/Group/Project avatars
- get ":model/:mounted_as/:id/:filename",
+ get "system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ }
@@ -9,8 +9,13 @@ scope path: :uploads do
to: 'uploads#show',
constraints: { model: /personal_snippet/, id: /\d+/, filename: /[^\/]+/ }
+ # show temporary uploads
+ get 'temp/:secret/:filename',
+ to: 'uploads#show',
+ constraints: { filename: /[^\/]+/ }
+
# Appearance
- get ":model/:mounted_as/:id/:filename",
+ get "system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /appearance/, mounted_as: /logo|header_logo/, filename: /.+/ }
@@ -20,7 +25,7 @@ scope path: :uploads do
constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ }
# create uploads for models, snippets (notes) available for now
- post ':model/:id/',
+ post ':model',
to: 'uploads#create',
constraints: { model: /personal_snippet/, id: /\d+/ },
as: 'upload'
diff --git a/config/webpack.config.js b/config/webpack.config.js
index c99298239b2..7501acb7633 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -16,6 +16,7 @@ var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost';
var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808;
var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false';
var WEBPACK_REPORT = process.env.WEBPACK_REPORT;
+var NO_COMPRESSION = process.env.NO_COMPRESSION;
var config = {
// because sqljs requires fs.
@@ -39,6 +40,7 @@ var config = {
filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js',
group: './group.js',
+ groups: './groups/index.js',
groups_list: './groups_list.js',
issue_show: './issue_show/index.js',
integrations: './integrations',
@@ -154,6 +156,7 @@ var config = {
'environments',
'environments_folder',
'filtered_search',
+ 'groups',
'issue_show',
'merge_conflicts',
'notebook_viewer',
@@ -182,15 +185,7 @@ var config = {
// create cacheable common library bundles
new webpack.optimize.CommonsChunkPlugin({
- names: ['main', 'common', 'runtime'],
- }),
-
- // locale common library
- new webpack.optimize.CommonsChunkPlugin({
- name: 'locale',
- chunks: [
- 'cycle_analytics',
- ],
+ names: ['main', 'locale', 'common', 'runtime'],
}),
],
@@ -221,11 +216,18 @@ if (IS_PRODUCTION) {
}),
new webpack.DefinePlugin({
'process.env': { NODE_ENV: JSON.stringify('production') }
- }),
- new CompressionPlugin({
- asset: '[path].gz[query]',
})
);
+
+ // zopfli requires a lot of compute time and is disabled in CI
+ if (!NO_COMPRESSION) {
+ config.plugins.push(
+ new CompressionPlugin({
+ asset: '[path].gz[query]',
+ algorithm: 'zopfli',
+ })
+ );
+ }
}
if (IS_DEV_SERVER) {
diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb
index c2b8f7ba819..6553c5d457a 100644
--- a/db/fixtures/development/04_project.rb
+++ b/db/fixtures/development/04_project.rb
@@ -71,7 +71,9 @@ Sidekiq::Testing.inline! do
# hook won't run until after the fixture is loaded. That is too late
# since the Sidekiq::Testing block has already exited. Force clearing
# the `after_commit` queue to ensure the job is run now.
- project.send(:_run_after_commit_queue)
+ Sidekiq::Worker.skipping_transaction_check do
+ project.send(:_run_after_commit_queue)
+ end
if project.valid? && project.valid_repo?
print '.'
diff --git a/db/fixtures/development/11_keys.rb b/db/fixtures/development/11_keys.rb
index 51e22137d6f..c405ecfdaf3 100644
--- a/db/fixtures/development/11_keys.rb
+++ b/db/fixtures/development/11_keys.rb
@@ -1,17 +1,26 @@
require './spec/support/sidekiq'
+
# Creating keys runs a gitlab-shell worker. Since we may not have the right
# gitlab-shell path set (yet) we need to disable this for these fixtures.
Sidekiq::Testing.disable! do
Gitlab::Seeder.quiet do
+ # We want to run `add_to_shell` immediately instead of after the commit, so
+ # that it falls under `Sidekiq::Testing.disable!`.
+ Key.skip_callback(:commit, :after, :add_to_shell)
+
User.first(10).each do |user|
key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0="
- user.keys.create(
+ key = user.keys.create(
title: "Sample key #{user.id}",
key: key
)
+ Sidekiq::Worker.skipping_transaction_check do
+ key.add_to_shell
+ end
+
print '.'
end
end
diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb
index 68767f0e585..5de5339b70e 100644
--- a/db/fixtures/development/14_pipelines.rb
+++ b/db/fixtures/development/14_pipelines.rb
@@ -112,6 +112,10 @@ class Gitlab::Seeder::Pipelines
setup_artifacts(build)
setup_build_log(build)
+
+ build.project.environments.
+ find_or_create_by(name: build.expanded_environment_name)
+
build.save
end
end
diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb
index 75457b2d369..7c1d758dada 100644
--- a/db/fixtures/development/17_cycle_analytics.rb
+++ b/db/fixtures/development/17_cycle_analytics.rb
@@ -212,12 +212,9 @@ class Gitlab::Seeder::CycleAnalytics
merge_requests.each do |merge_request|
Timecop.travel 12.hours.from_now
- CreateDeploymentService.new(merge_request.project, @user, {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: @project.repository.commit('master').sha
- }).execute
+ job = merge_request.head_pipeline.builds.where.not(environment: nil).last
+
+ CreateDeploymentService.new(job).execute
end
end
end
diff --git a/db/fixtures/development/21_conversational_development_index_metrics.rb b/db/fixtures/development/21_conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..4cd0a82ed1a
--- /dev/null
+++ b/db/fixtures/development/21_conversational_development_index_metrics.rb
@@ -0,0 +1,40 @@
+Gitlab::Seeder.quiet do
+ conversational_development_index_metric = ConversationalDevelopmentIndex::Metric.new(
+ leader_issues: 10.2,
+ instance_issues: 3.2,
+
+ leader_notes: 25.3,
+ instance_notes: 23.2,
+
+ leader_milestones: 16.2,
+ instance_milestones: 5.5,
+
+ leader_boards: 5.2,
+ instance_boards: 3.2,
+
+ leader_merge_requests: 5.2,
+ instance_merge_requests: 3.2,
+
+ leader_ci_pipelines: 25.1,
+ instance_ci_pipelines: 21.3,
+
+ leader_environments: 3.3,
+ instance_environments: 2.2,
+
+ leader_deployments: 41.3,
+ instance_deployments: 15.2,
+
+ leader_projects_prometheus_active: 0.31,
+ instance_projects_prometheus_active: 0.30,
+
+ leader_service_desk_issues: 15.8,
+ instance_service_desk_issues: 15.1
+ )
+
+ if conversational_development_index_metric.save
+ print '.'
+ else
+ puts conversational_development_index_metric.errors.full_messages
+ print 'F'
+ end
+end
diff --git a/db/fixtures/production/010_settings.rb b/db/fixtures/production/010_settings.rb
index 5522f31629a..7626cdb0b9c 100644
--- a/db/fixtures/production/010_settings.rb
+++ b/db/fixtures/production/010_settings.rb
@@ -1,16 +1,26 @@
-if ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'].present?
- settings = ApplicationSetting.current || ApplicationSetting.create_from_defaults
- settings.set_runners_registration_token(ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'])
-
+def save(settings, topic)
if settings.save
- puts "Saved Runner Registration Token".color(:green)
+ puts "Saved #{topic}".color(:green)
else
- puts "Could not save Runner Registration Token".color(:red)
+ puts "Could not save #{topic}".color(:red)
puts
settings.errors.full_messages.map do |message|
puts "--> #{message}".color(:red)
end
puts
- exit 1
+ exit(1)
end
end
+
+if ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'].present?
+ settings = Gitlab::CurrentSettings.current_application_settings
+ settings.set_runners_registration_token(ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'])
+ save(settings, 'Runner Registration Token')
+end
+
+if ENV['GITLAB_PROMETHEUS_METRICS_ENABLED'].present?
+ settings = Gitlab::CurrentSettings.current_application_settings
+ value = Gitlab::Utils.to_boolean(ENV['GITLAB_PROMETHEUS_METRICS_ENABLED']) || false
+ settings.prometheus_metrics_enabled = value
+ save(settings, 'Prometheus metrics enabled flag')
+end
diff --git a/db/migrate/20170316163800_rename_system_namespaces.rb b/db/migrate/20170316163800_rename_system_namespaces.rb
new file mode 100644
index 00000000000..b5408fbf112
--- /dev/null
+++ b/db/migrate/20170316163800_rename_system_namespaces.rb
@@ -0,0 +1,231 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+class RenameSystemNamespaces < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ include Gitlab::ShellAdapter
+ disable_ddl_transaction!
+
+ class User < ActiveRecord::Base
+ self.table_name = 'users'
+ end
+
+ class Namespace < ActiveRecord::Base
+ self.table_name = 'namespaces'
+ belongs_to :parent, class_name: 'RenameSystemNamespaces::Namespace'
+ has_one :route, as: :source
+ has_many :children, class_name: 'RenameSystemNamespaces::Namespace', foreign_key: :parent_id
+ belongs_to :owner, class_name: 'RenameSystemNamespaces::User'
+
+ # Overridden to have the correct `source_type` for the `route` relation
+ def self.name
+ 'Namespace'
+ end
+
+ def full_path
+ if route && route.path.present?
+ @full_path ||= route.path
+ else
+ update_route if persisted?
+
+ build_full_path
+ end
+ end
+
+ def build_full_path
+ if parent && path
+ parent.full_path + '/' + path
+ else
+ path
+ end
+ end
+
+ def update_route
+ prepare_route
+ route.save
+ end
+
+ def prepare_route
+ route || build_route(source: self)
+ route.path = build_full_path
+ route.name = build_full_name
+ @full_path = nil
+ @full_name = nil
+ end
+
+ def build_full_name
+ if parent && name
+ parent.human_name + ' / ' + name
+ else
+ name
+ end
+ end
+
+ def human_name
+ owner&.name
+ end
+ end
+
+ class Route < ActiveRecord::Base
+ self.table_name = 'routes'
+ belongs_to :source, polymorphic: true
+ end
+
+ class Project < ActiveRecord::Base
+ self.table_name = 'projects'
+
+ def repository_storage_path
+ Gitlab.config.repositories.storages[repository_storage]['path']
+ end
+ end
+
+ DOWNTIME = false
+
+ def up
+ return unless system_namespace
+
+ old_path = system_namespace.path
+ old_full_path = system_namespace.full_path
+ # Only remove the last occurrence of the path name to get the parent namespace path
+ namespace_path = remove_last_occurrence(old_full_path, old_path)
+ new_path = rename_path(namespace_path, old_path)
+ new_full_path = join_namespace_path(namespace_path, new_path)
+
+ Namespace.where(id: system_namespace).update_all(path: new_path) # skips callbacks & validations
+
+ replace_statement = replace_sql(Route.arel_table[:path], old_full_path, new_full_path)
+ route_matches = [old_full_path, "#{old_full_path}/%"]
+
+ update_column_in_batches(:routes, :path, replace_statement) do |table, query|
+ query.where(Route.arel_table[:path].matches_any(route_matches))
+ end
+
+ clear_cache_for_namespace(system_namespace)
+
+ # tasks here are based on `Namespace#move_dir`
+ move_repositories(system_namespace, old_full_path, new_full_path)
+ move_namespace_folders(uploads_dir, old_full_path, new_full_path) if file_storage?
+ move_namespace_folders(pages_dir, old_full_path, new_full_path)
+ end
+
+ def down
+ # nothing to do
+ end
+
+ def remove_last_occurrence(string, pattern)
+ string.reverse.sub(pattern.reverse, "").reverse
+ end
+
+ def move_namespace_folders(directory, old_relative_path, new_relative_path)
+ old_path = File.join(directory, old_relative_path)
+ return unless File.directory?(old_path)
+
+ new_path = File.join(directory, new_relative_path)
+ FileUtils.mv(old_path, new_path)
+ end
+
+ def move_repositories(namespace, old_full_path, new_full_path)
+ repo_paths_for_namespace(namespace).each do |repository_storage_path|
+ # Ensure old directory exists before moving it
+ gitlab_shell.add_namespace(repository_storage_path, old_full_path)
+
+ unless gitlab_shell.mv_namespace(repository_storage_path, old_full_path, new_full_path)
+ say "Exception moving path #{repository_storage_path} from #{old_full_path} to #{new_full_path}"
+ end
+ end
+ end
+
+ def rename_path(namespace_path, path_was)
+ counter = 0
+ path = "#{path_was}#{counter}"
+
+ while route_exists?(join_namespace_path(namespace_path, path))
+ counter += 1
+ path = "#{path_was}#{counter}"
+ end
+
+ path
+ end
+
+ def route_exists?(full_path)
+ Route.where(Route.arel_table[:path].matches(full_path)).any?
+ end
+
+ def join_namespace_path(namespace_path, path)
+ if namespace_path.present?
+ File.join(namespace_path, path)
+ else
+ path
+ end
+ end
+
+ def system_namespace
+ @system_namespace ||= Namespace.where(parent_id: nil).
+ where(arel_table[:path].matches(system_namespace_path)).
+ first
+ end
+
+ def system_namespace_path
+ "system"
+ end
+
+ def clear_cache_for_namespace(namespace)
+ project_ids = projects_for_namespace(namespace).pluck(:id)
+
+ update_column_in_batches(:projects, :description_html, nil) do |table, query|
+ query.where(table[:id].in(project_ids))
+ end
+
+ update_column_in_batches(:issues, :description_html, nil) do |table, query|
+ query.where(table[:project_id].in(project_ids))
+ end
+
+ update_column_in_batches(:merge_requests, :description_html, nil) do |table, query|
+ query.where(table[:target_project_id].in(project_ids))
+ end
+
+ update_column_in_batches(:notes, :note_html, nil) do |table, query|
+ query.where(table[:project_id].in(project_ids))
+ end
+
+ update_column_in_batches(:milestones, :description_html, nil) do |table, query|
+ query.where(table[:project_id].in(project_ids))
+ end
+ end
+
+ def projects_for_namespace(namespace)
+ namespace_ids = child_ids_for_parent(namespace, ids: [namespace.id])
+ namespace_or_children = Project.arel_table[:namespace_id].in(namespace_ids)
+ Project.unscoped.where(namespace_or_children)
+ end
+
+ # This won't scale to huge trees, but it should do for a handful of namespaces
+ # called `system`.
+ def child_ids_for_parent(namespace, ids: [])
+ namespace.children.each do |child|
+ ids << child.id
+ child_ids_for_parent(child, ids: ids) if child.children.any?
+ end
+ ids
+ end
+
+ def repo_paths_for_namespace(namespace)
+ projects_for_namespace(namespace).distinct.
+ select(:repository_storage).map(&:repository_storage_path)
+ end
+
+ def uploads_dir
+ File.join(Rails.root, "public", "uploads")
+ end
+
+ def pages_dir
+ Settings.pages.path
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def arel_table
+ Namespace.arel_table
+ end
+end
diff --git a/db/migrate/20170316163845_move_uploads_to_system_dir.rb b/db/migrate/20170316163845_move_uploads_to_system_dir.rb
new file mode 100644
index 00000000000..564ee10b5ab
--- /dev/null
+++ b/db/migrate/20170316163845_move_uploads_to_system_dir.rb
@@ -0,0 +1,59 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class MoveUploadsToSystemDir < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+ DIRECTORIES_TO_MOVE = %w(user project note group appearance).freeze
+
+ def up
+ return unless file_storage?
+
+ FileUtils.mkdir_p(new_upload_dir)
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ source = File.join(old_upload_dir, dir)
+ destination = File.join(new_upload_dir, dir)
+ next unless File.directory?(source)
+ next if File.directory?(destination)
+
+ say "Moving #{source} -> #{destination}"
+ FileUtils.mv(source, destination)
+ FileUtils.ln_s(destination, source)
+ end
+ end
+
+ def down
+ return unless file_storage?
+ return unless File.directory?(new_upload_dir)
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ source = File.join(new_upload_dir, dir)
+ destination = File.join(old_upload_dir, dir)
+ next unless File.directory?(source)
+ next if File.directory?(destination) && !File.symlink?(destination)
+
+ say "Moving #{source} -> #{destination}"
+ FileUtils.rm(destination) if File.symlink?(destination)
+ FileUtils.mv(source, destination)
+ end
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def base_directory
+ Rails.root
+ end
+
+ def old_upload_dir
+ File.join(base_directory, "public", "uploads")
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "public", "uploads", "system")
+ end
+end
diff --git a/db/migrate/20170503114228_add_description_to_snippets.rb b/db/migrate/20170503114228_add_description_to_snippets.rb
new file mode 100644
index 00000000000..3fc960b2da5
--- /dev/null
+++ b/db/migrate/20170503114228_add_description_to_snippets.rb
@@ -0,0 +1,12 @@
+class AddDescriptionToSnippets < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def change
+ add_column :snippets, :description, :text
+ add_column :snippets, :description_html, :text
+ end
+end
diff --git a/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb b/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb
new file mode 100644
index 00000000000..6ec2ed712b9
--- /dev/null
+++ b/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb
@@ -0,0 +1,16 @@
+class AddPrometheusSettingsToMetricsSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ add_column_with_default(:application_settings, :prometheus_metrics_enabled, :boolean,
+ default: false, allow_null: false)
+ end
+
+ def down
+ remove_column(:application_settings, :prometheus_metrics_enabled)
+ end
+end
diff --git a/db/migrate/20170523121229_create_conversational_development_index_metrics.rb b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..9f9ec526055
--- /dev/null
+++ b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb
@@ -0,0 +1,39 @@
+class CreateConversationalDevelopmentIndexMetrics < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :conversational_development_index_metrics do |t|
+ t.float :leader_issues, null: false
+ t.float :instance_issues, null: false
+
+ t.float :leader_notes, null: false
+ t.float :instance_notes, null: false
+
+ t.float :leader_milestones, null: false
+ t.float :instance_milestones, null: false
+
+ t.float :leader_boards, null: false
+ t.float :instance_boards, null: false
+
+ t.float :leader_merge_requests, null: false
+ t.float :instance_merge_requests, null: false
+
+ t.float :leader_ci_pipelines, null: false
+ t.float :instance_ci_pipelines, null: false
+
+ t.float :leader_environments, null: false
+ t.float :instance_environments, null: false
+
+ t.float :leader_deployments, null: false
+ t.float :instance_deployments, null: false
+
+ t.float :leader_projects_prometheus_active, null: false
+ t.float :instance_projects_prometheus_active, null: false
+
+ t.float :leader_service_desk_issues, null: false
+ t.float :instance_service_desk_issues, null: false
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20170525132202_create_pipeline_stages.rb b/db/migrate/20170525132202_create_pipeline_stages.rb
new file mode 100644
index 00000000000..25656f2a2c2
--- /dev/null
+++ b/db/migrate/20170525132202_create_pipeline_stages.rb
@@ -0,0 +1,25 @@
+class CreatePipelineStages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :ci_stages do |t|
+ t.integer :project_id
+ t.integer :pipeline_id
+ t.timestamps null: true
+ t.string :name
+ end
+
+ add_concurrent_foreign_key :ci_stages, :projects, column: :project_id, on_delete: :cascade
+ add_concurrent_foreign_key :ci_stages, :ci_pipelines, column: :pipeline_id, on_delete: :cascade
+ add_concurrent_index :ci_stages, :project_id
+ add_concurrent_index :ci_stages, :pipeline_id
+ end
+
+ def down
+ drop_table :ci_stages
+ end
+end
diff --git a/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb b/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb
new file mode 100644
index 00000000000..d5675d5828b
--- /dev/null
+++ b/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb
@@ -0,0 +1,21 @@
+class AddStageIdToCiBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column :ci_builds, :stage_id, :integer
+
+ add_concurrent_foreign_key :ci_builds, :ci_stages, column: :stage_id, on_delete: :cascade
+ add_concurrent_index :ci_builds, :stage_id
+ end
+
+ def down
+ remove_foreign_key :ci_builds, column: :stage_id
+ remove_concurrent_index :ci_builds, :stage_id
+
+ remove_column :ci_builds, :stage_id, :integer
+ end
+end
diff --git a/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb b/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb
new file mode 100644
index 00000000000..470c3b8166c
--- /dev/null
+++ b/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb
@@ -0,0 +1,15 @@
+class RenameUsersLdapEmailToExternalEmail < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ rename_column_concurrently :users, :ldap_email, :external_email
+ end
+
+ def down
+ cleanup_concurrent_column_rename :users, :external_email, :ldap_email
+ end
+end
diff --git a/db/migrate/20170603200744_add_email_provider_to_users.rb b/db/migrate/20170603200744_add_email_provider_to_users.rb
new file mode 100644
index 00000000000..ed90af9aadc
--- /dev/null
+++ b/db/migrate/20170603200744_add_email_provider_to_users.rb
@@ -0,0 +1,9 @@
+class AddEmailProviderToUsers < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :users, :email_provider, :string
+ end
+end
diff --git a/db/post_migrate/20170317162059_update_upload_paths_to_system.rb b/db/post_migrate/20170317162059_update_upload_paths_to_system.rb
new file mode 100644
index 00000000000..9a77b0bbdfb
--- /dev/null
+++ b/db/post_migrate/20170317162059_update_upload_paths_to_system.rb
@@ -0,0 +1,55 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class UpdateUploadPathsToSystem < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ AFFECTED_MODELS = %w(User Project Note Namespace Appearance)
+
+ def up
+ update_column_in_batches(:uploads, :path, replace_sql(arel_table[:path], base_directory, new_upload_dir)) do |_table, query|
+ query.where(uploads_to_switch_to_new_path)
+ end
+ end
+
+ def down
+ update_column_in_batches(:uploads, :path, replace_sql(arel_table[:path], new_upload_dir, base_directory)) do |_table, query|
+ query.where(uploads_to_switch_to_old_path)
+ end
+ end
+
+ # "SELECT \"uploads\".* FROM \"uploads\" WHERE \"uploads\".\"model_type\" IN ('User', 'Project', 'Note', 'Namespace', 'Appearance') AND (\"uploads\".\"path\" ILIKE 'uploads/%' AND NOT (\"uploads\".\"path\" ILIKE 'uploads/system/%'))"
+ def uploads_to_switch_to_new_path
+ affected_uploads.and(starting_with_base_directory).and(starting_with_new_upload_directory.not)
+ end
+
+ # "SELECT \"uploads\".* FROM \"uploads\" WHERE \"uploads\".\"model_type\" IN ('User', 'Project', 'Note', 'Namespace', 'Appearance') AND (\"uploads\".\"path\" ILIKE 'uploads/%' AND \"uploads\".\"path\" ILIKE 'uploads/system/%')"
+ def uploads_to_switch_to_old_path
+ affected_uploads.and(starting_with_new_upload_directory)
+ end
+
+ def starting_with_base_directory
+ arel_table[:path].matches("#{base_directory}/%")
+ end
+
+ def starting_with_new_upload_directory
+ arel_table[:path].matches("#{new_upload_dir}/%")
+ end
+
+ def affected_uploads
+ arel_table[:model_type].in(AFFECTED_MODELS)
+ end
+
+ def base_directory
+ "uploads"
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "system")
+ end
+
+ def arel_table
+ Arel::Table.new(:uploads)
+ end
+end
diff --git a/db/post_migrate/20170406111121_clean_upload_symlinks.rb b/db/post_migrate/20170406111121_clean_upload_symlinks.rb
new file mode 100644
index 00000000000..3ac9a6c10bc
--- /dev/null
+++ b/db/post_migrate/20170406111121_clean_upload_symlinks.rb
@@ -0,0 +1,52 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanUploadSymlinks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+ DIRECTORIES_TO_MOVE = %w(user project note group appeareance)
+
+ def up
+ return unless file_storage?
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ symlink_location = File.join(old_upload_dir, dir)
+ next unless File.symlink?(symlink_location)
+ say "removing symlink: #{symlink_location}"
+ FileUtils.rm(symlink_location)
+ end
+ end
+
+ def down
+ return unless file_storage?
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ symlink = File.join(old_upload_dir, dir)
+ destination = File.join(new_upload_dir, dir)
+
+ next if File.directory?(symlink)
+ next unless File.directory?(destination)
+
+ say "Creating symlink #{symlink} -> #{destination}"
+ FileUtils.ln_s(destination, symlink)
+ end
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def base_directory
+ Rails.root
+ end
+
+ def old_upload_dir
+ File.join(base_directory, "public", "uploads")
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "public", "uploads", "system")
+ end
+end
diff --git a/db/post_migrate/20170526185842_migrate_pipeline_stages.rb b/db/post_migrate/20170526185842_migrate_pipeline_stages.rb
new file mode 100644
index 00000000000..afd4db183c2
--- /dev/null
+++ b/db/post_migrate/20170526185842_migrate_pipeline_stages.rb
@@ -0,0 +1,22 @@
+class MigratePipelineStages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ disable_statement_timeout
+
+ execute <<-SQL.strip_heredoc
+ INSERT INTO ci_stages (project_id, pipeline_id, name)
+ SELECT project_id, commit_id, stage FROM ci_builds
+ WHERE stage IS NOT NULL
+ AND stage_id IS NULL
+ AND EXISTS (SELECT 1 FROM projects WHERE projects.id = ci_builds.project_id)
+ AND EXISTS (SELECT 1 FROM ci_pipelines WHERE ci_pipelines.id = ci_builds.commit_id)
+ GROUP BY project_id, commit_id, stage
+ ORDER BY MAX(stage_idx)
+ SQL
+ end
+end
diff --git a/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb b/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb
new file mode 100644
index 00000000000..ec9ff33b6b7
--- /dev/null
+++ b/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb
@@ -0,0 +1,15 @@
+class CreateIndexInPipelineStages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index(:ci_stages, [:pipeline_id, :name])
+ end
+
+ def down
+ remove_concurrent_index(:ci_stages, [:pipeline_id, :name])
+ end
+end
diff --git a/db/post_migrate/20170526185921_migrate_build_stage_reference.rb b/db/post_migrate/20170526185921_migrate_build_stage_reference.rb
new file mode 100644
index 00000000000..797e106cae4
--- /dev/null
+++ b/db/post_migrate/20170526185921_migrate_build_stage_reference.rb
@@ -0,0 +1,25 @@
+class MigrateBuildStageReference < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ disable_statement_timeout
+
+ stage_id = Arel.sql <<-SQL.strip_heredoc
+ (SELECT id FROM ci_stages
+ WHERE ci_stages.pipeline_id = ci_builds.commit_id
+ AND ci_stages.name = ci_builds.stage)
+ SQL
+
+ update_column_in_batches(:ci_builds, :stage_id, stage_id) do |table, query|
+ query.where(table[:stage_id].eq(nil))
+ end
+ end
+
+ def down
+ disable_statement_timeout
+
+ update_column_in_batches(:ci_builds, :stage_id, nil)
+ end
+end
diff --git a/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb b/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb
new file mode 100644
index 00000000000..15edb402b86
--- /dev/null
+++ b/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb
@@ -0,0 +1,15 @@
+class CleanupUsersLdapEmailRename < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ cleanup_concurrent_column_rename :users, :ldap_email, :external_email
+ end
+
+ def down
+ rename_column_concurrently :users, :external_email, :ldap_email
+ end
+end
diff --git a/db/post_migrate/20170606202615_move_appearance_to_system_dir.rb b/db/post_migrate/20170606202615_move_appearance_to_system_dir.rb
new file mode 100644
index 00000000000..561de59ec69
--- /dev/null
+++ b/db/post_migrate/20170606202615_move_appearance_to_system_dir.rb
@@ -0,0 +1,57 @@
+class MoveAppearanceToSystemDir < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+ DIRECTORY_TO_MOVE = 'appearance'.freeze
+
+ def up
+ source = File.join(old_upload_dir, DIRECTORY_TO_MOVE)
+ destination = File.join(new_upload_dir, DIRECTORY_TO_MOVE)
+
+ move_directory(source, destination)
+ end
+
+ def down
+ source = File.join(new_upload_dir, DIRECTORY_TO_MOVE)
+ destination = File.join(old_upload_dir, DIRECTORY_TO_MOVE)
+
+ move_directory(source, destination)
+ end
+
+ def move_directory(source, destination)
+ unless file_storage?
+ say 'Not using file storage, skipping'
+ return
+ end
+
+ unless File.directory?(source)
+ say "#{source} did not exist, skipping"
+ return
+ end
+
+ if File.directory?(destination)
+ say "#{destination} already existed, skipping"
+ return
+ end
+
+ say "Moving #{source} -> #{destination}"
+ FileUtils.mv(source, destination)
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def base_directory
+ Rails.root
+ end
+
+ def old_upload_dir
+ File.join(base_directory, "public", "uploads")
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "public", "uploads", "system")
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fa1c5dc15c4..b93630a410d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,8 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20170525174156) do
-
+ActiveRecord::Schema.define(version: 20170606202615) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "pg_trgm"
@@ -123,6 +122,7 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.integer "cached_markdown_version"
t.boolean "clientside_sentry_enabled", default: false, null: false
t.string "clientside_sentry_dsn"
+ t.boolean "prometheus_metrics_enabled", default: false, null: false
end
create_table "audit_events", force: :cascade do |t|
@@ -233,6 +233,7 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.string "coverage_regex"
t.integer "auto_canceled_by_id"
t.boolean "retried"
+ t.integer "stage_id"
end
add_index "ci_builds", ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree
@@ -242,6 +243,7 @@ ActiveRecord::Schema.define(version: 20170525174156) do
add_index "ci_builds", ["commit_id", "type", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_ref", using: :btree
add_index "ci_builds", ["project_id"], name: "index_ci_builds_on_project_id", using: :btree
add_index "ci_builds", ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree
+ add_index "ci_builds", ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree
add_index "ci_builds", ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree
add_index "ci_builds", ["status"], name: "index_ci_builds_on_status", using: :btree
add_index "ci_builds", ["token"], name: "index_ci_builds_on_token", unique: true, using: :btree
@@ -326,6 +328,18 @@ ActiveRecord::Schema.define(version: 20170525174156) do
add_index "ci_runners", ["locked"], name: "index_ci_runners_on_locked", using: :btree
add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree
+ create_table "ci_stages", force: :cascade do |t|
+ t.integer "project_id"
+ t.integer "pipeline_id"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "name"
+ end
+
+ add_index "ci_stages", ["pipeline_id", "name"], name: "index_ci_stages_on_pipeline_id_and_name", using: :btree
+ add_index "ci_stages", ["pipeline_id"], name: "index_ci_stages_on_pipeline_id", using: :btree
+ add_index "ci_stages", ["project_id"], name: "index_ci_stages_on_project_id", using: :btree
+
create_table "ci_trigger_requests", force: :cascade do |t|
t.integer "trigger_id", null: false
t.text "variables"
@@ -371,6 +385,31 @@ ActiveRecord::Schema.define(version: 20170525174156) do
add_index "container_repositories", ["project_id", "name"], name: "index_container_repositories_on_project_id_and_name", unique: true, using: :btree
add_index "container_repositories", ["project_id"], name: "index_container_repositories_on_project_id", using: :btree
+ create_table "conversational_development_index_metrics", force: :cascade do |t|
+ t.float "leader_issues", null: false
+ t.float "instance_issues", null: false
+ t.float "leader_notes", null: false
+ t.float "instance_notes", null: false
+ t.float "leader_milestones", null: false
+ t.float "instance_milestones", null: false
+ t.float "leader_boards", null: false
+ t.float "instance_boards", null: false
+ t.float "leader_merge_requests", null: false
+ t.float "instance_merge_requests", null: false
+ t.float "leader_ci_pipelines", null: false
+ t.float "instance_ci_pipelines", null: false
+ t.float "leader_environments", null: false
+ t.float "instance_environments", null: false
+ t.float "leader_deployments", null: false
+ t.float "instance_deployments", null: false
+ t.float "leader_projects_prometheus_active", null: false
+ t.float "instance_projects_prometheus_active", null: false
+ t.float "leader_service_desk_issues", null: false
+ t.float "instance_service_desk_issues", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "deploy_keys_projects", force: :cascade do |t|
t.integer "deploy_key_id", null: false
t.integer "project_id", null: false
@@ -1173,6 +1212,8 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.text "title_html"
t.text "content_html"
t.integer "cached_markdown_version"
+ t.text "description"
+ t.text "description_html"
end
add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree
@@ -1373,7 +1414,6 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.boolean "hide_project_limit", default: false
t.string "unlock_token"
t.datetime "otp_grace_period_started_at"
- t.boolean "ldap_email", default: false, null: false
t.boolean "external", default: false
t.string "incoming_email_token"
t.string "organization"
@@ -1384,6 +1424,8 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.boolean "notified_of_own_activity"
t.string "preferred_language"
t.string "rss_token"
+ t.boolean "external_email", default: false, null: false
+ t.string "email_provider"
end
add_index "users", ["admin"], name: "index_users_on_admin", using: :btree
@@ -1446,8 +1488,8 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.string "token"
t.boolean "pipeline_events", default: false, null: false
t.boolean "confidential_issues_events", default: false, null: false
- t.boolean "job_events", default: false, null: false
t.boolean "repository_update_events", default: false, null: false
+ t.boolean "job_events", default: false, null: false
end
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
@@ -1456,10 +1498,13 @@ ActiveRecord::Schema.define(version: 20170525174156) do
add_foreign_key "boards", "projects"
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify
+ add_foreign_key "ci_builds", "ci_stages", column: "stage_id", name: "fk_3a9eaa254d", on_delete: :cascade
add_foreign_key "ci_pipeline_schedules", "projects", name: "fk_8ead60fcc4", on_delete: :cascade
add_foreign_key "ci_pipeline_schedules", "users", column: "owner_id", name: "fk_9ea99f58d2", on_delete: :nullify
add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify
add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify
+ add_foreign_key "ci_stages", "ci_pipelines", column: "pipeline_id", name: "fk_fb57e6cc56", on_delete: :cascade
+ add_foreign_key "ci_stages", "projects", name: "fk_2360681d1d", on_delete: :cascade
add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade
add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade
add_foreign_key "ci_variables", "projects", name: "fk_ada5eb64b3", on_delete: :cascade
diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md
index f707039827b..afafb6bf1f5 100644
--- a/doc/administration/container_registry.md
+++ b/doc/administration/container_registry.md
@@ -1,10 +1,7 @@
# GitLab Container Registry administration
-> [Introduced][ce-4040] in GitLab 8.8.
-
----
-
> **Notes:**
+- [Introduced][ce-4040] in GitLab 8.8.
- Container Registry manifest `v1` support was added in GitLab 8.9 to support
Docker versions earlier than 1.10.
- This document is about the admin guide. To learn how to use GitLab Container
@@ -514,8 +511,8 @@ configurable in future releases.
## Configure Container Registry notifications
-You can configure the Container Registry to send webhook notifications in
-response to events happening within the registry.
+You can configure the Container Registry to send webhook notifications in
+response to events happening within the registry.
Read more about the Container Registry notifications config options in the
[Docker Registry notifications documentation][notifications-config].
@@ -568,12 +565,25 @@ notifications:
backoff: 1000
```
-## Changelog
+## Using self-signed certificates with Container Registry
+
+If you're using a self-signed certificate with your Container Registry, you
+might encounter issues during the CI jobs like the following:
+
+```
+Error response from daemon: Get registry.example.com/v1/users/: x509: certificate signed by unknown authority
+```
-**GitLab 8.8 ([source docs][8-8-docs])**
+The Docker daemon running the command expects a cert signed by a recognized CA,
+thus the error above.
-- GitLab Container Registry feature was introduced.
+While GitLab doesn't support using self-signed certificates with Container
+Registry out of the box, it is possible to make it work if you follow
+[Docker's documentation][docker-insecure]. You may find some additional
+information in [issue 18239][ce-18239].
+[ce-18239]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18239
+[docker-insecure]: https://docs.docker.com/registry/insecure/#using-self-signed-certificates
[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure
[restart gitlab]: restart_gitlab.md#installations-from-source
[wildcard certificate]: https://en.wikipedia.org/wiki/Wildcard_certificate
@@ -589,4 +599,4 @@ notifications:
[existing-domain]: #configure-container-registry-under-an-existing-gitlab-domain
[new-domain]: #configure-container-registry-under-its-own-domain
[notifications-config]: https://docs.docker.com/registry/notifications/
-[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications \ No newline at end of file
+[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications
diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md
index 7b0610ae414..5599435564e 100644
--- a/doc/administration/job_artifacts.md
+++ b/doc/administration/job_artifacts.md
@@ -82,6 +82,42 @@ _The artifacts are stored by default in
1. Save the file and [restart GitLab][] for the changes to take effect.
+## Expiring artifacts
+
+If an expiry date is used for the artifacts, they are marked for deletion
+right after that date passes. Artifacts are cleaned up by the
+`expire_build_artifacts_worker` cron job which is run by Sidekiq every hour at
+50 minutes (`50 * * * *`).
+
+To change the default schedule on which the artifacts are expired, follow the
+steps below.
+
+---
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and comment out or add the following line
+
+ ```ruby
+ gitlab_rails['expire_build_artifacts_worker_cron'] = "50 * * * *"
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ expire_build_artifacts_worker:
+ cron: "50 * * * *"
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+
## Set the maximum file size of the artifacts
Provided the artifacts are enabled, you can change the maximum file size of the
diff --git a/doc/api/README.md b/doc/api/README.md
index 45579ccac4e..1241801a81c 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -15,6 +15,8 @@ following locations:
- [Commits](commits.md)
- [Deployments](deployments.md)
- [Deploy Keys](deploy_keys.md)
+- [Environments](environments.md)
+- [Events](events.md)
- [Gitignores templates](templates/gitignores.md)
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Groups](groups.md)
@@ -53,6 +55,19 @@ following locations:
- [V3 to V4](v3_to_v4.md)
- [Version](version.md)
+## Road to GraphQL
+
+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
+compatability layer on top of GraphQL.
+
### Internal CI API
The following documentation is for the [internal CI API](ci/README.md):
diff --git a/doc/api/enviroments.md b/doc/api/environments.md
index 5ca766bf87d..5ca766bf87d 100644
--- a/doc/api/enviroments.md
+++ b/doc/api/environments.md
diff --git a/doc/api/events.md b/doc/api/events.md
new file mode 100644
index 00000000000..e7829c9f479
--- /dev/null
+++ b/doc/api/events.md
@@ -0,0 +1,347 @@
+# Events
+
+## Filter parameters
+
+### Action Types
+
+Available action types for the `action` parameter are:
+
+- `created`
+- `updated`
+- `closed`
+- `reopened`
+- `pushed`
+- `commented`
+- `merged`
+- `joined`
+- `left`
+- `destroyed`
+- `expired`
+
+Note that these options are downcased.
+
+### Target Types
+
+Available target types for the `target_type` parameter are:
+
+- `issue`
+- `milestone`
+- `merge_request`
+- `note`
+- `project`
+- `snippet`
+- `user`
+
+Note that these options are downcased.
+
+### Date formatting
+
+Dates for the `before` and `after` parameters should be supplied in the following format:
+
+```
+YYYY-MM-DD
+```
+
+## List currently authenticated user's events
+
+>**Note:** This endpoint was introduced in GitLab 9.3.
+
+Get a list of events for the authenticated user.
+
+```
+GET /events
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `action` | string | no | Include only events of a particular [action type][action-types] |
+| `target_type` | string | no | Include only events of a particular [target type][target-types] |
+| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] |
+| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] |
+| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` |
+
+Example request:
+
+```
+curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01
+```
+
+Example response:
+
+```json
+[
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":160,
+ "target_type":"Issue",
+ "author_id":25,
+ "data":null,
+ "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.",
+ "created_at":"2017-02-09T10:43:19.667Z",
+ "author":{
+ "name":"User 3",
+ "username":"user3",
+ "id":25,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/user3"
+ },
+ "author_username":"user3"
+ },
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":159,
+ "target_type":"Issue",
+ "author_id":21,
+ "data":null,
+ "target_title":"Nostrum enim non et sed optio illo deleniti non.",
+ "created_at":"2017-02-09T10:43:19.426Z",
+ "author":{
+ "name":"Test User",
+ "username":"ted",
+ "id":21,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/ted"
+ },
+ "author_username":"ted"
+ }
+]
+```
+
+### Get user contribution events
+
+>**Note:** Documentation was formerly located in the [Users API pages][users-api].
+
+Get the contribution events for the specified user, sorted from newest to oldest.
+
+```
+GET /users/:id/events
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID or Username of the user |
+| `action` | string | no | Include only events of a particular [action type][action-types] |
+| `target_type` | string | no | Include only events of a particular [target type][target-types] |
+| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] |
+| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] |
+| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/:id/events
+```
+
+Example response:
+
+```json
+[
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "closed",
+ "target_id": 830,
+ "target_type": "Issue",
+ "author_id": 1,
+ "data": null,
+ "target_title": "Public project search field",
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "root"
+ },
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "opened",
+ "target_id": null,
+ "target_type": null,
+ "author_id": 1,
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "john",
+ "data": {
+ "before": "50d4420237a9de7be1304607147aec22e4a14af7",
+ "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
+ "ref": "refs/heads/master",
+ "user_id": 1,
+ "user_name": "Dmitriy Zaporozhets",
+ "repository": {
+ "name": "gitlabhq",
+ "url": "git@dev.gitlab.org:gitlab/gitlabhq.git",
+ "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.",
+ "homepage": "https://dev.gitlab.org/gitlab/gitlabhq"
+ },
+ "commits": [
+ {
+ "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
+ "message": "Add simple search to projects in public area",
+ "timestamp": "2013-05-13T18:18:08+00:00",
+ "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428",
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "email": "dmitriy.zaporozhets@gmail.com"
+ }
+ }
+ ],
+ "total_commits_count": 1
+ },
+ "target_title": null
+ },
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "closed",
+ "target_id": 840,
+ "target_type": "Issue",
+ "author_id": 1,
+ "data": null,
+ "target_title": "Finish & merge Code search PR",
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "root"
+ },
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "commented on",
+ "target_id": 1312,
+ "target_type": "Note",
+ "author_id": 1,
+ "data": null,
+ "target_title": null,
+ "created_at": "2015-12-04T10:33:58.089Z",
+ "note": {
+ "id": 1312,
+ "body": "What an awesome day!",
+ "attachment": null,
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2015-12-04T10:33:56.698Z",
+ "system": false,
+ "noteable_id": 377,
+ "noteable_type": "Issue"
+ },
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "root"
+ }
+]
+```
+
+## List a Project's visible events
+
+>**Note:** This endpoint has been around longer than the others. Documentation was formerly located in the [Projects API pages][projects-api].
+
+Get a list of visible events for a particular project.
+
+```
+GET /:project_id/events
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `project_id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `action` | string | no | Include only events of a particular [action type][action-types] |
+| `target_type` | string | no | Include only events of a particular [target type][target-types] |
+| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] |
+| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] |
+| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` |
+
+Example request:
+
+```
+curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:project_id/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01
+```
+
+Example response:
+
+```json
+[
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":160,
+ "target_type":"Issue",
+ "author_id":25,
+ "data":null,
+ "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.",
+ "created_at":"2017-02-09T10:43:19.667Z",
+ "author":{
+ "name":"User 3",
+ "username":"user3",
+ "id":25,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/user3"
+ },
+ "author_username":"user3"
+ },
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":159,
+ "target_type":"Issue",
+ "author_id":21,
+ "data":null,
+ "target_title":"Nostrum enim non et sed optio illo deleniti non.",
+ "created_at":"2017-02-09T10:43:19.426Z",
+ "author":{
+ "name":"Test User",
+ "username":"ted",
+ "id":21,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/ted"
+ },
+ "author_username":"ted"
+ }
+]
+```
+
+[target-types]: #target-types "Target Type parameter"
+[action-types]: #action-types "Action Type parameter"
+[date-formatting]: #date-formatting "Date Formatting guidance"
+[projects-api]: projects.md "Projects API pages"
+[users-api]: users.md "Users API pages"
diff --git a/doc/api/project_snippets.md b/doc/api/project_snippets.md
index ff379473961..92491de4daa 100644
--- a/doc/api/project_snippets.md
+++ b/doc/api/project_snippets.md
@@ -43,6 +43,7 @@ Parameters:
"id": 1,
"title": "test",
"file_name": "add.rb",
+ "description": "Ruby test snippet",
"author": {
"id": 1,
"username": "john_smith",
@@ -70,6 +71,7 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `title` (required) - The title of a snippet
- `file_name` (required) - The name of a snippet file
+- `description` (optional) - The description of a snippet
- `code` (required) - The content of a snippet
- `visibility` (required) - The snippet's visibility
@@ -87,6 +89,7 @@ Parameters:
- `snippet_id` (required) - The ID of a project's snippet
- `title` (optional) - The title of a snippet
- `file_name` (optional) - The name of a snippet file
+- `description` (optional) - The description of a snippet
- `code` (optional) - The content of a snippet
- `visibility` (optional) - The snippet's visibility
diff --git a/doc/api/projects.md b/doc/api/projects.md
index 62c78ddc32e..bf21aa0e179 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -81,6 +81,7 @@ Parameters:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
"archived": false,
"avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png",
"shared_runners_enabled": true,
@@ -138,6 +139,8 @@ Parameters:
"kind": "group",
"full_path": "brightbox"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
@@ -225,6 +228,8 @@ Parameters:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
@@ -305,143 +310,7 @@ GET /projects/:id/users
### Get project events
-Get the events for the specified project sorted from newest to oldest. This
-endpoint can be accessed without authentication if the project is publicly
-accessible.
-
-```
-GET /projects/:id/events
-```
-
-Parameters:
-
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
-
-```json
-[
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 830,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Public project search field",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "opened",
- "target_id": null,
- "target_type": null,
- "author_id": 1,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "john",
- "data": {
- "before": "50d4420237a9de7be1304607147aec22e4a14af7",
- "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "ref": "refs/heads/master",
- "user_id": 1,
- "user_name": "Dmitriy Zaporozhets",
- "repository": {
- "name": "gitlabhq",
- "url": "git@dev.gitlab.org:gitlab/gitlabhq.git",
- "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.",
- "homepage": "https://dev.gitlab.org/gitlab/gitlabhq"
- },
- "commits": [
- {
- "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "message": "Add simple search to projects in public area",
- "timestamp": "2013-05-13T18:18:08+00:00",
- "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "email": "dmitriy.zaporozhets@gmail.com"
- }
- }
- ],
- "total_commits_count": 1
- },
- "target_title": null
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 840,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Finish & merge Code search PR",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "commented on",
- "target_id": 1312,
- "target_type": "Note",
- "author_id": 1,
- "data": null,
- "target_title": null,
- "created_at": "2015-12-04T10:33:58.089Z",
- "note": {
- "id": 1312,
- "body": "What an awesome day!",
- "attachment": null,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "created_at": "2015-12-04T10:33:56.698Z",
- "system": false,
- "noteable_id": 377,
- "noteable_type": "Issue"
- },
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- }
-]
-```
+Please refer to the [Events API documentation](events.md#list-a-projects-visible-events)
### Create project
@@ -546,6 +415,8 @@ Parameters:
Forks a project into the user namespace of the authenticated user or the one provided.
+The forking operation for a project is asynchronous and is completed in a background job. The request will return immediately. To determine whether the fork of the project has completed, query the `import_status` for the new project.
+
```
POST /projects/:id/fork
```
@@ -611,6 +482,7 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
"archived": true,
"avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png",
"shared_runners_enabled": true,
@@ -676,6 +548,7 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
"archived": true,
"avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png",
"shared_runners_enabled": true,
@@ -747,6 +620,8 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
@@ -829,6 +704,8 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md
index 0b5782a8cc4..18ceb8f779e 100644
--- a/doc/api/repository_files.md
+++ b/doc/api/repository_files.md
@@ -111,6 +111,7 @@ Parameters:
- `author_name` (optional) - Specify the commit author's name
- `content` (required) - New file content
- `commit_message` (required) - Commit message
+- `last_commit_id` (optional) - Last known file commit id
If the commit fails for any reason we return a 400 error with a non-specific
error message. Possible causes for a failed commit include:
diff --git a/doc/api/snippets.md b/doc/api/snippets.md
index fb8cf97896c..efaab712367 100644
--- a/doc/api/snippets.md
+++ b/doc/api/snippets.md
@@ -48,6 +48,7 @@ Example response:
"id": 1,
"title": "test",
"file_name": "add.rb",
+ "description": "Ruby test snippet",
"author": {
"id": 1,
"username": "john_smith",
@@ -73,16 +74,17 @@ POST /snippets
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `title` | String | yes | The title of a snippet |
-| `file_name` | String | yes | The name of a snippet file |
-| `content` | String | yes | The content of a snippet |
-| `visibility` | String | yes | The snippet's visibility |
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `title` | String | yes | The title of a snippet |
+| `file_name` | String | yes | The name of a snippet file |
+| `content` | String | yes | The content of a snippet |
+| `description` | String | no | The description of a snippet |
+| `visibility` | String | no | The snippet's visibility |
``` bash
-curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets
+curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets
```
Example response:
@@ -92,6 +94,7 @@ Example response:
"id": 1,
"title": "This is a snippet",
"file_name": "test.txt",
+ "description": "Hello World snippet",
"author": {
"id": 1,
"username": "john_smith",
@@ -117,13 +120,14 @@ PUT /snippets/:id
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | Integer | yes | The ID of a snippet |
-| `title` | String | no | The title of a snippet |
-| `file_name` | String | no | The name of a snippet file |
-| `content` | String | no | The content of a snippet |
-| `visibility` | String | no | The snippet's visibility |
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | Integer | yes | The ID of a snippet |
+| `title` | String | no | The title of a snippet |
+| `file_name` | String | no | The name of a snippet file |
+| `description` | String | no | The description of a snippet |
+| `content` | String | no | The content of a snippet |
+| `visibility` | String | no | The snippet's visibility |
``` bash
@@ -137,6 +141,7 @@ Example response:
"id": 1,
"title": "test",
"file_name": "add.rb",
+ "description": "description of snippet",
"author": {
"id": 1,
"username": "john_smith",
diff --git a/doc/api/users.md b/doc/api/users.md
index 331f9a9b80b..f4167ba2605 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -300,6 +300,9 @@ DELETE /users/:id
Parameters:
- `id` (required) - The ID of the user
+- `hard_delete` (optional) - If true, contributions that would usually be
+ [moved to the ghost user](../user/profile/account/delete_account.md#associated-records)
+ will be deleted instead, as well as groups owned solely by this user.
## User
@@ -698,147 +701,8 @@ Will return `201 OK` on success, `404 User Not Found` is user cannot be found or
### Get user contribution events
-Get the contribution events for the specified user, sorted from newest to oldest.
+Please refer to the [Events API documentation](events.md#get-user-contribution-events)
-```
-GET /users/:id/events
-```
-
-Parameters:
-
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer | yes | The ID of the user |
-
-```bash
-curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/:id/events
-```
-
-Example response:
-
-```json
-[
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 830,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Public project search field",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "opened",
- "target_id": null,
- "target_type": null,
- "author_id": 1,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "john",
- "data": {
- "before": "50d4420237a9de7be1304607147aec22e4a14af7",
- "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "ref": "refs/heads/master",
- "user_id": 1,
- "user_name": "Dmitriy Zaporozhets",
- "repository": {
- "name": "gitlabhq",
- "url": "git@dev.gitlab.org:gitlab/gitlabhq.git",
- "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.",
- "homepage": "https://dev.gitlab.org/gitlab/gitlabhq"
- },
- "commits": [
- {
- "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "message": "Add simple search to projects in public area",
- "timestamp": "2013-05-13T18:18:08+00:00",
- "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "email": "dmitriy.zaporozhets@gmail.com"
- }
- }
- ],
- "total_commits_count": 1
- },
- "target_title": null
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 840,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Finish & merge Code search PR",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "commented on",
- "target_id": 1312,
- "target_type": "Note",
- "author_id": 1,
- "data": null,
- "target_title": null,
- "created_at": "2015-12-04T10:33:58.089Z",
- "note": {
- "id": 1312,
- "body": "What an awesome day!",
- "attachment": null,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "created_at": "2015-12-04T10:33:56.698Z",
- "system": false,
- "noteable_id": 377,
- "noteable_type": "Issue"
- },
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- }
-]
-```
## Get all impersonation tokens of a user
diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md
index 96834e15bb9..7709541ba9d 100644
--- a/doc/ci/docker/using_docker_images.md
+++ b/doc/ci/docker/using_docker_images.md
@@ -140,21 +140,58 @@ that runner.
## Define an image from a private Docker registry
-Starting with GitLab Runner 0.6.0, you are able to define images located to
-private registries that could also require authentication.
-
-All you have to do is be explicit on the image definition in `.gitlab-ci.yml`.
-
-```yaml
-image: my.registry.tld:5000/namespace/image:tag
-```
-
-In the example above, GitLab Runner will look at `my.registry.tld:5000` for the
-image `namespace/image:tag`.
-
-If the repository is private you need to authenticate your GitLab Runner in the
-registry. Learn how to do that on
-[GitLab Runner's documentation][runner-priv-reg].
+> **Notes:**
+- This feature requires GitLab Runner **1.8** or higher
+- For GitLab Runner versions **>= 0.6, <1.8** there was a partial
+ support for using private registries, which required manual configuration
+ of credentials on runner's host. We recommend to upgrade your Runner to
+ at least version **1.8** if you want to use private registries.
+- If the repository is private you need to authenticate your GitLab Runner in the
+ registry. Learn more about how [GitLab Runner works in this case][runner-priv-reg].
+
+As an example, let's assume that you want to use the `registry.example.com/private/image:latest`
+image which is private and requires you to login into a private container registry.
+To configure access for `registry.example.com`, follow these steps:
+
+1. Do a `docker login` on your computer:
+
+ ```bash
+ docker login registry.example.com --username my_username --password my_password
+ ```
+
+1. Copy the content of `~/.docker/config.json`
+1. Create a [secret variable] `DOCKER_AUTH_CONFIG` with the content of the
+ Docker configuration file as the value:
+
+ ```json
+ {
+ "auths": {
+ "registry.example.com": {
+ "auth": "bXlfdXNlcm5hbWU6bXlfcGFzc3dvcmQ="
+ }
+ }
+ }
+ ```
+
+1. Do a `docker logout` on your computer if you don't need access to the
+ registry from it:
+
+ ```bash
+ docker logout registry.example.com
+ ```
+
+1. You can now use any private image from `registry.example.com` defined in
+ `image` and/or `services` in your [`.gitlab-ci.yml` file][yaml-priv-reg]:
+
+ ```yaml
+ image: my.registry.tld:5000/namespace/image:tag
+ ```
+
+ In the example above, GitLab Runner will look at `my.registry.tld:5000` for the
+ image `namespace/image:tag`.
+
+You can add configuration for as many registries as you want, adding more
+registries to the `"auths"` hash as described above.
## Accessing the services
@@ -283,4 +320,5 @@ creation.
[tutum/wordpress]: https://hub.docker.com/r/tutum/wordpress/
[postgres-hub]: https://hub.docker.com/r/_/postgres/
[mysql-hub]: https://hub.docker.com/r/_/mysql/
-[runner-priv-reg]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/configuration/advanced-configuration.md#using-a-private-docker-registry
+[runner-priv-reg]: http://docs.gitlab.com/runner/configuration/advanced-configuration.html#using-a-private-container-registry
+[secret variable]: ../variables/README.md#secret-variables
diff --git a/doc/ci/environments.md b/doc/ci/environments.md
index 169e0fbae3d..3393030210e 100644
--- a/doc/ci/environments.md
+++ b/doc/ci/environments.md
@@ -94,6 +94,12 @@ the name given in `.gitlab-ci.yml` (with any variables expanded), while the
second is a "cleaned-up" version of the name, suitable for use in URLs, DNS,
etc.
+>**Note:**
+Starting with GitLab 9.3, the environment URL is exposed to the Runner via
+`$CI_ENVIRONMENT_URL`. The URL would be expanded from `.gitlab-ci.yml`, or if
+the URL was not defined there, the external URL from the environment would be
+used.
+
To sum up, with the above `.gitlab-ci.yml` we have achieved that:
- All branches will run the `test` and `build` jobs.
diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md
index bd53f80ce14..a047e809788 100644
--- a/doc/ci/examples/code_climate.md
+++ b/doc/ci/examples/code_climate.md
@@ -1,11 +1,11 @@
# Analyze project code quality with Code Climate CLI
-This example shows how to run [Code Climate CLI][cli] on your code by using\
+This example shows how to run [Code Climate CLI][cli] on your code by using
GitLab CI and Docker.
-First, you need GitLab Runner with [docker-in-docker executor](../docker/using_docker_build.md#use-docker-in-docker-executor).
+First, you need GitLab Runner with [docker-in-docker executor][dind].
-Once you setup the Runner add new job to `.gitlab-ci.yml`:
+Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codeclimate`:
```yaml
codeclimate:
@@ -25,4 +25,10 @@ codeclimate:
This will create a `codeclimate` job in your CI pipeline and will allow you to
download and analyze the report artifact in JSON format.
+For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically
+extracted and shown right in the merge request widget. [Learn more on code quality
+diffs in merge requests](http://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.md).
+
[cli]: https://github.com/codeclimate/codeclimate
+[dind]: ../docker/using_docker_build.md#use-docker-in-docker-executor
+[ee]: https://about.gitlab.com/gitlab-ee/
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index 1bd1ee93ac5..73aee3c3f56 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -1,124 +1,168 @@
# Runners
-In GitLab CI, Runners run your [yaml](../yaml/README.md).
-A Runner is an isolated (virtual) machine that picks up jobs
-through the coordinator API of GitLab CI.
+In GitLab CI, Runners run the code defined in [`.gitlab-ci.yml`](../yaml/README.md).
+They are isolated (virtual) machines that pick up jobs through the coordinator
+API of GitLab CI.
A Runner can be specific to a certain project or serve any project
in GitLab CI. A Runner that serves all projects is called a shared Runner.
-Ideally, GitLab Runner should not be installed on the same machine as GitLab.
+Ideally, the GitLab Runner should not be installed on the same machine as GitLab.
Read the [requirements documentation](../../install/requirements.md#gitlab-runner)
for more information.
-## Shared vs. Specific Runners
-
-A Runner that is specific only runs for the specified project. A shared Runner
-can run jobs for every project that has enabled the option **Allow shared Runners**.
-
-**Shared Runners** are useful for jobs that have similar requirements,
-between multiple projects. Rather than having multiple Runners idling for
-many projects, you can have a single or a small number of Runners that handle
-multiple projects. This makes it easier to maintain and update Runners.
-
-**Specific Runners** are useful for jobs that have special requirements or for
-projects with a specific demand. If a job has certain requirements, you can set
-up the specific Runner with this in mind, while not having to do this for all
-Runners. For example, if you want to deploy a certain project, you can setup
-a specific Runner to have the right credentials for this.
-
-Projects with high demand of CI activity can also benefit from using specific Runners.
-By having dedicated Runners you are guaranteed that the Runner is not being held
-up by another project's jobs.
+## Shared vs specific Runners
+
+After [installing the Runner][install], you can either register it as shared or
+specific. You can only register a shared Runner if you have admin access to
+the GitLab instance. The main differences between a shared and a specific Runner
+are:
+
+- **Shared Runners** are useful for jobs that have similar requirements,
+ between multiple projects. Rather than having multiple Runners idling for
+ many projects, you can have a single or a small number of Runners that handle
+ multiple projects. This makes it easier to maintain and update them.
+ Shared Runners process jobs using a [fair usage queue](#how-shared-runners-pick-jobs).
+ In contrast to specific Runners that use a FIFO queue, this prevents
+ cases where projects create hundreds of jobs which can lead to eating all
+ available shared Runners resources.
+- **Specific Runners** are useful for jobs that have special requirements or for
+ projects with a specific demand. If a job has certain requirements, you can set
+ up the specific Runner with this in mind, while not having to do this for all
+ Runners. For example, if you want to deploy a certain project, you can setup
+ a specific Runner to have the right credentials for this. The [usage of tags](#using-tags)
+ may be useful in this case. Specific Runners process jobs using a [FIFO] queue.
+
+A Runner that is specific only runs for the specified project(s). A shared Runner
+can run jobs for every project that has enabled the option **Allow shared Runners**
+under **Settings ➔ Pipelines**.
+
+Projects with high demand of CI activity can also benefit from using specific
+Runners. By having dedicated Runners you are guaranteed that the Runner is not
+being held up by another project's jobs.
You can set up a specific Runner to be used by multiple projects. The difference
with a shared Runner is that you have to enable each project explicitly for
the Runner to be able to run its jobs.
Specific Runners do not get shared with forked projects automatically.
-A fork does copy the CI settings (jobs, allow shared, etc) of the cloned repository.
-
-# Creating and Registering a Runner
-
-There are several ways to create a Runner. Only after creation, upon
-registration its status as Shared or Specific is determined.
-
-[See the documentation for](https://docs.gitlab.com/runner/install)
-the different methods of installing a Runner instance.
+A fork does copy the CI settings (jobs, allow shared, etc) of the cloned
+repository.
-After installing the Runner, you can either register it as `Shared` or as `Specific`.
-You can only register a Shared Runner if you have admin access to the GitLab instance.
+## Registering a shared Runner
-## Registering a Shared Runner
+You can only register a shared Runner if you are an admin of the GitLab instance.
-You can only register a shared Runner if you are an admin on the linked
-GitLab instance.
+1. Grab the shared-Runner token on the `admin/runners` page
-Grab the shared-Runner token on the `admin/runners` page of your GitLab CI
-instance.
+ ![Shared Runners admin area](img/shared_runners_admin.png)
-![shared token](shared_runner.png)
+1. [Register the Runner][register]
-Now simply register the Runner as any Runner:
+Shared Runners are enabled by default as of GitLab 8.2, but can be disabled
+with the **Disable shared Runners** button which is present under each project's
+**Settings ➔ Pipelines** page. Previous versions of GitLab defaulted shared
+Runners to disabled.
-```
-sudo gitlab-ci-multi-runner register
-```
-
-Shared Runners are enabled by default as of GitLab 8.2, but can be disabled with the
-`DISABLE SHARED RUNNERS` button. Previous versions of GitLab defaulted shared Runners to
-disabled.
-
-## Registering a Specific Runner
+## Registering a specific Runner
Registering a specific can be done in two ways:
1. Creating a Runner with the project registration token
1. Converting a shared Runner into a specific Runner (one-way, admin only)
-There are several ways to create a Runner instance. The steps below only
-concern registering the Runner on GitLab CI.
-
-### Registering a Specific Runner with a Project Registration token
+### Registering a specific Runner with a project registration token
To create a specific Runner without having admin rights to the GitLab instance,
-visit the project you want to make the Runner work for in GitLab CI.
+visit the project you want to make the Runner work for in GitLab:
-Click on the Runner tab and use the registration token you find there to
-setup a specific Runner for this project.
+1. Go to **Settings ➔ Pipelines** to obtain the token
+1. [Register the Runner][register]
-![project Runners in GitLab CI](project_specific.png)
+### Making an existing shared Runner specific
-To register the Runner, run the command below and follow instructions:
+If you are an admin on your GitLab instance, you can turn any shared Runner into
+a specific one, but not the other way around. Keep in mind that this is a one
+way transition.
-```
-sudo gitlab-ci-multi-runner register
-```
+1. Go to the Runners in the admin area **Overview ➔ Runners** (`/admin/runners`)
+ and find your Runner
+1. Enable any projects under **Restrict projects for this Runner** to be used
+ with the Runner
-### Lock a specific Runner from being enabled for other projects
+From now on, the shared Runner will be specific to those projects.
+
+## Locking a specific Runner from being enabled for other projects
You can configure a Runner to assign it exclusively to a project. When a
Runner is locked this way, it can no longer be enabled for other projects.
-This setting is available on each Runner in *Project Settings* > *Runners*.
+This setting can be enabled the first time you [register a Runner][register] and
+can be changed afterwards under each Runner's settings.
+
+To lock/unlock a Runner:
+
+1. Visit your project's **Settings ➔ Pipelines**
+1. Find the Runner you wish to lock/unlock and make sure it's enabled
+1. Click the pencil button
+1. Check the **Lock to current projects** option
+1. Click **Save changes** for the changes to take effect
-### Making an existing Shared Runner Specific
+## How shared Runners pick jobs
-If you are an admin on your GitLab instance,
-you can make any shared Runner a specific Runner, _but you can not
-make a specific Runner a shared Runner_.
+Shared Runners abide to a process queue we call fair usage. The fair usage
+algorithm tries to assign jobs to shared Runners from projects that have the
+lowest number of jobs currently running on shared Runners.
-To make a shared Runner specific, go to the Runner page (`/admin/runners`)
-and find your Runner. Add any projects on the left to make this Runner
-run exclusively for these projects, therefore making it a specific Runner.
+**Example 1**
-![making a shared Runner specific](shared_to_specific_admin.png)
+We have following jobs in queue:
-## Using Shared Runners Effectively
+- job 1 for project 1
+- job 2 for project 1
+- job 3 for project 1
+- job 4 for project 2
+- job 5 for project 2
+- job 6 for project 3
+
+With the fair usage algorithm jobs are assigned in following order:
+
+1. We choose job 1, because project 1 doesn't run currently any jobs and has the lowest job number from projects that doesn't run jobs
+1. We choose job 4, because project 2 doesn't run currently any jobs and has the lowest job number from projects that doesn't run jobs
+1. We choose job 6, because project 3 doesn't run currently any jobs and has the lowest job number from projects that doesn't run jobs
+1. We choose job 2, because project 1 as other it runs 1 job
+1. We choose job 5, because project 2 runs 1 job, where project 1 runs 2 jobs now
+1. We choose job 3, because project 1 and runs 2 jobs
+
+---
+
+**Example 2**
+
+We have following jobs in queue:
+
+- job 1 for project 1
+- job 2 for project 1
+- job 3 for project 1
+- job 4 for project 2
+- job 5 for project 2
+- job 6 for project 3
+
+With the fair usage algorithm jobs are assigned in following order:
+
+1. We choose job 1, because project 1 doesn't run currently any jobs and has the lowest job number from projects that doesn't run jobs
+1. We finish job 1
+1. We choose job 2, because project 1 doesn't run currently any jobs and has the lowest job number from projects that doesn't run jobs
+1. We choose job 4, because project 2 doesn't run currently any jobs and has the lowest job number from projects that doesn't run jobs
+1. We finish job 4
+1. We choose job 5, because project 2 doesn't run currently any jobs and has the lowest job number from projects that doesn't run jobs
+1. We choose job 6, because project 3 doesn't run currently any jobs
+1. We choose job 3, because project 1, 2 and 3 runs exactly one job now
+
+## Using shared Runners effectively
If you are planning to use shared Runners, there are several things you
should keep in mind.
-### Use Tags
+### Using tags
You must setup a Runner to be able to run all the different types of jobs
that it may encounter on the projects it's shared over. This would be
@@ -130,17 +174,27 @@ shared Runners will only run the jobs they are equipped to run.
For instance, at GitLab we have Runners tagged with "rails" if they contain
the appropriate dependencies to run Rails test suites.
-### Prevent Runner with tags from picking jobs without tags
+### Preventing Runners with tags from picking jobs without tags
You can configure a Runner to prevent it from picking jobs with tags when
-the Runner does not have tags assigned. This setting is available on each
-Runner in *Project Settings* > *Runners*.
+the Runner does not have tags assigned. This setting can be enabled the first
+time you [register a Runner][register] and can be changed afterwards under
+each Runner's settings.
+
+To make a Runner pick tagged/untagged jobs:
+
+1. Visit your project's **Settings ➔ Pipelines**
+1. Find the Runner you wish and make sure it's enabled
+1. Click the pencil button
+1. Check the **Run untagged jobs** option
+1. Click **Save changes** for the changes to take effect
### Be careful with sensitive information
If you can run a job on a Runner, you can get access to any code it runs
and get the token of the Runner. With shared Runners, this means that anyone
-that runs jobs on the Runner, can access anyone else's code that runs on the Runner.
+that runs jobs on the Runner, can access anyone else's code that runs on the
+Runner.
In addition, because you can get access to the Runner token, it is possible
to create a clone of a Runner and submit false jobs, for example.
@@ -160,3 +214,7 @@ project.
Mentioned briefly earlier, but the following things of Runners can be exploited.
We're always looking for contributions that can mitigate these
[Security Considerations](https://docs.gitlab.com/runner/security/).
+
+[install]: http://docs.gitlab.com/runner/install/
+[fifo]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)
+[register]: http://docs.gitlab.com/runner/register/
diff --git a/doc/ci/runners/img/shared_runners_admin.png b/doc/ci/runners/img/shared_runners_admin.png
new file mode 100644
index 00000000000..e049b339b36
--- /dev/null
+++ b/doc/ci/runners/img/shared_runners_admin.png
Binary files differ
diff --git a/doc/ci/runners/project_specific.png b/doc/ci/runners/project_specific.png
deleted file mode 100644
index c812defa67b..00000000000
--- a/doc/ci/runners/project_specific.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/runners/shared_runner.png b/doc/ci/runners/shared_runner.png
deleted file mode 100644
index 31574a17764..00000000000
--- a/doc/ci/runners/shared_runner.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md
index cb646827fb4..7ec7136d8c6 100644
--- a/doc/ci/triggers/README.md
+++ b/doc/ci/triggers/README.md
@@ -4,15 +4,22 @@
- [Introduced][ci-229] in GitLab CE 7.14.
- GitLab 8.12 has a completely redesigned job permissions system. Read all
about the [new model and its implications](../../user/project/new_ci_build_permissions_model.md#job-triggers).
-- GitLab 9.0 introduced a trigger ownership to solve permission problems.
-Triggers can be used to force a rebuild of a specific `ref` (branch or tag)
-with an API call.
+Triggers can be used to force a pipeline rerun of a specific `ref` (branch or
+tag) with an API call.
-## Add a trigger
+## Authentication tokens
+
+The following methods of authentication are supported.
+
+### Trigger token
+
+A unique trigger token can be obtained when [adding a new trigger](#adding-a-new-trigger).
+
+## Adding a new trigger
You can add a new trigger by going to your project's
-**Settings ➔ Pipelines ➔ Triggers**. The **Add trigger** button will
+**Settings ➔ Pipelines** under **Triggers**. The **Add trigger** button will
create a new token which you can then use to trigger a rerun of this
particular project's pipeline.
@@ -22,7 +29,10 @@ overview of the time the triggers were last used.
![Triggers page overview](img/triggers_page.png)
-## Take ownership
+## Taking ownership of a trigger
+
+> **Note**:
+GitLab 9.0 introduced a trigger ownership to solve permission problems.
Each created trigger when run will impersonate their associated user including
their access to projects and their project permissions.
@@ -30,26 +40,20 @@ their access to projects and their project permissions.
You can take ownership of existing triggers by clicking *Take ownership*.
From now on the trigger will be run as you.
-## Legacy triggers
-
-Old triggers, created before 9.0 will be marked as Legacy. Triggers with
-the legacy label do not have an associated user and only have access
-to the current project.
-
-Legacy trigger are considered deprecated and will be removed
-with one of the future versions of GitLab.
-
-## Revoke a trigger
+## Revoking a trigger
You can revoke a trigger any time by going at your project's
-**Settings > Triggers** and hitting the **Revoke** button. The action is
-irreversible.
+**Settings ➔ Pipelines** under **Triggers** and hitting the **Revoke** button.
+The action is irreversible.
-## Trigger a pipeline
+## Triggering a pipeline
-> **Note**:
-Valid refs are only the branches and tags. If you pass a commit SHA as a ref,
-it will not trigger a job.
+> **Notes**:
+- Valid refs are only the branches and tags. If you pass a commit SHA as a ref,
+ it will not trigger a job.
+- If your project is public, passing the token in plain text is probably not the
+ wisest idea, so you might want to use a
+ [secret variable](../variables/README.md#secret-variables) for that purpose.
To trigger a job you need to send a `POST` request to GitLab's API endpoint:
@@ -57,11 +61,11 @@ To trigger a job you need to send a `POST` request to GitLab's API endpoint:
POST /projects/:id/trigger/pipeline
```
-The required parameters are the trigger's `token` and the Git `ref` on which
-the trigger will be performed. Valid refs are the branch and the tag. The `:id`
-of a project can be found by [querying the API](../../api/projects.md)
-or by visiting the **Pipelines** settings page which provides
-self-explanatory examples.
+The required parameters are the [trigger's `token`](#authentication-tokens)
+and the Git `ref` on which the trigger will be performed. Valid refs are the
+branch and the tag. The `:id` of a project can be found by
+[querying the API](../../api/projects.md) or by visiting the **Pipelines**
+settings page which provides self-explanatory examples.
When a rerun of a pipeline is triggered, the information is exposed in GitLab's
UI under the **Jobs** page and the jobs are marked as triggered 'by API'.
@@ -78,46 +82,7 @@ below.
---
-See the [Examples](#examples) section for more details on how to actually
-trigger a rebuild.
-
-## Trigger a pipeline from webhook
-
-> Introduced in GitLab 8.14.
-
-To trigger a job from webhook of another project you need to add the following
-webhook url for Push and Tag push events:
-
-```
-https://gitlab.example.com/api/v4/projects/:id/ref/:ref/trigger/pipeline?token=TOKEN
-```
-
-> **Note**:
-- `ref` should be passed as part of url in order to take precedence over `ref`
- from webhook body that designates the branchref that fired the trigger in the source repository.
-- `ref` should be url encoded if contains slashes.
-
-## Pass job variables to a trigger
-
-You can pass any number of arbitrary variables in the trigger API call and they
-will be available in GitLab CI so that they can be used in your `.gitlab-ci.yml`
-file. The parameter is of the form:
-
-```
-variables[key]=value
-```
-
-This information is also exposed in the UI.
-
-![Job variables in UI](img/trigger_variables.png)
-
----
-
-See the [Examples](#examples) section below for more details.
-
-## Examples
-
-Using cURL you can trigger a rebuild with minimal effort, for example:
+By using cURL you can trigger a pipeline rerun with minimal effort, for example:
```bash
curl --request POST \
@@ -135,8 +100,6 @@ curl --request POST \
"https://gitlab.example.com/api/v4/projects/9/trigger/pipeline?token=TOKEN&ref=master"
```
-### Triggering a pipeline within `.gitlab-ci.yml`
-
You can also benefit by using triggers in your `.gitlab-ci.yml`. Let's say that
you have two projects, A and B, and you want to trigger a rebuild on the `master`
branch of project B whenever a tag on project A is created. This is the job you
@@ -156,14 +119,37 @@ Now, whenever a new tag is pushed on project A, the job will run and the
`stage: deploy` ensures that this job will run only after all jobs with
`stage: test` complete successfully.
-_**Note:** If your project is public, passing the token in plain text is
-probably not the wisest idea, so you might want to use a
-[secure variable](../variables/README.md#user-defined-variables-secure-variables)
-for that purpose._
+## Triggering a pipeline from a webhook
-### Making use of trigger variables
+> **Notes**:
+- Introduced in GitLab 8.14.
+- `ref` should be passed as part of the URL in order to take precedence over
+ `ref` from the webhook body that designates the branch ref that fired the
+ trigger in the source repository.
+- `ref` should be URL-encoded if it contains slashes.
-Using trigger variables can be proven useful for a variety of reasons.
+To trigger a job from a webhook of another project you need to add the following
+webhook URL for Push and Tag events (change the project ID, ref and token):
+
+```
+https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/pipeline?token=TOKEN
+```
+
+## Making use of trigger variables
+
+You can pass any number of arbitrary variables in the trigger API call and they
+will be available in GitLab CI so that they can be used in your `.gitlab-ci.yml`
+file. The parameter is of the form:
+
+```
+variables[key]=value
+```
+
+This information is also exposed in the UI.
+
+![Job variables in UI](img/trigger_variables.png)
+
+Using trigger variables can be proven useful for a variety of reasons:
* Identifiable jobs. Since the variable is exposed in the UI you can know
why the rebuild was triggered if you pass a variable that explains the
@@ -208,15 +194,7 @@ curl --request POST \
https://gitlab.example.com/api/v4/projects/9/trigger/pipeline
```
-### Using a webhook to trigger a pipeline
-
-You can add the following webhook to another project in order to trigger a job:
-
-```
-https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/pipeline?token=TOKEN&variables[UPLOAD_TO_S3]=true
-```
-
-### Using cron to trigger nightly pipelines
+## Using cron to trigger nightly pipelines
>**Note:**
The following behavior can also be achieved through GitLab's UI with
@@ -230,4 +208,18 @@ branch of project with ID `9` every night at `00:30`:
30 0 * * * curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/pipeline
```
+## Legacy triggers
+
+Old triggers, created before GitLab 9.0 will be marked as legacy.
+
+Triggers with the legacy label do not have an associated user and only have
+access to the current project. They are considered deprecated and will be
+removed with one of the future versions of GitLab. You are advised to
+[take ownership](#taking-ownership) of any legacy triggers.
+
+[ee-2017]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2017
[ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229
+[ee]: https://about.gitlab.com/gitlab-ee/
+[variables]: ../variables/README.md
+[predef]: ../variables/README.md#predefined-variables-environment-variables
+[registry]: ../../user/project/container_registry.md
diff --git a/doc/ci/triggers/img/triggers_page.png b/doc/ci/triggers/img/triggers_page.png
index eafd8519a23..7dc8f91cf7e 100644
--- a/doc/ci/triggers/img/triggers_page.png
+++ b/doc/ci/triggers/img/triggers_page.png
Binary files differ
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index 76ad7c564a3..d1f9881e51b 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -43,6 +43,7 @@ future GitLab releases.**
| **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled |
| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job |
| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. |
+| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job |
| **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally |
| **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started |
| **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` |
@@ -56,9 +57,10 @@ future GitLab releases.**
| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] |
| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run |
| **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally |
-| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built |
+| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) |
| **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built |
| **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name |
+| **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. |
| **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project |
| **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry |
| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project |
@@ -118,11 +120,11 @@ The YAML-defined variables are also set to all created
tune them.
Variables can be defined at a global level, but also at a job level. To turn off
-global defined variables in your job, define an empty array:
+global defined variables in your job, define an empty hash:
```yaml
job_name:
- variables: []
+ variables: {}
```
You are able to use other variables inside your variable definition (or escape them with `$$`):
@@ -343,20 +345,45 @@ All variables are set as environment variables in the build environment, and
they are accessible with normal methods that are used to access such variables.
In most cases `bash` or `sh` is used to execute the job script.
-To access the variables (predefined and user-defined) in a `bash`/`sh` environment,
-prefix the variable name with the dollar sign (`$`):
+To access environment variables, use the syntax for your Runner's [shell][shellexecutors].
-```
+| Shell | Usage |
+|----------------------|-----------------|
+| bash/sh | `$variable` |
+| windows batch | `%variable%` |
+| PowerShell | `$env:variable` |
+
+To access environment variables in bash, prefix the variable name with (`$`):
+
+```yaml
job_name:
script:
- echo $CI_JOB_ID
```
+To access environment variables in **Windows Batch**, surround the variable
+with (`%`):
+
+```yaml
+job_name:
+ script:
+ - echo %CI_JOB_ID%
+```
+
+To access environment variables in a **Windows PowerShell** environment, prefix
+the variable name with (`$env:`):
+
+```yaml
+job_name:
+ script:
+ - echo $env:CI_JOB_ID
+```
+
You can also list all environment variables with the `export` command,
but be aware that this will also expose the values of all the secret variables
you set, in the job log:
-```
+```yaml
job_name:
script:
- export
@@ -403,3 +430,5 @@ export CI_REGISTRY_PASSWORD="longalfanumstring"
[triggers]: ../triggers/README.md#pass-job-variables-to-a-trigger
[protected branches]: ../../user/project/protected_branches.md
[protected tags]: ../../user/project/protected_tags.md
+[shellexecutors]: https://docs.gitlab.com/runner/executors/
+[eep]: https://about.gitlab.com/gitlab-ee/ "Available only in GitLab Enterprise Edition Premium"
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index fab5d14ac54..8a0662db6fd 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -297,6 +297,15 @@ cache:
untracked: true
```
+If you use **Windows PowerShell** to run your shell scripts you need to replace
+`$` with `$env:`:
+
+```yaml
+cache:
+ key: "$env:CI_JOB_STAGE/$env:CI_COMMIT_REF_NAME"
+ untracked: true
+```
+
## Jobs
`.gitlab-ci.yml` allows you to specify an unlimited number of jobs. Each job
@@ -384,7 +393,8 @@ There are a few rules that apply to the usage of refs policy:
* `only` and `except` are inclusive. If both `only` and `except` are defined
in a job specification, the ref is filtered by `only` and `except`.
* `only` and `except` allow the use of regular expressions.
-* `only` and `except` allow the use of special keywords: `branches`, `tags`, and `triggers`.
+* `only` and `except` allow the use of special keywords:
+`api`, `branches`, `external`, `tags`, `pushes`, `schedules`, `triggers`, and `web`
* `only` and `except` allow to specify a repository path to filter jobs for
forks.
@@ -402,7 +412,7 @@ job:
```
In this example, `job` will run only for refs that are tagged, or if a build is
-explicitly requested via an API trigger.
+explicitly requested via an API trigger or a [Pipeline Schedule](../../user/project/pipelines/schedules.md).
```yaml
job:
@@ -410,6 +420,7 @@ job:
only:
- tags
- triggers
+ - schedules
```
The repository path can be used to have jobs executed only for the parent
@@ -434,11 +445,11 @@ but allows you to define job-specific variables.
When the `variables` keyword is used on a job level, it overrides the global YAML
job variables and predefined ones. To turn off global defined variables
-in your job, define an empty array:
+in your job, define an empty hash:
```yaml
job_name:
- variables: []
+ variables: {}
```
Job variables priority is defined in the [variables documentation][variables].
@@ -909,6 +920,16 @@ job:
untracked: true
```
+If you use **Windows PowerShell** to run your shell scripts you need to replace
+`$` with `$env:`:
+
+```yaml
+job:
+ artifacts:
+ name: "$env:CI_JOB_STAGE_$env:CI_COMMIT_REF_NAME"
+ untracked: true
+```
+
#### artifacts:when
> Introduced in GitLab 8.9 and GitLab Runner v1.3.0.
diff --git a/doc/development/README.md b/doc/development/README.md
index af4131c4a8f..40addfd8a4c 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -51,6 +51,8 @@
- [Post Deployment Migrations](post_deployment_migrations.md)
- [Foreign Keys & Associations](foreign_keys.md)
- [Serializing Data](serializing_data.md)
+- [Polymorphic Associations](polymorphic_associations.md)
+- [Single Table Inheritance](single_table_inheritance.md)
## i18n
diff --git a/doc/development/architecture.md b/doc/development/architecture.md
index b36fd52603b..acd5e3c2093 100644
--- a/doc/development/architecture.md
+++ b/doc/development/architecture.md
@@ -12,7 +12,7 @@ Both EE and CE require some add-on components called gitlab-shell and Gitaly. Th
You can imagine GitLab as a physical office.
-**The repositories** are the goods GitLab handling.
+**The repositories** are the goods GitLab handles.
They can be stored in a warehouse.
This can be either a hard disk, or something more complex, such as a NFS filesystem;
@@ -54,7 +54,7 @@ To serve repositories over SSH there's an add-on application called gitlab-shell
### Components
-![GitLab Diagram Overview](gitlab_architecture_diagram.png)
+<img src="https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/pub?w=987&amp;h=797">
_[edit diagram (for GitLab team members only)](https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/edit)_
@@ -66,7 +66,9 @@ When serving repositories over HTTP/HTTPS GitLab utilizes the GitLab API to reso
The add-on component gitlab-shell serves repositories over SSH. It manages the SSH keys within `/home/git/.ssh/authorized_keys` which should not be manually edited. gitlab-shell accesses the bare repositories through Gitaly to serve git objects and communicates with redis to submit jobs to Sidekiq for GitLab to process. gitlab-shell queries the GitLab API to determine authorization and access.
-Gitaly executes git operations from gitlab-shell and Workhorse, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files)
+Gitaly executes git operations from gitlab-shell and the GitLab web app, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files).
+
+You may also be interested in the [production architecture of GitLab.com](https://about.gitlab.com/handbook/infrastructure/production-architecture/).
### Installation Folder Summary
diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md
index 0ef9fc61a61..867c83f1e72 100644
--- a/doc/development/fe_guide/testing.md
+++ b/doc/development/fe_guide/testing.md
@@ -7,7 +7,7 @@ feature tests with Capybara for e2e (end-to-end) integration testing.
Unit and feature tests need to be written for all new features.
Most of the time, you should use rspec for your feature tests.
There are cases where the behaviour you are testing is not worth the time spent running the full application,
-for example, if you are testing styling, animation or small actions that don't involve the backend,
+for example, if you are testing styling, animation, edge cases or small actions that don't involve the backend,
you should write an integration test using Jasmine.
![Testing priority triangle](img/testing_triangle.png)
diff --git a/doc/development/polymorphic_associations.md b/doc/development/polymorphic_associations.md
new file mode 100644
index 00000000000..d63b9fb115f
--- /dev/null
+++ b/doc/development/polymorphic_associations.md
@@ -0,0 +1,146 @@
+# Polymorphic Associations
+
+**Summary:** always use separate tables instead of polymorphic associations.
+
+Rails makes it possible to define so called "polymorphic associations". This
+usually works by adding two columns to a table: a target type column, and a
+target id. For example, at the time of writing we have such a setup for
+`members` with the following columns:
+
+* `source_type`: a string defining the model to use, can be either `Project` or
+ `Namespace`.
+* `source_id`: the ID of the row to retrieve based on `source_type`. For
+ example, when `source_type` is `Project` then `source_id` will contain a
+ project ID.
+
+While such a setup may appear to be useful, it comes with many drawbacks; enough
+that you should avoid this at all costs.
+
+## Space Wasted
+
+Because this setup relies on string values to determine the model to use it will
+end up wasting a lot of space. For example, for `Project` and `Namespace` the
+maximum size is 9 bytes, plus 1 extra byte for every string when using
+PostgreSQL. While this may only be 10 bytes per row, given enough tables and
+rows using such a setup we can end up wasting quite a bit of disk space and
+memory (for any indexes).
+
+## Indexes
+
+Because our associations are broken up into two columns this may result in
+requiring composite indexes for queries to be performed efficiently. While
+composite indexes are not wrong at all, they can be tricky to set up as the
+ordering of columns in these indexes is important to ensure optimal performance.
+
+## Consistency
+
+One really big problem with polymorphic associations is being unable to enforce
+data consistency on the database level using foreign keys. For consistency to be
+enforced on the database level one would have to write their own foreign key
+logic to support polymorphic associations.
+
+Enforcing consistency on the database level is absolutely crucial for
+maintaining a healthy environment, and thus is another reason to avoid
+polymorphic associations.
+
+## Query Overhead
+
+When using polymorphic associations you always need to filter using both
+columns. For example, you may end up writing a query like this:
+
+```sql
+SELECT *
+FROM members
+WHERE source_type = 'Project'
+AND source_id = 13083;
+```
+
+Here PostgreSQL can perform the query quite efficiently if both columns are
+indexed, but as the query gets more complex it may not be able to use these
+indexes efficiently.
+
+## Mixed Responsibilities
+
+Similar to functions and classes a table should have a single responsibility:
+storing data with a certain set of pre-defined columns. When using polymorphic
+associations you are instead storing different types of data (possibly with
+different columns set) in the same table.
+
+## The Solution
+
+Fortunately there is a very simple solution to these problems: simply use a
+separate table for every type you would otherwise store in the same table. Using
+a separate table allows you to use everything a database may provide to ensure
+consistency and query data efficiently, without any additional application logic
+being necessary.
+
+Let's say you have a `members` table storing both approved and pending members,
+for both projects and groups, and the pending state is determined by the column
+`requested_at` being set or not. Schema wise such a setup can lead to various
+columns only being set for certain rows, wasting space. It's also possible that
+certain indexes will only be set for certain rows, again wasting space. Finally,
+querying such a table requires less than ideal queries. For example:
+
+```sql
+SELECT *
+FROM members
+WHERE requested_at IS NULL
+AND source_type = 'GroupMember'
+AND source_id = 4
+```
+
+Instead such a table should be broken up into separate tables. For example, you
+may end up with 4 tables in this case:
+
+* project_members
+* group_members
+* pending_project_members
+* pending_group_members
+
+This makes querying data trivial. For example, to get the members of a group
+you'd run:
+
+```sql
+SELECT *
+FROM group_members
+WHERE group_id = 4
+```
+
+To get all the pending members of a group in turn you'd run:
+
+```sql
+SELECT *
+FROM pending_group_members
+WHERE group_id = 4
+```
+
+If you want to get both you can use a UNION, though you need to be explicit
+about what columns you want to SELECT as otherwise the result set will use the
+columns of the first query. For example:
+
+```sql
+SELECT id, 'Group' AS target_type, group_id AS target_id
+FROM group_members
+
+UNION ALL
+
+SELECT id, 'Project' AS target_type, project_id AS target_id
+FROM project_members
+```
+
+The above example is perhaps a bit silly, but it shows that there's nothing
+stopping you from merging the data together and presenting it on the same page.
+Selecting columns explicitly can also speed up queries as the database has to do
+less work to get the data (compared to selecting all columns, even ones you're
+not using).
+
+Our schema also becomes easier. No longer do we need to both store and index the
+`source_type` column, we can define foreign keys easily, and we don't need to
+filter rows using the `IS NULL` condition.
+
+To summarize: using separate tables allows us to use foreign keys effectively,
+create indexes only where necessary, conserve space, query data more
+efficiently, and scale these tables more easily (e.g. by storing them on
+separate disks). A nice side effect of this is that code can also become easier
+as you won't end up with a single model having to handle different kinds of
+data.
diff --git a/doc/development/single_table_inheritance.md b/doc/development/single_table_inheritance.md
new file mode 100644
index 00000000000..27c3c4f3199
--- /dev/null
+++ b/doc/development/single_table_inheritance.md
@@ -0,0 +1,18 @@
+# Single Table Inheritance
+
+**Summary:** don't use Single Table Inheritance (STI), use separate tables
+instead.
+
+Rails makes it possible to have multiple models stored in the same table and map
+these rows to the correct models using a `type` column. This can be used to for
+example store two different types of SSH keys in the same table.
+
+While tempting to use one should avoid this at all costs for the same reasons as
+outlined in the document ["Polymorphic Associations"](polymorphic_associations.md).
+
+## Solution
+
+The solution is very simple: just use a separate table for every type you'd
+otherwise store in the same table. For example, instead of having a `keys` table
+with `type` set to either `Key` or `DeployKey` you'd have two separate tables:
+`keys` and `deploy_keys`.
diff --git a/doc/install/installation.md b/doc/install/installation.md
index c911b297f8d..84af6432889 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -143,20 +143,19 @@ Then install the Bundler Gem:
## 3. Go
-Since GitLab 8.0, Git HTTP requests are handled by gitlab-workhorse (formerly
-gitlab-git-http-server). This is a small daemon written in Go. To install
-gitlab-workhorse we need a Go compiler. The instructions below assume you
-use 64-bit Linux. You can find downloads for other platforms at the [Go download
+Since GitLab 8.0, GitLab has several daemons written in Go. To install
+GitLab we need a Go compiler. The instructions below assume you use 64-bit
+Linux. You can find downloads for other platforms at the [Go download
page](https://golang.org/dl).
# Remove former Go installation folder
sudo rm -rf /usr/local/go
- curl --remote-name --progress https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz
- echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
- sudo tar -C /usr/local -xzf go1.5.3.linux-amd64.tar.gz
+ curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
+ echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
+ sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
- rm go1.5.3.linux-amd64.tar.gz
+ rm go1.8.3.linux-amd64.tar.gz
## 4. Node
diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md
index b4ffd57afbb..d2442a4fbde 100644
--- a/doc/install/kubernetes/gitlab_chart.md
+++ b/doc/install/kubernetes/gitlab_chart.md
@@ -207,7 +207,9 @@ its class in an annotation.
>**Note:**
The Ingress alone doesn't expose GitLab externally. You need to have a Ingress controller setup to do that.
Setting up an Ingress controller can be done by installing the `nginx-ingress` helm chart. But be sure
-to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md)
+to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md).
+>**Note:**
+If you would like to use the Registry, you will also need to ensure your Ingress supports a [sufficiently large request size](http://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size).
#### Preserving Source IPs
diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md
index 305b4593c73..b8bc0795f2e 100644
--- a/doc/install/kubernetes/gitlab_runner_chart.md
+++ b/doc/install/kubernetes/gitlab_runner_chart.md
@@ -141,7 +141,7 @@ Once you [have configured](#configuration) GitLab Runner in your `values.yml` fi
run the following:
```bash
-helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner
+helm install --namespace <NAMESPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner
```
- `<NAMESPACE>` is the Kubernetes namespace where you want to install the GitLab Runner.
@@ -153,7 +153,7 @@ helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE>
Once your GitLab Runner Chart is installed, configuration changes and chart updates should we done using `helm upgrade`
```bash
-helm upgrade --namepace <NAMEPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner
+helm upgrade --namespace <NAMESPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner
```
Where:
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index 5338ccb9d3a..197a92905c8 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -129,7 +129,8 @@ We _highly_ recommend the use of PostgreSQL instead of MySQL/MariaDB as not all
features of GitLab may work with MySQL/MariaDB. For example, MySQL does not have
the right features to support nested groups in an efficient manner; see
<https://gitlab.com/gitlab-org/gitlab-ce/issues/30472> for more information
-about this. Existing users using GitLab with MySQL/MariaDB are advised to
+about this. GitLab Geo also does [not support MySQL](https://docs.gitlab.com/ee/gitlab-geo/database.html#mysql-replication).
+Existing users using GitLab with MySQL/MariaDB are advised to
migrate to PostgreSQL instead.
The server running the database should have _at least_ 5-10 GB of storage
diff --git a/doc/integration/saml.md b/doc/integration/saml.md
index 2277aa827b7..b5b245c626f 100644
--- a/doc/integration/saml.md
+++ b/doc/integration/saml.md
@@ -201,6 +201,9 @@ Please keep in mind that every sign in attempt will be redirected to the SAML se
so you will not be able to sign in using local credentials. Make sure that at least one
of the SAML users has admin permissions.
+You may also bypass the auto signin feature by browsing to
+https://gitlab.example.com/users/sign_in?auto_sign_in=false.
+
### `attribute_statements`
>**Note:**
diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md
index 044b104f5c2..3ae46019daf 100644
--- a/doc/raketasks/user_management.md
+++ b/doc/raketasks/user_management.md
@@ -71,6 +71,85 @@ sudo gitlab-rake gitlab:two_factor:disable_for_all_users
bundle exec rake gitlab:two_factor:disable_for_all_users RAILS_ENV=production
```
+## Rotate Two-factor Authentication (2FA) encryption key
+
+GitLab stores the secret data enabling 2FA to work in an encrypted database
+column. The encryption key for this data is known as `otp_key_base`, and is
+stored in `config/secrets.yml`.
+
+
+If that file is leaked, but the individual 2FA secrets have not, it's possible
+to re-encrypt those secrets with a new encryption key. This allows you to change
+the leaked key without forcing all users to change their 2FA details.
+
+First, look up the old key. This is in the `config/secrets.yml` file, but
+**make sure you're working with the production section**. The line you're
+interested in will look like this:
+
+```yaml
+production:
+ otp_key_base: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
+```
+
+Next, generate a new secret:
+
+```
+# omnibus-gitlab
+sudo gitlab-rake secret
+
+# installation from source
+bundle exec rake secret RAILS_ENV=production
+```
+
+Now you need to stop the GitLab server, back up the existing secrets file and
+update the database:
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl stop
+sudo cp config/secrets.yml config/secrets.yml.bak
+sudo gitlab-rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key>
+
+# installation from source
+sudo /etc/init.d/gitlab stop
+cp config/secrets.yml config/secrets.yml.bak
+bundle exec rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key> RAILS_ENV=production
+```
+
+The `<old key>` value can be read from `config/secrets.yml`; `<new key>` was
+generated earlier. The **encrypted** values for the user 2FA secrets will be
+written to the specified `filename` - you can use this to rollback in case of
+error.
+
+Finally, change `config/secrets.yml` to set `otp_key_base` to `<new key>` and
+restart. Again, make sure you're operating in the **production** section.
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl start
+
+# installation from source
+sudo /etc/init.d/gitlab start
+```
+
+If there are any problems (perhaps using the wrong value for `old_key`), you can
+restore your backup of `config/secrets.yml` and rollback the changes:
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl stop
+sudo gitlab-rake gitlab:two_factor:rotate_key:rollback filename=backup.csv
+sudo cp config/secrets.yml.bak config/secrets.yml
+sudo gitlab-ctl start
+
+# installation from source
+sudo /etc/init.d/gitlab start
+bundle exec rake gitlab:two_factor:rotate_key:rollback filename=backup.csv RAILS_ENV=production
+cp config/secrets.yml.bak config/secrets.yml
+sudo /etc/init.d/gitlab start
+
+```
+
## Clear authentication tokens for all users. Important! Data loss!
Clear authentication tokens for all users in the GitLab database. This
diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md
index 26049721fd3..0c32e4db53f 100644
--- a/doc/update/9.2-to-9.3.md
+++ b/doc/update/9.2-to-9.3.md
@@ -70,7 +70,27 @@ curl --location https://yarnpkg.com/install.sh | bash -
More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
-### 5. Get latest code
+### 5. Update Go
+
+NOTE: GitLab 9.3 and higher only supports Go 1.8.3 and dropped support for Go 1.5.x through 1.7.x. Be
+sure to upgrade your installation if necessary
+
+You can check which version you are running with `go version`.
+
+Download and install Go:
+
+```bash
+# Remove former Go installation folder
+sudo rm -rf /usr/local/go
+
+curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
+echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
+ sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
+sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
+rm go1.8.3.linux-amd64.tar.gz
+```
+
+### 6. Get latest code
```bash
cd /home/git/gitlab
@@ -97,7 +117,7 @@ cd /home/git/gitlab
sudo -u git -H git checkout 9-3-stable-ee
```
-### 6. Update gitlab-shell
+### 5. Update gitlab-shell
```bash
cd /home/git/gitlab-shell
@@ -107,7 +127,7 @@ sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION)
sudo -u git -H bin/compile
```
-### 7. Update gitlab-workhorse
+### 6. Update gitlab-workhorse
Install and compile gitlab-workhorse. This requires
[Go 1.5](https://golang.org/dl) which should already be on your system from
@@ -123,7 +143,7 @@ sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION)
sudo -u git -H make
```
-### 8. Update Gitaly
+### 7. Update Gitaly
If you have not yet set up Gitaly then follow [Gitaly section of the installation
guide](../install/installation.md#install-gitaly).
@@ -137,7 +157,7 @@ sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION)
sudo -u git -H make
```
-### 9. Update configuration files
+### 10. Update configuration files
#### New configuration options for `gitlab.yml`
@@ -211,7 +231,7 @@ For Ubuntu 16.04.1 LTS:
sudo systemctl daemon-reload
```
-### 10. Install libs, migrations, etc.
+### 11. Install libs, migrations, etc.
```bash
cd /home/git/gitlab
@@ -237,14 +257,14 @@ sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production
**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md).
-### 11. Start application
+### 12. Start application
```bash
sudo service gitlab start
sudo service nginx restart
```
-### 12. Check application status
+### 13. Check application status
Check if GitLab and its environment are configured correctly:
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index b0145b0a759..3fda47b9e34 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -126,7 +126,7 @@ which visibility level you select on project settings.
## GitLab CI
GitLab CI permissions rely on the role the user has in GitLab. There are four
-permission levels it total:
+permission levels in total:
- admin
- master
diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md
index b5d3b009044..e7596f5c577 100644
--- a/doc/user/profile/account/delete_account.md
+++ b/doc/user/profile/account/delete_account.md
@@ -5,21 +5,31 @@
## Associated Records
-> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award emoji, notes, and abuse reports in [GitLab 9.1][ce-10467].
+> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award
+ emoji, notes, and abuse reports in [GitLab 9.1][ce-10467].
+ Hard deletion from abuse reports and spam logs was introduced in
+ [GitLab 9.1][ce-10273], and from the API in [GitLab 9.3][ce-11853].
-When a user account is deleted, not all associated records are deleted with it. Here's a list of things that will not be deleted:
+When a user account is deleted, not all associated records are deleted with it.
+Here's a list of things that will not be deleted:
- Issues that the user created
- Merge requests that the user created
- Notes that the user created
- Abuse reports that the user reported
-- Award emoji that the user craeted
+- Award emoji that the user created
+Instead of being deleted, these records will be moved to a system-wide
+"Ghost User", whose sole purpose is to act as a container for such records.
-Instead of being deleted, these records will be moved to a system-wide "Ghost User", whose sole purpose is to act as a container for such records.
-
+When a user is deleted from an abuse report or spam log, these associated
+records are not ghosted and will be removed, along with any groups the user
+is a sole owner of. Administrators can also request this behaviour when
+deleting users from the [API](../../../api/users.md#user-deletion) or the
+admin area.
[ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393
+[ce-10273]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10273
[ce-10467]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10467
-
+[ce-11853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11853
diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md
index 8a7cff69c8f..75ea911b9bc 100644
--- a/doc/user/project/container_registry.md
+++ b/doc/user/project/container_registry.md
@@ -112,12 +112,14 @@ Make sure that your GitLab Runner is configured to allow building Docker images
following the [Using Docker Build](../../ci/docker/using_docker_build.md)
and [Using the GitLab Container Registry documentation](../../ci/docker/using_docker_build.md#using-the-gitlab-container-registry).
-## Limitations
+## Using with private projects
-In order to use a container image from your private project as an `image:` in
-your `.gitlab-ci.yml`, you have to follow the
-[Using a private Docker Registry][private-docker]
-documentation. This workflow will be simplified in the future.
+If a project is private, credentials will need to be provided for authorization.
+The preferred way to do this, is by using personal access tokens, which can be
+created under `/profile/personal_access_tokens`. The minimal scope needed is:
+`read_registry`.
+
+This feature was introduced in GitLab 9.3.
## Troubleshooting the GitLab Container Registry
@@ -263,4 +265,3 @@ Once the right permissions were set, the error will go away.
[ce-4040]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4040
[docker-docs]: https://docs.docker.com/engine/userguide/intro/
-[private-docker]: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#using-a-private-container-registry
diff --git a/doc/user/project/img/project_settings_list.png b/doc/user/project/img/project_settings_list.png
deleted file mode 100644
index 0bb761b45c9..00000000000
--- a/doc/user/project/img/project_settings_list.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/protected_branches_delete.png b/doc/user/project/img/protected_branches_delete.png
new file mode 100644
index 00000000000..cfdfe6c6c29
--- /dev/null
+++ b/doc/user/project/img/protected_branches_delete.png
Binary files differ
diff --git a/doc/user/project/integrations/img/accessing_integrations.png b/doc/user/project/integrations/img/accessing_integrations.png
deleted file mode 100644
index 3b941f64998..00000000000
--- a/doc/user/project/integrations/img/accessing_integrations.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/integrations/index.md b/doc/user/project/integrations/index.md
index 99093ebaed5..e384ed57de9 100644
--- a/doc/user/project/integrations/index.md
+++ b/doc/user/project/integrations/index.md
@@ -1,10 +1,8 @@
# Project integrations
-You can find the available integrations under the **Integrations** page by
-navigating to the cog icon in the upper right corner of your project. You need
-to have at least [master permission][permissions] on the project.
-
-![Accessing the integrations](img/accessing_integrations.png)
+You can find the available integrations under your project's
+**Settings ➔ Integrations** page. You need to have at least
+[master permission][permissions] on the project.
## Project services
diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md
index 31baea507d7..51989ccaaea 100644
--- a/doc/user/project/integrations/project_services.md
+++ b/doc/user/project/integrations/project_services.md
@@ -6,18 +6,13 @@ functionality to GitLab.
## Accessing the project services
-You can find the available services under the **Integrations** page in your
-project's settings.
+You can find the available services under your project's
+**Settings ➔ Integrations** page.
-1. Navigate to the cog icon in the upper right corner of your project. You need
- to have at least [master permission][permissions] on the project.
+There are more than 20 services to integrate with. Click on the one that you
+want to configure.
- ![Accessing the services](img/accessing_integrations.png)
-
-1. There are more than 20 services to integrate with. Click on the one that you
- want to configure.
-
- ![Project services list](img/project_services.png)
+ ![Project services list](img/project_services.png)
Below, you will find a list of the currently supported ones accompanied with
comprehensive documentation.
diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md
index d0bb1cd11a8..0517ed3ec18 100644
--- a/doc/user/project/integrations/webhooks.md
+++ b/doc/user/project/integrations/webhooks.md
@@ -14,11 +14,8 @@ to the webhook URL.
Webhooks can be used to update an external issue tracker, trigger CI jobs,
update a backup mirror, or even deploy to your production server.
-Navigate to the webhooks page by going to the **Integrations** page from your
-project's settings which can be found under the wheel icon in the upper right
-corner.
-
-![Accessing the integrations](img/accessing_integrations.png)
+Navigate to the webhooks page by going to your project's
+**Settings ➔ Integrations**.
## Webhook endpoint tips
diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md
index 9598cb801be..fe87e6f9495 100644
--- a/doc/user/project/issues/index.md
+++ b/doc/user/project/issues/index.md
@@ -1,4 +1,4 @@
-# GitLab Issues Documentation
+# Issues documentation
The GitLab Issue Tracker is an advanced and complete tool
for tracking the evolution of a new idea or the process
@@ -41,13 +41,13 @@ The image bellow illustrates how an issue looks like:
Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md).
-## New Issue
+## New issue
Read through the [documentation on creating issues](create_new_issue.md).
## Closing issues
-Read through the distinct ways to [close issues](closing_issues.md) on GitLab.
+Learn distinct ways to [close issues](closing_issues.md) in GitLab.
## Create a merge request from an issue
@@ -84,7 +84,7 @@ Learn more about them on the [issue templates documentation](../../project/descr
Learn more about [crosslinking](crosslinking_issues.md) issues and merge requests.
-### GitLab Issue Board
+### Issue Board
The [GitLab Issue Board](https://about.gitlab.com/features/issueboard/) is a way to
enhance your workflow by organizing and prioritizing issues in GitLab.
diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md
index 151ee4728ad..e853bfff444 100644
--- a/doc/user/project/pipelines/job_artifacts.md
+++ b/doc/user/project/pipelines/job_artifacts.md
@@ -12,7 +12,7 @@
to GitLab using GitLab Runner version 1.0 and up. It will not be possible to
browse old artifacts already uploaded to GitLab.
>- This is the user documentation. For the administration guide see
- [administration/job_artifacts.md](../../../administration/job_artifacts.md).
+ [administration/job_artifacts](../../../administration/job_artifacts.md).
Artifacts is a list of files and directories which are attached to a job
after it completes successfully. This feature is enabled by default in all
@@ -29,25 +29,31 @@ pdf:
artifacts:
paths:
- mycv.pdf
+ expire_in: 1 week
```
A job named `pdf` calls the `xelatex` command in order to build a pdf file from
the latex source file `mycv.tex`. We then define the `artifacts` paths which in
turn are defined with the `paths` keyword. All paths to files and directories
-are relative to the repository that was cloned during the build.
+are relative to the repository that was cloned during the build. These uploaded
+artifacts will be kept in GitLab for 1 week as defined by the `expire_in`
+definition. You have the option to keep the artifacts from expiring via the
+[web interface](#browsing-job-artifacts). If you don't define an expiry date,
+the artifacts will be kept forever.
-For more examples on artifacts, follow the artifacts reference in
-[`.gitlab-ci.yml` documentation](../../../ci/yaml/README.md#artifacts).
+For more examples on artifacts, follow the [artifacts reference in
+`.gitlab-ci.yml`](../../../ci/yaml/README.md#artifacts).
## Browsing job artifacts
>**Note:**
-With GitLab 9.2, PDFs, images, videos and other formats can be previewed directly
-in the job artifacts browser without the need to download them.
+With GitLab 9.2, PDFs, images, videos and other formats can be previewed
+directly in the job artifacts browser without the need to download them.
-After a job finishes, if you visit the job's specific page, you can see
-that there are two buttons. One is for downloading the artifacts archive and
-the other for browsing its contents.
+After a job finishes, if you visit the job's specific page, there are three
+buttons. You can download the artifacts archive or browse its contents, whereas
+the **Keep** button appears only if you have set an [expiry date] to the
+artifacts in case you changed your mind and want to keep them.
![Job artifacts browser button](img/job_artifacts_browser_button.png)
@@ -103,7 +109,7 @@ https://example.com/<namespace>/<project>/builds/artifacts/<ref>/download?job=<j
To download a single file from the artifacts use the following URL:
```
-https://example.com/<namespace>/<project>/builds/artifacts/<ref>/file/<path_to_file>?job=<job_name>
+https://example.com/<namespace>/<project>/builds/artifacts/<ref>/raw/<path_to_file>?job=<job_name>
```
For example, to download the latest artifacts of the job named `coverage` of
@@ -118,7 +124,7 @@ To download the file `coverage/index.html` from the same
artifacts use the following URL:
```
-https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/file/coverage/index.html?job=coverage
+https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/raw/coverage/index.html?job=coverage
```
There is also a URL to browse the latest job artifacts:
@@ -145,3 +151,5 @@ information in the UI.
![Latest artifacts button](img/job_latest_artifacts_browser.png)
+
+[expiry date]: ../../../ci/yaml/README.md#artifacts-expire_in
diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md
index 1b42c43cf8f..1d2eba4f74b 100644
--- a/doc/user/project/pipelines/settings.md
+++ b/doc/user/project/pipelines/settings.md
@@ -1,12 +1,7 @@
# Pipelines settings
-To reach the pipelines settings:
-
-1. Navigate to your project and click the cog icon in the upper right corner.
-
- ![Project settings menu](../img/project_settings_list.png)
-
-1. Select **Pipelines** from the menu.
+To reach the pipelines settings navigate to your project's
+**Settings ➔ CI/CD Pipelines**.
The following settings can be configured per project.
diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md
index f7a686d2ccf..0570d9f471f 100644
--- a/doc/user/project/protected_branches.md
+++ b/doc/user/project/protected_branches.md
@@ -27,11 +27,8 @@ See the [Changelog](#changelog) section for changes over time.
To protect a branch, you need to have at least Master permission level. Note
that the `master` branch is protected by default.
-1. Navigate to the main page of the project.
-1. In the upper right corner, click the settings wheel and select **Protected branches**.
-
- ![Project settings list](img/project_settings_list.png)
-
+1. Navigate to your project's **Settings ➔ Repository**
+1. Scroll to find the **Protected branches** section.
1. From the **Branch** dropdown menu, select the branch you want to protect and
click **Protect**. In the screenshot below, we chose the `develop` branch.
@@ -97,8 +94,33 @@ all matching branches:
![Protected branch matches](img/protected_branches_matches.png)
+## Deleting a protected branch
+
+> [Introduced][ce-21393] in GitLab 9.3.
+
+From time to time, it may be required to delete or clean up branches that are
+protected.
+
+User with [Master permissions][perm] and up can manually delete protected
+branches via GitLab's web interface:
+
+1. Visit **Repository > Branches**
+1. Click on the delete icon next to the branch you wish to delete
+1. In order to prevent accidental deletion, an additional confirmation is
+ required
+
+ ![Delete protected branches](img/protected_branches_delete.png)
+
+Deleting a protected branch is only allowed via the web interface, not via Git.
+This means that you can't accidentally delete a protected branch from your
+command line or a Git client application.
+
## Changelog
+**9.2**
+
+- Allow deletion of protected branches via the web interface [gitlab-org/gitlab-ce#21393][ce-21393]
+
**8.11**
- Allow creating protected branches that can't be pushed to [gitlab-org/gitlab-ce!5081][ce-5081]
@@ -113,4 +135,6 @@ all matching branches:
[ce-4665]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4665 "Allow specifying protected branches using wildcards"
[ce-4892]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4892 "Allow developers to merge into a protected branch without having push access"
[ce-5081]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5081 "Allow creating protected branches that can't be pushed to"
+[ce-21393]: https://gitlab.com/gitlab-org/gitlab-ce/issues/21393
[ee-restrict]: http://docs.gitlab.com/ee/user/project/protected_branches.html#restricting-push-and-merge-access-to-certain-users
+[perm]: ../permissions.md
diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature
index 1b00d8a32a0..4f905674d8c 100644
--- a/features/project/issues/issues.feature
+++ b/features/project/issues/issues.feature
@@ -12,11 +12,13 @@ Feature: Project Issues
Given I should see "Release 0.4" in issues
And I should not see "Release 0.3" in issues
+ @javascript
Scenario: I should see closed issues
Given I click link "Closed"
Then I should see "Release 0.3" in issues
And I should not see "Release 0.4" in issues
+ @javascript
Scenario: I should see all issues
Given I click link "All"
Then I should see "Release 0.3" in issues
diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature
index a8c528d3d6f..0ebeded7fc5 100644
--- a/features/project/merge_requests.feature
+++ b/features/project/merge_requests.feature
@@ -38,11 +38,13 @@ Feature: Project Merge Requests
When I visit merge request page "Bug NS-08"
Then I should see the diverged commits count
+ @javascript
Scenario: I should see rejected merge requests
Given I click link "Closed"
Then I should see "Feature NS-03" in merge requests
And I should not see "Bug NS-04" in merge requests
+ @javascript
Scenario: I should see all merge requests
Given I click link "All"
Then I should see "Feature NS-03" in merge requests
diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb
index 4fb16d3bb57..530fd6f7bdb 100644
--- a/features/steps/dashboard/new_project.rb
+++ b/features/steps/dashboard/new_project.rb
@@ -4,7 +4,13 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps
include SharedProject
step 'I click "New project" link' do
- page.within('.content') do
+ page.within '#content-body' do
+ click_link "New project"
+ end
+ end
+
+ step 'I click "New project" in top right menu' do
+ page.within '.header-content' do
click_link "New project"
end
end
diff --git a/features/steps/groups.rb b/features/steps/groups.rb
index 83d8abbab1f..25bb374b868 100644
--- a/features/steps/groups.rb
+++ b/features/steps/groups.rb
@@ -81,7 +81,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'I should see new group "Owned" avatar' do
expect(owned_group.avatar).to be_instance_of AvatarUploader
- expect(owned_group.avatar.url).to eq "/uploads/group/avatar/#{Group.find_by(name: "Owned").id}/banana_sample.gif"
+ expect(owned_group.avatar.url).to eq "/uploads/system/group/avatar/#{Group.find_by(name: "Owned").id}/banana_sample.gif"
end
step 'I should see the "Remove avatar" button' do
diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb
index 24cfbaad7fe..254c26bb6af 100644
--- a/features/steps/profile/profile.rb
+++ b/features/steps/profile/profile.rb
@@ -36,7 +36,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
step 'I should see new avatar' do
expect(@user.avatar).to be_instance_of AvatarUploader
- expect(@user.avatar.url).to eq "/uploads/user/avatar/#{@user.id}/banana_sample.gif"
+ expect(@user.avatar.url).to eq "/uploads/system/user/avatar/#{@user.id}/banana_sample.gif"
end
step 'I should see the "Remove avatar" button' do
diff --git a/features/steps/project/create.rb b/features/steps/project/create.rb
index 5f5f806df36..28be9c6df5b 100644
--- a/features/steps/project/create.rb
+++ b/features/steps/project/create.rb
@@ -5,7 +5,9 @@ class Spinach::Features::ProjectCreate < Spinach::FeatureSteps
step 'fill project form with valid data' do
fill_in 'project_path', with: 'Empty'
- click_button "Create project"
+ page.within '#content-body' do
+ click_button "Create project"
+ end
end
step 'I should see project page' do
diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb
index 7591e7d5612..35df403a85f 100644
--- a/features/steps/project/fork.rb
+++ b/features/steps/project/fork.rb
@@ -5,7 +5,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
step 'I click link "Fork"' do
expect(page).to have_content "Shop"
- click_link "Fork project"
+ click_link "Fork"
end
step 'I am a member of project "Shop"' do
@@ -42,7 +42,9 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I click link "New merge request"' do
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '#content-body' do
+ page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ end
end
step 'I should see the new merge request page for my namespace' do
diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb
index 25514eb9ef2..2d9d3efd9d4 100644
--- a/features/steps/project/forked_merge_requests.rb
+++ b/features/steps/project/forked_merge_requests.rb
@@ -17,7 +17,9 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
end
step 'I click link "New Merge Request"' do
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '#content-body' do
+ page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ end
end
step 'I should see merge request "Merge Request On Forked Project"' do
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index 637e6568267..e4a559d8ff5 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -28,7 +28,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "Closed"' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click
end
step 'I click button "Unsubscribe"' do
@@ -44,7 +44,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "All"' do
- click_link "All"
+ find('.issues-state-filters [data-state="all"] span', text: 'All').click
# Waits for load
expect(find('.issues-state-filters > .active')).to have_content 'All'
end
@@ -62,7 +62,9 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "New issue"' do
- page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
+ page.within '#content-body' do
+ page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
+ end
end
step 'I click "author" dropdown' do
diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb
index 54b6352c952..69f5d0f8410 100644
--- a/features/steps/project/merge_requests.rb
+++ b/features/steps/project/merge_requests.rb
@@ -14,7 +14,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I click link "New Merge Request"' do
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '#content-body' do
+ page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ end
end
step 'I click link "Bug NS-04"' do
@@ -26,7 +28,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I click link "All"' do
- click_link "All"
+ find('.issues-state-filters [data-state="all"] span', text: 'All').click
# Waits for load
expect(find('.issues-state-filters > .active')).to have_content 'All'
end
@@ -36,9 +38,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I click link "Closed"' do
- page.within('.issues-state-filters') do
- click_link "Closed"
- end
+ find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click
end
step 'I should see merge request "Wiki Feature"' do
@@ -299,6 +299,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I change the comment "Line is wrong" to "Typo, please fix" on diff' do
page.within('.diff-file:nth-of-type(5) .note') do
+ find('.more-actions').click
+ find('.more-actions .dropdown-menu li', match: :first)
+
find('.js-note-edit').click
page.within('.current-note-edit-form', visible: true) do
@@ -324,6 +327,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I delete the comment "Line is wrong" on diff' do
page.within('.diff-file:nth-of-type(5) .note') do
+ find('.more-actions').click
+ find('.more-actions .dropdown-menu li', match: :first)
+
find('.js-note-delete').click
end
end
diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb
index de32c9afcca..7d34331db46 100644
--- a/features/steps/project/project.rb
+++ b/features/steps/project/project.rb
@@ -38,7 +38,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps
step 'I should see new project avatar' do
expect(@project.avatar).to be_instance_of AvatarUploader
url = @project.avatar.url
- expect(url).to eq "/uploads/project/avatar/#{@project.id}/banana_sample.gif"
+ expect(url).to eq "/uploads/system/project/avatar/#{@project.id}/banana_sample.gif"
end
step 'I should see the "Remove avatar" button' do
diff --git a/features/steps/project/project_group_links.rb b/features/steps/project/project_group_links.rb
index 739a85e5fa4..5280a38ce81 100644
--- a/features/steps/project/project_group_links.rb
+++ b/features/steps/project/project_group_links.rb
@@ -5,18 +5,19 @@ class Spinach::Features::ProjectGroupLinks < Spinach::FeatureSteps
include Select2Helper
step 'I should see project already shared with group "Ops"' do
- page.within '.enabled-groups' do
+ page.within '.project-members-groups' do
expect(page).to have_content "Ops"
end
end
step 'I should see project is not shared with group "Market"' do
- page.within '.enabled-groups' do
+ page.within '.project-members-groups' do
expect(page).not_to have_content "Market"
end
end
step 'I select group "Market" for share' do
+ click_link 'Share with group'
group = Group.find_by(path: 'market')
select2(group.id, from: "#link_group_id")
select "Master", from: 'link_group_access'
@@ -24,7 +25,7 @@ class Spinach::Features::ProjectGroupLinks < Spinach::FeatureSteps
end
step 'I should see project is shared with group "Market"' do
- page.within '.enabled-groups' do
+ page.within '.project-members-groups' do
expect(page).to have_content "Market"
end
end
diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb
index e3f5e9e3ef3..dd49701a3d9 100644
--- a/features/steps/project/snippets.rb
+++ b/features/steps/project/snippets.rb
@@ -23,7 +23,9 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
end
step 'I click link "New snippet"' do
- first(:link, "New snippet").click
+ page.within '#content-body' do
+ first(:link, "New snippet").click
+ end
end
step 'I click link "Snippet one"' do
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
index 44eb8f321dd..80187b83fee 100644
--- a/features/steps/shared/note.rb
+++ b/features/steps/shared/note.rb
@@ -8,7 +8,12 @@ module SharedNote
step 'I delete a comment' do
page.within('.main-notes-list') do
- find('.note').hover
+ note = find('.note')
+ note.hover
+
+ note.find('.more-actions').click
+ note.find('.more-actions .dropdown-menu li', match: :first)
+
find(".js-note-delete").click
end
end
@@ -139,8 +144,13 @@ module SharedNote
step 'I edit the last comment with a +1' do
page.within(".main-notes-list") do
- find(".note").hover
- find('.js-note-edit').click
+ note = find('.note')
+ note.hover
+
+ note.find('.more-actions').click
+ note.find('.more-actions .dropdown-menu li', match: :first)
+
+ note.find('.js-note-edit').click
end
page.within(".current-note-edit-form") do
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 7ae2f3cad40..d767af36e8e 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -45,6 +45,7 @@ module API
end
before { allow_access_with_scope :api }
+ before { header['X-Frame-Options'] = 'SAMEORIGIN' }
before { Gitlab::I18n.locale = current_user&.preferred_language }
after { Gitlab::I18n.use_default_locale }
@@ -94,6 +95,7 @@ module API
mount ::API::DeployKeys
mount ::API::Deployments
mount ::API::Environments
+ mount ::API::Events
mount ::API::Features
mount ::API::Files
mount ::API::Groups
diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb
index 8a54f7f3f05..7cdee8aced7 100644
--- a/lib/api/deploy_keys.rb
+++ b/lib/api/deploy_keys.rb
@@ -76,6 +76,27 @@ module API
end
end
+ desc 'Update an existing deploy key for a project' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :key_id, type: Integer, desc: 'The ID of the deploy key'
+ optional :title, type: String, desc: 'The name of the deploy key'
+ optional :can_push, type: Boolean, desc: "Can deploy key push to the project's repository"
+ at_least_one_of :title, :can_push
+ end
+ put ":id/deploy_keys/:key_id" do
+ key = user_project.deploy_keys.find(params.delete(:key_id))
+
+ authorize!(:update_deploy_key, key)
+
+ if key.update_attributes(declared_params(include_missing: false))
+ present key, with: Entities::SSHKey
+ else
+ render_validation_error!(key)
+ end
+ end
+
desc 'Enable a deploy key for a project' do
detail 'This feature was added in GitLab 8.11'
success Entities::SSHKey
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 31da85e9917..a836df3dc81 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -100,6 +100,8 @@ module API
expose :creator_id
expose :namespace, using: 'API::Entities::Namespace'
expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? }
+ expose :import_status
+ expose :import_error, if: lambda { |_project, options| options[:user_can_admin_project] }
expose :avatar_url do |user, options|
user.avatar_url(only_path: false)
end
@@ -224,7 +226,7 @@ module API
end
class ProjectSnippet < Grape::Entity
- expose :id, :title, :file_name
+ expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
@@ -234,7 +236,7 @@ module API
end
class PersonalSnippet < Grape::Entity
- expose :id, :title, :file_name
+ expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
diff --git a/lib/api/events.rb b/lib/api/events.rb
new file mode 100644
index 00000000000..dabdf579119
--- /dev/null
+++ b/lib/api/events.rb
@@ -0,0 +1,86 @@
+module API
+ class Events < Grape::API
+ include PaginationParams
+
+ helpers do
+ params :event_filter_params do
+ optional :action, type: String, values: Event.actions, desc: 'Event action to filter on'
+ optional :target_type, type: String, values: Event.target_types, desc: 'Event target type to filter on'
+ optional :before, type: Date, desc: 'Include only events created before this date'
+ optional :after, type: Date, desc: 'Include only events created after this date'
+ end
+
+ params :sort_params do
+ optional :sort, type: String, values: %w[asc desc], default: 'desc',
+ desc: 'Return events sorted in ascending and descending order'
+ end
+
+ def present_events(events)
+ events = events.reorder(created_at: params[:sort])
+
+ present paginate(events), with: Entities::Event
+ end
+ end
+
+ resource :events do
+ desc "List currently authenticated user's events" do
+ detail 'This feature was introduced in GitLab 9.3.'
+ success Entities::Event
+ end
+ params do
+ use :pagination
+ use :event_filter_params
+ use :sort_params
+ end
+ get do
+ authenticate!
+
+ events = EventsFinder.new(params.merge(source: current_user, current_user: current_user)).execute.preload(:author, :target)
+
+ present_events(events)
+ end
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID or Username of the user'
+ end
+ resource :users do
+ desc 'Get the contribution events of a specified user' do
+ detail 'This feature was introduced in GitLab 8.13.'
+ success Entities::Event
+ end
+ params do
+ use :pagination
+ use :event_filter_params
+ use :sort_params
+ end
+ get ':id/events' do
+ user = find_user(params[:id])
+ not_found!('User') unless user
+
+ events = EventsFinder.new(params.merge(source: user, current_user: current_user)).execute.preload(:author, :target)
+
+ present_events(events)
+ end
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: { id: %r{[^/]+} } do
+ desc "List a Project's visible events" do
+ success Entities::Event
+ end
+ params do
+ use :pagination
+ use :event_filter_params
+ use :sort_params
+ end
+ get ":id/events" do
+ events = EventsFinder.new(params.merge(source: user_project, current_user: current_user)).execute.preload(:author, :target)
+
+ present_events(events)
+ end
+ end
+ end
+end
diff --git a/lib/api/files.rb b/lib/api/files.rb
index e6ea12c5ab7..521287ee2b4 100644
--- a/lib/api/files.rb
+++ b/lib/api/files.rb
@@ -10,7 +10,8 @@ module API
file_content: attrs[:content],
file_content_encoding: attrs[:encoding],
author_email: attrs[:author_email],
- author_name: attrs[:author_name]
+ author_name: attrs[:author_name],
+ last_commit_sha: attrs[:last_commit_id]
}
end
@@ -24,7 +25,7 @@ module API
@blob = @repo.blob_at(@commit.sha, params[:file_path])
not_found!('File') unless @blob
- @blob.load_all_data!(@repo)
+ @blob.load_all_data!
end
def commit_response(attrs)
@@ -46,6 +47,7 @@ module API
use :simple_file_params
requires :content, type: String, desc: 'File content'
optional :encoding, type: String, values: %w[base64], desc: 'File encoding'
+ optional :last_commit_id, type: String, desc: 'Last known commit id for this file'
end
end
@@ -111,7 +113,12 @@ module API
authorize! :push_code, user_project
file_params = declared_params(include_missing: false)
- result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute
+
+ begin
+ result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute
+ rescue ::Files::UpdateService::FileChangedError => e
+ render_api_error!(e.message, 400)
+ end
if result[:status] == :success
status(200)
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index e14a988a153..ebbaed0cbb7 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -83,7 +83,7 @@ module API
group = ::Groups::CreateService.new(current_user, declared_params(include_missing: false)).execute
if group.persisted?
- present group, with: Entities::Group, current_user: current_user
+ present group, with: Entities::GroupDetail, current_user: current_user
else
render_api_error!("Failed to save group #{group.errors.messages}", 400)
end
@@ -101,8 +101,6 @@ module API
optional :name, type: String, desc: 'The name of the group'
optional :path, type: String, desc: 'The path of the group'
use :optional_params
- at_least_one_of :name, :path, :description, :visibility,
- :lfs_enabled, :request_access_enabled
end
put ':id' do
group = find_group!(params[:id])
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 81f6fc3201d..2c73a6fdc4e 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -158,7 +158,7 @@ module API
params_hash = custom_params || params
attrs = {}
keys.each do |key|
- if params_hash[key].present? || (params_hash.has_key?(key) && params_hash[key] == false)
+ if params_hash[key].present? || (params_hash.key?(key) && params_hash[key] == false)
attrs[key] = params_hash[key]
end
end
diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb
index 264df7271a3..d3732d67622 100644
--- a/lib/api/helpers/internal_helpers.rb
+++ b/lib/api/helpers/internal_helpers.rb
@@ -42,6 +42,22 @@ module API
@project, @wiki = Gitlab::RepoPath.parse(params[:project])
end
end
+
+ # Project id to pass between components that don't share/don't have
+ # access to the same filesystem mounts
+ def gl_repository
+ Gitlab::GlRepository.gl_repository(project, wiki?)
+ end
+
+ # Return the repository full path so that gitlab-shell has it when
+ # handling ssh commands
+ def repository_path
+ if wiki?
+ project.wiki.repository.path_to_repo
+ else
+ project.repository.path_to_repo
+ end
+ end
end
end
end
diff --git a/lib/api/internal.rb b/lib/api/internal.rb
index 9ebd4841296..38631953014 100644
--- a/lib/api/internal.rb
+++ b/lib/api/internal.rb
@@ -32,31 +32,23 @@ module API
actor.update_last_used_at if actor.is_a?(Key)
- access_checker = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
- access_status = access_checker
+ access_checker_klass = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
+ access_checker = access_checker_klass
.new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities)
- .check(params[:action], params[:changes])
- response = { status: access_status.status, message: access_status.message }
-
- if access_status.status
- log_user_activity(actor)
-
- # Project id to pass between components that don't share/don't have
- # access to the same filesystem mounts
- response[:gl_repository] = Gitlab::GlRepository.gl_repository(project, wiki?)
-
- # Return the repository full path so that gitlab-shell has it when
- # handling ssh commands
- response[:repository_path] =
- if wiki?
- project.wiki.repository.path_to_repo
- else
- project.repository.path_to_repo
- end
+ begin
+ access_checker.check(params[:action], params[:changes])
+ rescue Gitlab::GitAccess::UnauthorizedError, Gitlab::GitAccess::NotFoundError => e
+ return { status: false, message: e.message }
end
- response
+ log_user_activity(actor)
+
+ {
+ status: true,
+ gl_repository: gl_repository,
+ repository_path: repository_path
+ }
end
post "/lfs_authenticate" do
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index 98bc9c28527..64efe82a937 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -49,6 +49,7 @@ module API
requires :title, type: String, desc: 'The title of the snippet'
requires :file_name, type: String, desc: 'The file name of the snippet'
requires :code, type: String, desc: 'The content of the snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
requires :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
@@ -77,6 +78,7 @@ module API
optional :title, type: String, desc: 'The title of the snippet'
optional :file_name, type: String, desc: 'The file name of the snippet'
optional :code, type: String, desc: 'The content of the snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 17008aa6d0f..56046742e08 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -109,7 +109,7 @@ module API
end
post do
attrs = declared_params(include_missing: false)
- attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled)
+ attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled)
project = ::Projects::CreateService.new(current_user, attrs).execute
if project.saved?
@@ -167,16 +167,6 @@ module API
user_can_admin_project: can?(current_user, :admin_project, user_project), statistics: params[:statistics]
end
- desc 'Get events for a single project' do
- success Entities::Event
- end
- params do
- use :pagination
- end
- get ":id/events" do
- present paginate(user_project.events.recent), with: Entities::Event
- end
-
desc 'Fork new project for the current user or provided namespace.' do
success Entities::Project
end
@@ -248,7 +238,7 @@ module API
authorize! :rename_project, user_project if attrs[:name].present?
authorize! :change_visibility_level, user_project if attrs[:visibility].present?
- attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled)
+ attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled)
result = ::Projects::UpdateService.new(user_project, current_user, attrs).execute
diff --git a/lib/api/runner.rb b/lib/api/runner.rb
index 3fd0536dadd..4552115b3e2 100644
--- a/lib/api/runner.rb
+++ b/lib/api/runner.rb
@@ -141,7 +141,7 @@ module API
patch '/:id/trace' do
job = authenticate_job!
- error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range')
+ error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range')
content_range = request.headers['Content-Range']
content_range = content_range.split('-')
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index 82f513c984e..25027c3b114 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -110,6 +110,7 @@ module API
optional :default_artifacts_expire_in, type: String, desc: "Set the default expiration time for each job's artifacts"
optional :max_pages_size, type: Integer, desc: 'Maximum size of pages in MB'
optional :container_registry_token_expire_delay, type: Integer, desc: 'Authorization token duration (minutes)'
+ optional :prometheus_metrics_enabled, type: Boolean, desc: 'Enable Prometheus metrics'
optional :metrics_enabled, type: Boolean, desc: 'Enable the InfluxDB metrics'
given metrics_enabled: ->(val) { val } do
requires :metrics_host, type: String, desc: 'The InfluxDB host'
diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb
index 53f5953a8fb..c630c24c339 100644
--- a/lib/api/snippets.rb
+++ b/lib/api/snippets.rb
@@ -58,6 +58,7 @@ module API
requires :title, type: String, desc: 'The title of a snippet'
requires :file_name, type: String, desc: 'The name of a snippet file'
requires :content, type: String, desc: 'The content of a snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
default: 'internal',
@@ -85,6 +86,7 @@ module API
optional :title, type: String, desc: 'The title of a snippet'
optional :file_name, type: String, desc: 'The name of a snippet file'
optional :content, type: String, desc: 'The content of a snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
diff --git a/lib/api/time_tracking_endpoints.rb b/lib/api/time_tracking_endpoints.rb
index 05b4b490e27..df4632346dd 100644
--- a/lib/api/time_tracking_endpoints.rb
+++ b/lib/api/time_tracking_endpoints.rb
@@ -5,7 +5,7 @@ module API
included do
helpers do
def issuable_name
- declared_params.has_key?(:issue_iid) ? 'issue' : 'merge_request'
+ declared_params.key?(:issue_iid) ? 'issue' : 'merge_request'
end
def issuable_key
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 3d83720b7b9..dda64715ee1 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -124,10 +124,6 @@ module API
optional :name, type: String, desc: 'The name of the user'
optional :username, type: String, desc: 'The username of the user'
use :optional_attributes
- at_least_one_of :email, :password, :name, :username, :skype, :linkedin,
- :twitter, :website_url, :organization, :projects_limit,
- :extern_uid, :provider, :bio, :location, :admin,
- :can_create_group, :confirm, :external
end
put ":id" do
authenticated_as_admin!
@@ -286,13 +282,14 @@ module API
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
+ optional :hard_delete, type: Boolean, desc: "Whether to remove a user's contributions"
end
delete ":id" do
authenticated_as_admin!
user = User.find_by(id: params[:id])
not_found!('User') unless user
- DeleteUserWorker.perform_async(current_user.id, user.id)
+ user.delete_async(deleted_by: current_user, params: params)
end
desc 'Block a user. Available only for admins.'
@@ -327,27 +324,6 @@ module API
end
end
- desc 'Get the contribution events of a specified user' do
- detail 'This feature was introduced in GitLab 8.13.'
- success Entities::Event
- end
- params do
- requires :id, type: Integer, desc: 'The ID of the user'
- use :pagination
- end
- get ':id/events' do
- user = User.find_by(id: params[:id])
- not_found!('User') unless user
-
- events = user.events.
- merge(ProjectsFinder.new(current_user: current_user).execute).
- references(:project).
- with_associations.
- recent
-
- present paginate(events), with: Entities::Event
- end
-
params do
requires :user_id, type: Integer, desc: 'The ID of the user'
end
diff --git a/lib/api/v3/files.rb b/lib/api/v3/files.rb
index c76acc86504..7b4b3448b6d 100644
--- a/lib/api/v3/files.rb
+++ b/lib/api/v3/files.rb
@@ -56,7 +56,7 @@ module API
blob = repo.blob_at(commit.sha, params[:file_path])
not_found!('File') unless blob
- blob.load_all_data!(repo)
+ blob.load_all_data!
status(200)
{
diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb
index 896c00b88e7..20976b9dd08 100644
--- a/lib/api/v3/projects.rb
+++ b/lib/api/v3/projects.rb
@@ -44,7 +44,7 @@ module API
end
def set_only_allow_merge_if_pipeline_succeeds!
- if params.has_key?(:only_allow_merge_if_build_succeeds)
+ if params.key?(:only_allow_merge_if_build_succeeds)
params[:only_allow_merge_if_pipeline_succeeds] = params.delete(:only_allow_merge_if_build_succeeds)
end
end
diff --git a/lib/api/v3/time_tracking_endpoints.rb b/lib/api/v3/time_tracking_endpoints.rb
index 81ae4e8137d..d5b90e435ba 100644
--- a/lib/api/v3/time_tracking_endpoints.rb
+++ b/lib/api/v3/time_tracking_endpoints.rb
@@ -6,7 +6,7 @@ module API
included do
helpers do
def issuable_name
- declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request'
+ declared_params.key?(:issue_id) ? 'issue' : 'merge_request'
end
def issuable_key
diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb
index 6b29600a751..a1685c77916 100644
--- a/lib/backup/repository.rb
+++ b/lib/backup/repository.rb
@@ -7,15 +7,15 @@ module Backup
prepare
Project.find_each(batch_size: 1000) do |project|
- $progress.print " * #{project.path_with_namespace} ... "
+ progress.print " * #{project.path_with_namespace} ... "
path_to_project_repo = path_to_repo(project)
path_to_project_bundle = path_to_bundle(project)
# Create namespace dir if missing
FileUtils.mkdir_p(File.join(backup_repos_path, project.namespace.full_path)) if project.namespace
- if project.empty_repo?
- $progress.puts "[SKIPPED]".color(:cyan)
+ if empty_repo?(project)
+ progress.puts "[SKIPPED]".color(:cyan)
else
in_path(path_to_project_repo) do |dir|
FileUtils.mkdir_p(path_to_tars(project))
@@ -23,10 +23,7 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
unless status.zero?
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Backup failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
@@ -34,12 +31,9 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts "[DONE]".color(:green)
+ progress.puts "[DONE]".color(:green)
else
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Backup failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
@@ -48,19 +42,16 @@ module Backup
path_to_wiki_bundle = path_to_bundle(wiki)
if File.exist?(path_to_wiki_repo)
- $progress.print " * #{wiki.path_with_namespace} ... "
- if wiki.repository.empty?
- $progress.puts " [SKIPPED]".color(:cyan)
+ progress.print " * #{wiki.path_with_namespace} ... "
+ if empty_repo?(wiki)
+ progress.puts " [SKIPPED]".color(:cyan)
else
cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path_to_wiki_repo} bundle create #{path_to_wiki_bundle} --all)
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts " [DONE]".color(:green)
+ progress.puts " [DONE]".color(:green)
else
- puts " [FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Backup failed'
+ progress_warn(wiki, cmd.join(' '), output)
end
end
end
@@ -80,7 +71,7 @@ module Backup
end
Project.find_each(batch_size: 1000) do |project|
- $progress.print " * #{project.path_with_namespace} ... "
+ progress.print " * #{project.path_with_namespace} ... "
path_to_project_repo = path_to_repo(project)
path_to_project_bundle = path_to_bundle(project)
@@ -94,12 +85,9 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts "[DONE]".color(:green)
+ progress.puts "[DONE]".color(:green)
else
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Restore failed'
+ progress_warn(project, cmd.join(' '), output)
end
in_path(path_to_tars(project)) do |dir|
@@ -107,10 +95,7 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
unless status.zero?
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Restore failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
@@ -119,7 +104,7 @@ module Backup
path_to_wiki_bundle = path_to_bundle(wiki)
if File.exist?(path_to_wiki_bundle)
- $progress.print " * #{wiki.path_with_namespace} ... "
+ progress.print " * #{wiki.path_with_namespace} ... "
# If a wiki bundle exists, first remove the empty repo
# that was initialized with ProjectWiki.new() and then
@@ -129,22 +114,19 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts " [DONE]".color(:green)
+ progress.puts " [DONE]".color(:green)
else
- puts " [FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Restore failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
end
- $progress.print 'Put GitLab hooks in repositories dirs'.color(:yellow)
+ progress.print 'Put GitLab hooks in repositories dirs'.color(:yellow)
cmd = %W(#{Gitlab.config.gitlab_shell.path}/bin/create-hooks) + repository_storage_paths_args
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts " [DONE]".color(:green)
+ progress.puts " [DONE]".color(:green)
else
puts " [FAILED]".color(:red)
puts "failed: #{cmd}"
@@ -201,8 +183,25 @@ module Backup
private
+ def progress_warn(project, cmd, output)
+ progress.puts "[WARNING] Executing #{cmd}".color(:orange)
+ progress.puts "Ignoring error on #{project.path_with_namespace} - #{output}".color(:orange)
+ end
+
+ def empty_repo?(project_or_wiki)
+ project_or_wiki.repository.empty_repo?
+ rescue => e
+ progress.puts "Ignoring repository error and continuing backing up project: #{project_or_wiki.path_with_namespace} - #{e.message}".color(:orange)
+
+ false
+ end
+
def repository_storage_paths_args
Gitlab.config.repositories.storages.values.map { |rs| rs['path'] }
end
+
+ def progress
+ $progress
+ end
end
end
diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb
index d99a3bfa625..1e2536231d8 100644
--- a/lib/banzai/reference_parser/base_parser.rb
+++ b/lib/banzai/reference_parser/base_parser.rb
@@ -62,7 +62,7 @@ module Banzai
nodes.select do |node|
if node.has_attribute?(project_attr)
- can_read_reference?(user, projects[node])
+ can_read_reference?(user, projects[node], node)
else
true
end
@@ -231,7 +231,7 @@ module Banzai
# see reference comments.
# Override this method on subclasses
# to check if user can read resource
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
raise NotImplementedError
end
diff --git a/lib/banzai/reference_parser/commit_parser.rb b/lib/banzai/reference_parser/commit_parser.rb
index 8c54a041cb8..30dc87248b4 100644
--- a/lib/banzai/reference_parser/commit_parser.rb
+++ b/lib/banzai/reference_parser/commit_parser.rb
@@ -32,7 +32,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :download_code, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/commit_range_parser.rb b/lib/banzai/reference_parser/commit_range_parser.rb
index 0878b6afba3..a50e6f8ef8f 100644
--- a/lib/banzai/reference_parser/commit_range_parser.rb
+++ b/lib/banzai/reference_parser/commit_range_parser.rb
@@ -36,7 +36,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :download_code, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/external_issue_parser.rb b/lib/banzai/reference_parser/external_issue_parser.rb
index 6e7b7669578..6307c1b571a 100644
--- a/lib/banzai/reference_parser/external_issue_parser.rb
+++ b/lib/banzai/reference_parser/external_issue_parser.rb
@@ -23,7 +23,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :read_issue, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/label_parser.rb b/lib/banzai/reference_parser/label_parser.rb
index aa76c64ac5f..30e2a012f09 100644
--- a/lib/banzai/reference_parser/label_parser.rb
+++ b/lib/banzai/reference_parser/label_parser.rb
@@ -9,7 +9,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :read_label, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/merge_request_parser.rb b/lib/banzai/reference_parser/merge_request_parser.rb
index 8b0662749fd..75cbc7fdac4 100644
--- a/lib/banzai/reference_parser/merge_request_parser.rb
+++ b/lib/banzai/reference_parser/merge_request_parser.rb
@@ -40,6 +40,10 @@ module Banzai
self.class.data_attribute
)
end
+
+ def can_read_reference?(user, ref_project, node)
+ can?(user, :read_merge_request, ref_project)
+ end
end
end
end
diff --git a/lib/banzai/reference_parser/milestone_parser.rb b/lib/banzai/reference_parser/milestone_parser.rb
index d3968d6b229..68675abe22a 100644
--- a/lib/banzai/reference_parser/milestone_parser.rb
+++ b/lib/banzai/reference_parser/milestone_parser.rb
@@ -9,7 +9,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :read_milestone, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/snippet_parser.rb b/lib/banzai/reference_parser/snippet_parser.rb
index 63b592137bb..3ade168b566 100644
--- a/lib/banzai/reference_parser/snippet_parser.rb
+++ b/lib/banzai/reference_parser/snippet_parser.rb
@@ -9,8 +9,8 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
- can?(user, :read_project_snippet, ref_project)
+ def can_read_reference?(user, ref_project, node)
+ can?(user, :read_project_snippet, referenced_by([node]).first)
end
end
end
diff --git a/lib/banzai/reference_parser/user_parser.rb b/lib/banzai/reference_parser/user_parser.rb
index 09b66cbd8fb..3efbd2fd631 100644
--- a/lib/banzai/reference_parser/user_parser.rb
+++ b/lib/banzai/reference_parser/user_parser.rb
@@ -103,7 +103,7 @@ module Banzai
flat_map { |p| p.team.members.to_a }
end
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :read_project, ref_project)
end
end
diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb
index 4f8efe03bae..c52acbc3ddc 100644
--- a/lib/bitbucket/representation/pull_request_comment.rb
+++ b/lib/bitbucket/representation/pull_request_comment.rb
@@ -22,11 +22,11 @@ module Bitbucket
end
def inline?
- raw.has_key?('inline')
+ raw.key?('inline')
end
def has_parent?
- raw.has_key?('parent')
+ raw.key?('parent')
end
private
diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb
index 2285ef241d7..e2e91ce99cd 100644
--- a/lib/ci/api/builds.rb
+++ b/lib/ci/api/builds.rb
@@ -88,7 +88,7 @@ module Ci
patch ":id/trace.txt" do
build = authenticate_build!
- error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range')
+ error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range')
content_range = request.headers['Content-Range']
content_range = content_range.split('-')
diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb
index b06474cda7f..56ad2c77c7d 100644
--- a/lib/ci/gitlab_ci_yaml_processor.rb
+++ b/lib/ci/gitlab_ci_yaml_processor.rb
@@ -20,26 +20,26 @@ module Ci
raise ValidationError, e.message
end
- def jobs_for_ref(ref, tag = false, trigger_request = nil)
+ def jobs_for_ref(ref, tag = false, source = nil)
@jobs.select do |_, job|
- process?(job[:only], job[:except], ref, tag, trigger_request)
+ process?(job[:only], job[:except], ref, tag, source)
end
end
- def jobs_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil)
- jobs_for_ref(ref, tag, trigger_request).select do |_, job|
+ def jobs_for_stage_and_ref(stage, ref, tag = false, source = nil)
+ jobs_for_ref(ref, tag, source).select do |_, job|
job[:stage] == stage
end
end
- def builds_for_ref(ref, tag = false, trigger_request = nil)
- jobs_for_ref(ref, tag, trigger_request).map do |name, _|
+ def builds_for_ref(ref, tag = false, source = nil)
+ jobs_for_ref(ref, tag, source).map do |name, _|
build_attributes(name)
end
end
- def builds_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil)
- jobs_for_stage_and_ref(stage, ref, tag, trigger_request).map do |name, _|
+ def builds_for_stage_and_ref(stage, ref, tag = false, source = nil)
+ jobs_for_stage_and_ref(stage, ref, tag, source).map do |name, _|
build_attributes(name)
end
end
@@ -50,10 +50,21 @@ module Ci
end
end
+ def stage_seeds(pipeline)
+ seeds = @stages.uniq.map do |stage|
+ builds = builds_for_stage_and_ref(
+ stage, pipeline.ref, pipeline.tag?, pipeline.source)
+
+ Gitlab::Ci::Stage::Seed.new(pipeline, stage, builds) if builds.any?
+ end
+
+ seeds.compact
+ end
+
def build_attributes(name)
job = @jobs[name.to_sym] || {}
- {
- stage_idx: @stages.index(job[:stage]),
+
+ { stage_idx: @stages.index(job[:stage]),
stage: job[:stage],
commands: job[:commands],
tag_list: job[:tags] || [],
@@ -71,8 +82,7 @@ module Ci
dependencies: job[:dependencies],
after_script: job[:after_script],
environment: job[:environment]
- }.compact
- }
+ }.compact }
end
def self.validation_message(content)
@@ -181,30 +191,35 @@ module Ci
end
end
- def process?(only_params, except_params, ref, tag, trigger_request)
+ def process?(only_params, except_params, ref, tag, source)
if only_params.present?
- return false unless matching?(only_params, ref, tag, trigger_request)
+ return false unless matching?(only_params, ref, tag, source)
end
if except_params.present?
- return false if matching?(except_params, ref, tag, trigger_request)
+ return false if matching?(except_params, ref, tag, source)
end
true
end
- def matching?(patterns, ref, tag, trigger_request)
+ def matching?(patterns, ref, tag, source)
patterns.any? do |pattern|
- match_ref?(pattern, ref, tag, trigger_request)
+ pattern, path = pattern.split('@', 2)
+ matches_path?(path) && matches_pattern?(pattern, ref, tag, source)
end
end
- def match_ref?(pattern, ref, tag, trigger_request)
- pattern, path = pattern.split('@', 2)
- return false if path && path != self.path
+ def matches_path?(path)
+ return true unless path
+
+ path == self.path
+ end
+
+ def matches_pattern?(pattern, ref, tag, source)
return true if tag && pattern == 'tags'
return true if !tag && pattern == 'branches'
- return true if trigger_request.present? && pattern == 'triggers'
+ return true if source_to_pattern(source) == pattern
if pattern.first == "/" && pattern.last == "/"
Regexp.new(pattern[1...-1]) =~ ref
@@ -212,5 +227,13 @@ module Ci
pattern == ref
end
end
+
+ def source_to_pattern(source)
+ if %w[api external web].include?(source)
+ source
+ else
+ source&.pluralize
+ end
+ end
end
end
diff --git a/lib/feature.rb b/lib/feature.rb
index 2e2b343f82c..5650a1c1334 100644
--- a/lib/feature.rb
+++ b/lib/feature.rb
@@ -27,6 +27,18 @@ class Feature
all.map(&:name).include?(feature.name)
end
+ def enabled?(key)
+ get(key).enabled?
+ end
+
+ def enable(key)
+ get(key).enable
+ end
+
+ def disable(key)
+ get(key).disable
+ end
+
private
def flipper
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 099c45dcfb7..3933c3b04dd 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -2,6 +2,8 @@ module Gitlab
module Auth
MissingPersonalTokenError = Class.new(StandardError)
+ REGISTRY_SCOPES = [:read_registry].freeze
+
# Scopes used for GitLab API access
API_SCOPES = [:api, :read_user].freeze
@@ -11,8 +13,10 @@ module Gitlab
# Default scopes for OAuth applications that don't define their own
DEFAULT_SCOPES = [:api].freeze
+ AVAILABLE_SCOPES = (API_SCOPES + REGISTRY_SCOPES).freeze
+
# Other available scopes
- OPTIONAL_SCOPES = (API_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze
+ OPTIONAL_SCOPES = (AVAILABLE_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze
class << self
def find_for_git_client(login, password, project:, ip:)
@@ -26,14 +30,18 @@ module Gitlab
build_access_token_check(login, password) ||
lfs_token_check(login, password) ||
oauth_access_token_check(login, password) ||
- user_with_password_for_git(login, password) ||
personal_access_token_check(password) ||
+ user_with_password_for_git(login, password) ||
Gitlab::Auth::Result.new
rate_limit!(ip, success: result.success?, login: login)
Gitlab::Auth::UniqueIpsLimiter.limit_user!(result.actor)
- result
+ return result if result.success? || current_application_settings.signin_enabled? || Gitlab::LDAP::Config.enabled?
+
+ # If sign-in is disabled and LDAP is not configured, recommend a
+ # personal access token on failed auth attempts
+ raise Gitlab::Auth::MissingPersonalTokenError
end
def find_with_user_password(login, password)
@@ -109,6 +117,7 @@ module Gitlab
def oauth_access_token_check(login, password)
if login == "oauth2" && password.present?
token = Doorkeeper::AccessToken.by_token(password)
+
if valid_oauth_token?(token)
user = User.find_by(id: token.resource_owner_id)
Gitlab::Auth::Result.new(user, nil, :oauth, full_authentication_abilities)
@@ -121,17 +130,23 @@ module Gitlab
token = PersonalAccessTokensFinder.new(state: 'active').find_by(token: password)
- if token && valid_api_token?(token)
- Gitlab::Auth::Result.new(token.user, nil, :personal_token, full_authentication_abilities)
+ if token && valid_scoped_token?(token, AVAILABLE_SCOPES.map(&:to_s))
+ Gitlab::Auth::Result.new(token.user, nil, :personal_token, abilities_for_scope(token.scopes))
end
end
def valid_oauth_token?(token)
- token && token.accessible? && valid_api_token?(token)
+ token && token.accessible? && valid_scoped_token?(token, ["api"])
end
- def valid_api_token?(token)
- AccessTokenValidationService.new(token).include_any_scope?(['api'])
+ def valid_scoped_token?(token, scopes)
+ AccessTokenValidationService.new(token).include_any_scope?(scopes)
+ end
+
+ def abilities_for_scope(scopes)
+ scopes.map do |scope|
+ self.public_send(:"#{scope}_scope_authentication_abilities")
+ end.flatten.uniq
end
def lfs_token_check(login, password)
@@ -202,6 +217,16 @@ module Gitlab
:create_container_image
]
end
+ alias_method :api_scope_authentication_abilities, :full_authentication_abilities
+
+ def read_registry_scope_authentication_abilities
+ [:read_container_image]
+ end
+
+ # The currently used auth method doesn't allow any actions for this scope
+ def read_user_scope_authentication_abilities
+ []
+ end
end
end
end
diff --git a/lib/gitlab/auth/result.rb b/lib/gitlab/auth/result.rb
index 39b86c61a18..75451cf8aa9 100644
--- a/lib/gitlab/auth/result.rb
+++ b/lib/gitlab/auth/result.rb
@@ -15,6 +15,10 @@ module Gitlab
def success?
actor.present? || type == :ci
end
+
+ def failed?
+ !success?
+ end
end
end
end
diff --git a/lib/gitlab/blame.rb b/lib/gitlab/blame.rb
index d62bc50ce78..169aac79854 100644
--- a/lib/gitlab/blame.rb
+++ b/lib/gitlab/blame.rb
@@ -40,7 +40,7 @@ module Gitlab
end
def highlighted_lines
- @blob.load_all_data!(repository)
+ @blob.load_all_data!
@highlighted_lines ||=
Gitlab::Highlight.highlight(@blob.path, @blob.data, repository: repository).lines
end
diff --git a/lib/gitlab/chat_commands/presenters/base.rb b/lib/gitlab/chat_commands/presenters/base.rb
index 2700a5a2ad5..05994bee79d 100644
--- a/lib/gitlab/chat_commands/presenters/base.rb
+++ b/lib/gitlab/chat_commands/presenters/base.rb
@@ -45,9 +45,9 @@ module Gitlab
end
def format_response(response)
- response[:text] = format(response[:text]) if response.has_key?(:text)
+ response[:text] = format(response[:text]) if response.key?(:text)
- if response.has_key?(:attachments)
+ if response.key?(:attachments)
response[:attachments].each do |attachment|
attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext]
attachment[:text] = format(attachment[:text]) if attachment[:text]
diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb
index c984eb20606..b6805230348 100644
--- a/lib/gitlab/checks/change_access.rb
+++ b/lib/gitlab/checks/change_access.rb
@@ -1,6 +1,20 @@
module Gitlab
module Checks
class ChangeAccess
+ ERROR_MESSAGES = {
+ push_code: 'You are not allowed to push code to this project.',
+ delete_default_branch: 'The default branch of a project cannot be deleted.',
+ force_push_protected_branch: 'You are not allowed to force push code to a protected branch on this project.',
+ non_master_delete_protected_branch: 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.',
+ non_web_delete_protected_branch: 'You can only delete protected branches using the web interface.',
+ merge_protected_branch: 'You are not allowed to merge code into protected branches on this project.',
+ push_protected_branch: 'You are not allowed to push code to protected branches on this project.',
+ change_existing_tags: 'You are not allowed to change existing tags on this project.',
+ update_protected_tag: 'Protected tags cannot be updated.',
+ delete_protected_tag: 'Protected tags cannot be deleted.',
+ create_protected_tag: 'You are not allowed to create this tag as it is protected.'
+ }.freeze
+
attr_reader :user_access, :project, :skip_authorization, :protocol
def initialize(
@@ -17,22 +31,20 @@ module Gitlab
end
def exec
- return GitAccessStatus.new(true) if skip_authorization
+ return true if skip_authorization
- error = push_checks || branch_checks || tag_checks
+ push_checks
+ branch_checks
+ tag_checks
- if error
- GitAccessStatus.new(false, error)
- else
- GitAccessStatus.new(true)
- end
+ true
end
protected
def push_checks
if user_access.cannot_do_action?(:push_code)
- "You are not allowed to push code to this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code]
end
end
@@ -40,7 +52,7 @@ module Gitlab
return unless @branch_name
if deletion? && @branch_name == project.default_branch
- return "The default branch of a project cannot be deleted."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch]
end
protected_branch_checks
@@ -50,7 +62,7 @@ module Gitlab
return unless ProtectedBranch.protected?(project, @branch_name)
if forced_push?
- return "You are not allowed to force push code to a protected branch on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch]
end
if deletion?
@@ -62,22 +74,22 @@ module Gitlab
def protected_branch_deletion_checks
unless user_access.can_delete_branch?(@branch_name)
- return 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.'
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch]
end
unless protocol == 'web'
- 'You can only delete protected branches using the web interface.'
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch]
end
end
def protected_branch_push_checks
if matching_merge_request?
unless user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name)
- "You are not allowed to merge code into protected branches on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch]
end
else
unless user_access.can_push_to_branch?(@branch_name)
- "You are not allowed to push code to protected branches on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_protected_branch]
end
end
end
@@ -86,7 +98,7 @@ module Gitlab
return unless @tag_name
if tag_exists? && user_access.cannot_do_action?(:admin_project)
- return "You are not allowed to change existing tags on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags]
end
protected_tag_checks
@@ -95,11 +107,11 @@ module Gitlab
def protected_tag_checks
return unless ProtectedTag.protected?(project, @tag_name)
- return "Protected tags cannot be updated." if update?
- return "Protected tags cannot be deleted." if deletion?
+ raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update?
+ raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion?
unless user_access.can_create_tag?(@tag_name)
- return "You are not allowed to create this tag as it is protected."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag]
end
end
diff --git a/lib/gitlab/ci/stage/seed.rb b/lib/gitlab/ci/stage/seed.rb
new file mode 100644
index 00000000000..f81f9347b4d
--- /dev/null
+++ b/lib/gitlab/ci/stage/seed.rb
@@ -0,0 +1,49 @@
+module Gitlab
+ module Ci
+ module Stage
+ class Seed
+ attr_reader :pipeline
+ delegate :project, to: :pipeline
+
+ def initialize(pipeline, stage, jobs)
+ @pipeline = pipeline
+ @stage = { name: stage }
+ @jobs = jobs.to_a.dup
+ end
+
+ def user=(current_user)
+ @jobs.map! do |attributes|
+ attributes.merge(user: current_user)
+ end
+ end
+
+ def stage
+ @stage.merge(project: project)
+ end
+
+ def builds
+ trigger = pipeline.trigger_requests.first
+
+ @jobs.map do |attributes|
+ attributes.merge(project: project,
+ ref: pipeline.ref,
+ tag: pipeline.tag,
+ trigger_request: trigger)
+ end
+ end
+
+ def create!
+ pipeline.stages.create!(stage).tap do |stage|
+ builds_attributes = builds.map do |attributes|
+ attributes.merge(stage_id: stage.id)
+ end
+
+ pipeline.builds.create!(builds_attributes).each do |build|
+ yield build if block_given?
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/canceled.rb b/lib/gitlab/ci/status/canceled.rb
index 97c121ce7b9..e5fdc1f8136 100644
--- a/lib/gitlab/ci/status/canceled.rb
+++ b/lib/gitlab/ci/status/canceled.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Canceled < Status::Core
def text
- 'canceled'
+ s_('CiStatusText|canceled')
end
def label
- 'canceled'
+ s_('CiStatusLabel|canceled')
end
def icon
diff --git a/lib/gitlab/ci/status/created.rb b/lib/gitlab/ci/status/created.rb
index 0721bf6ec7c..d188bd286a6 100644
--- a/lib/gitlab/ci/status/created.rb
+++ b/lib/gitlab/ci/status/created.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Created < Status::Core
def text
- 'created'
+ s_('CiStatusText|created')
end
def label
- 'created'
+ s_('CiStatusLabel|created')
end
def icon
diff --git a/lib/gitlab/ci/status/failed.rb b/lib/gitlab/ci/status/failed.rb
index cb75e9383a8..38e45714c22 100644
--- a/lib/gitlab/ci/status/failed.rb
+++ b/lib/gitlab/ci/status/failed.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Failed < Status::Core
def text
- 'failed'
+ s_('CiStatusText|failed')
end
def label
- 'failed'
+ s_('CiStatusLabel|failed')
end
def icon
diff --git a/lib/gitlab/ci/status/manual.rb b/lib/gitlab/ci/status/manual.rb
index f8f6c2903ba..a4a7edadac9 100644
--- a/lib/gitlab/ci/status/manual.rb
+++ b/lib/gitlab/ci/status/manual.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Manual < Status::Core
def text
- 'manual'
+ s_('CiStatusText|manual')
end
def label
- 'manual action'
+ s_('CiStatusLabel|manual action')
end
def icon
diff --git a/lib/gitlab/ci/status/pending.rb b/lib/gitlab/ci/status/pending.rb
index f40cc1314dc..5164260b861 100644
--- a/lib/gitlab/ci/status/pending.rb
+++ b/lib/gitlab/ci/status/pending.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Pending < Status::Core
def text
- 'pending'
+ s_('CiStatusText|pending')
end
def label
- 'pending'
+ s_('CiStatusLabel|pending')
end
def icon
diff --git a/lib/gitlab/ci/status/pipeline/blocked.rb b/lib/gitlab/ci/status/pipeline/blocked.rb
index 37dfe43fb62..bf7e484ee9b 100644
--- a/lib/gitlab/ci/status/pipeline/blocked.rb
+++ b/lib/gitlab/ci/status/pipeline/blocked.rb
@@ -4,11 +4,11 @@ module Gitlab
module Pipeline
class Blocked < Status::Extended
def text
- 'blocked'
+ s_('CiStatusText|blocked')
end
def label
- 'waiting for manual action'
+ s_('CiStatusLabel|waiting for manual action')
end
def self.matches?(pipeline, user)
diff --git a/lib/gitlab/ci/status/running.rb b/lib/gitlab/ci/status/running.rb
index 1237cd47dc8..993937e98ca 100644
--- a/lib/gitlab/ci/status/running.rb
+++ b/lib/gitlab/ci/status/running.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Running < Status::Core
def text
- 'running'
+ s_('CiStatus|running')
end
def label
- 'running'
+ s_('CiStatus|running')
end
def icon
diff --git a/lib/gitlab/ci/status/skipped.rb b/lib/gitlab/ci/status/skipped.rb
index 28005d91503..0c942920b02 100644
--- a/lib/gitlab/ci/status/skipped.rb
+++ b/lib/gitlab/ci/status/skipped.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Skipped < Status::Core
def text
- 'skipped'
+ s_('CiStatusText|skipped')
end
def label
- 'skipped'
+ s_('CiStatusLabel|skipped')
end
def icon
diff --git a/lib/gitlab/ci/status/success.rb b/lib/gitlab/ci/status/success.rb
index 88f7758a270..d7af98857b0 100644
--- a/lib/gitlab/ci/status/success.rb
+++ b/lib/gitlab/ci/status/success.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Success < Status::Core
def text
- 'passed'
+ s_('CiStatusText|passed')
end
def label
- 'passed'
+ s_('CiStatusLabel|passed')
end
def icon
diff --git a/lib/gitlab/ci/status/success_warning.rb b/lib/gitlab/ci/status/success_warning.rb
index df6e76b0151..4d7d82e04cf 100644
--- a/lib/gitlab/ci/status/success_warning.rb
+++ b/lib/gitlab/ci/status/success_warning.rb
@@ -7,11 +7,11 @@ module Gitlab
#
class SuccessWarning < Status::Extended
def text
- 'passed'
+ s_('CiStatusText|passed')
end
def label
- 'passed with warnings'
+ s_('CiStatusLabel|passed with warnings')
end
def icon
diff --git a/lib/gitlab/ci_access.rb b/lib/gitlab/ci_access.rb
new file mode 100644
index 00000000000..def1373d8cf
--- /dev/null
+++ b/lib/gitlab/ci_access.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ # For backwards compatibility, generic CI (which is a build without a user) is
+ # allowed to :build_download_code without any other checks.
+ class CiAccess
+ def can_do_action?(action)
+ action == :build_download_code
+ end
+ end
+end
diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb
index 15992b77680..060e013183f 100644
--- a/lib/gitlab/contributions_calendar.rb
+++ b/lib/gitlab/contributions_calendar.rb
@@ -28,7 +28,7 @@ module Gitlab
union = Gitlab::SQL::Union.new([repo_events, issue_events, mr_events, note_events])
events = Event.find_by_sql(union.to_sql).map(&:attributes)
- @activity_events = events.each_with_object(Hash.new {|h, k| h[k] = 0 }) do |event, activities|
+ @activity_dates = events.each_with_object(Hash.new {|h, k| h[k] = 0 }) do |event, activities|
activities[event["date"]] += event["total_amount"]
end
end
diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb
index 9e14b35b0f8..48735fd197d 100644
--- a/lib/gitlab/current_settings.rb
+++ b/lib/gitlab/current_settings.rb
@@ -8,39 +8,55 @@ module Gitlab
end
end
- def ensure_application_settings!
- return fake_application_settings unless connect_to_db?
+ delegate :sidekiq_throttling_enabled?, to: :current_application_settings
- unless ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true'
- begin
- settings = ::ApplicationSetting.current
- # In case Redis isn't running or the Redis UNIX socket file is not available
- rescue ::Redis::BaseError, ::Errno::ENOENT
- settings = ::ApplicationSetting.last
- end
+ def fake_application_settings
+ OpenStruct.new(::ApplicationSetting.defaults)
+ end
- settings ||= ::ApplicationSetting.create_from_defaults
+ private
+
+ def ensure_application_settings!
+ unless ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true'
+ settings = retrieve_settings_from_database?
end
settings || in_memory_application_settings
end
- delegate :sidekiq_throttling_enabled?, to: :current_application_settings
+ def retrieve_settings_from_database?
+ settings = retrieve_settings_from_database_cache?
+ return settings if settings.present?
+
+ return fake_application_settings unless connect_to_db?
+
+ begin
+ db_settings = ::ApplicationSetting.current
+ # In case Redis isn't running or the Redis UNIX socket file is not available
+ rescue ::Redis::BaseError, ::Errno::ENOENT
+ db_settings = ::ApplicationSetting.last
+ end
+ db_settings || ::ApplicationSetting.create_from_defaults
+ end
+
+ def retrieve_settings_from_database_cache?
+ begin
+ settings = ApplicationSetting.cached
+ rescue ::Redis::BaseError, ::Errno::ENOENT
+ # In case Redis isn't running or the Redis UNIX socket file is not available
+ settings = nil
+ end
+ settings
+ end
def in_memory_application_settings
@in_memory_application_settings ||= ::ApplicationSetting.new(::ApplicationSetting.defaults)
- # In case migrations the application_settings table is not created yet,
- # we fallback to a simple OpenStruct
rescue ActiveRecord::StatementInvalid, ActiveRecord::UnknownAttributeError
+ # In case migrations the application_settings table is not created yet,
+ # we fallback to a simple OpenStruct
fake_application_settings
end
- def fake_application_settings
- OpenStruct.new(::ApplicationSetting.defaults)
- end
-
- private
-
def connect_to_db?
# When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised
active_db_connection = ActiveRecord::Base.connection.active? rescue false
diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb
index 182a30fd74d..e47fb85b5ee 100644
--- a/lib/gitlab/data_builder/pipeline.rb
+++ b/lib/gitlab/data_builder/pipeline.rb
@@ -22,7 +22,7 @@ module Gitlab
sha: pipeline.sha,
before_sha: pipeline.before_sha,
status: pipeline.status,
- stages: pipeline.stages_name,
+ stages: pipeline.stages_names,
created_at: pipeline.created_at,
finished_at: pipeline.finished_at,
duration: pipeline.duration
diff --git a/lib/gitlab/diff/diff_refs.rb b/lib/gitlab/diff/diff_refs.rb
index 7948782aecc..371cbe04b9b 100644
--- a/lib/gitlab/diff/diff_refs.rb
+++ b/lib/gitlab/diff/diff_refs.rb
@@ -37,6 +37,16 @@ module Gitlab
def complete?
start_sha && head_sha
end
+
+ def compare_in(project)
+ # We're at the initial commit, so just get that as we can't compare to anything.
+ if Gitlab::Git.blank_ref?(start_sha)
+ project.commit(head_sha)
+ else
+ straight = start_sha == base_sha
+ CompareService.new(project, head_sha).execute(project, start_sha, straight: straight)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb
index 2aef7fdaa35..4212a0dbe2e 100644
--- a/lib/gitlab/diff/file.rb
+++ b/lib/gitlab/diff/file.rb
@@ -58,19 +58,19 @@ module Gitlab
diff_refs&.head_sha
end
- def content_sha
- return old_content_sha if deleted_file?
- return @content_sha if defined?(@content_sha)
+ def new_content_sha
+ return if deleted_file?
+ return @new_content_sha if defined?(@new_content_sha)
refs = diff_refs || fallback_diff_refs
- @content_sha = refs&.head_sha
+ @new_content_sha = refs&.head_sha
end
- def content_commit
- return @content_commit if defined?(@content_commit)
+ def new_content_commit
+ return @new_content_commit if defined?(@new_content_commit)
- sha = content_sha
- @content_commit = repository.commit(sha) if sha
+ sha = new_content_commit
+ @new_content_commit = repository.commit(sha) if sha
end
def old_content_sha
@@ -88,13 +88,13 @@ module Gitlab
@old_content_commit = repository.commit(sha) if sha
end
- def blob
- return @blob if defined?(@blob)
+ def new_blob
+ return @new_blob if defined?(@new_blob)
- sha = content_sha
- return @blob = nil unless sha
+ sha = new_content_sha
+ return @new_blob = nil unless sha
- repository.blob_at(sha, file_path)
+ @new_blob = repository.blob_at(sha, file_path)
end
def old_blob
@@ -106,6 +106,18 @@ module Gitlab
@old_blob = repository.blob_at(sha, old_path)
end
+ def content_sha
+ new_content_sha || old_content_sha
+ end
+
+ def content_commit
+ new_content_commit || old_content_commit
+ end
+
+ def blob
+ new_blob || old_blob
+ end
+
attr_writer :highlighted_diff_lines
# Array of Gitlab::Diff::Line objects
@@ -153,6 +165,18 @@ module Gitlab
def file_identifier
"#{file_path}-#{new_file?}-#{deleted_file?}-#{renamed_file?}"
end
+
+ def diffable?
+ repository.attributes(file_path).fetch('diff') { true }
+ end
+
+ def binary?
+ old_blob&.binary? || new_blob&.binary?
+ end
+
+ def text?
+ !binary?
+ end
end
end
end
diff --git a/lib/gitlab/diff/file_collection/merge_request_diff.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb
index 9a58b500a2c..fcda1fe2233 100644
--- a/lib/gitlab/diff/file_collection/merge_request_diff.rb
+++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb
@@ -66,10 +66,7 @@ module Gitlab
end
def cacheable?(diff_file)
- @merge_request_diff.present? &&
- diff_file.blob &&
- diff_file.blob.text? &&
- @project.repository.diffable?(diff_file.blob)
+ @merge_request_diff.present? && diff_file.text? && diff_file.diffable?
end
def cache_key
diff --git a/lib/gitlab/diff/highlight.rb b/lib/gitlab/diff/highlight.rb
index ed2f541977a..b669ee5b799 100644
--- a/lib/gitlab/diff/highlight.rb
+++ b/lib/gitlab/diff/highlight.rb
@@ -42,9 +42,9 @@ module Gitlab
rich_line =
if diff_line.unchanged? || diff_line.added?
- new_lines[diff_line.new_pos - 1]
+ new_lines[diff_line.new_pos - 1]&.html_safe
elsif diff_line.removed?
- old_lines[diff_line.old_pos - 1]
+ old_lines[diff_line.old_pos - 1]&.html_safe
end
# Only update text if line is found. This will prevent
@@ -60,13 +60,18 @@ module Gitlab
end
def old_lines
- return unless diff_file
- @old_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_old_sha, diff_old_path)
+ @old_lines ||= highlighted_blob_lines(diff_file.old_blob)
end
def new_lines
- return unless diff_file
- @new_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_new_sha, diff_new_path)
+ @new_lines ||= highlighted_blob_lines(diff_file.new_blob)
+ end
+
+ def highlighted_blob_lines(blob)
+ return [] unless blob
+
+ blob.load_all_data!
+ Gitlab::Highlight.highlight(blob.path, blob.data, repository: repository).lines
end
end
end
diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb
index 4d96778a2b2..f80afb20f0c 100644
--- a/lib/gitlab/diff/position.rb
+++ b/lib/gitlab/diff/position.rb
@@ -145,23 +145,9 @@ module Gitlab
private
def find_diff_file(repository)
- # We're at the initial commit, so just get that as we can't compare to anything.
- compare =
- if Gitlab::Git.blank_ref?(start_sha)
- Gitlab::Git::Commit.find(repository.raw_repository, head_sha)
- else
- Gitlab::Git::Compare.new(
- repository.raw_repository,
- start_sha,
- head_sha
- )
- end
-
- diff = compare.diffs(paths: paths).first
-
- return unless diff
+ return unless diff_refs.complete?
- Gitlab::Diff::File.new(diff, repository: repository, diff_refs: diff_refs)
+ diff_refs.compare_in(repository.project).diffs(paths: paths, expanded: true).diff_files.first
end
end
end
diff --git a/lib/gitlab/diff/position_tracer.rb b/lib/gitlab/diff/position_tracer.rb
index dcabb5f7fe5..b68a1636814 100644
--- a/lib/gitlab/diff/position_tracer.rb
+++ b/lib/gitlab/diff/position_tracer.rb
@@ -216,7 +216,7 @@ module Gitlab
def compare(start_sha, head_sha, straight: false)
compare = CompareService.new(project, head_sha).execute(project, start_sha, straight: straight)
- compare.diffs(paths: paths)
+ compare.diffs(paths: paths, expanded: true)
end
def position(diff_file, old_line, new_line)
diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb
index dbe28e6bb93..781f9c56a42 100644
--- a/lib/gitlab/encoding_helper.rb
+++ b/lib/gitlab/encoding_helper.rb
@@ -38,7 +38,7 @@ module Gitlab
def encode_utf8(message)
detect = CharlockHolmes::EncodingDetector.detect(message)
- if detect
+ if detect && detect[:encoding]
begin
CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8')
rescue ArgumentError => e
diff --git a/lib/gitlab/etag_caching/middleware.rb b/lib/gitlab/etag_caching/middleware.rb
index 270d67dd50c..7f884183bb1 100644
--- a/lib/gitlab/etag_caching/middleware.rb
+++ b/lib/gitlab/etag_caching/middleware.rb
@@ -6,12 +6,13 @@ module Gitlab
end
def call(env)
- route = Gitlab::EtagCaching::Router.match(env)
+ request = Rack::Request.new(env)
+ route = Gitlab::EtagCaching::Router.match(request)
return @app.call(env) unless route
track_event(:etag_caching_middleware_used, route)
- etag, cached_value_present = get_etag(env)
+ etag, cached_value_present = get_etag(request)
if_none_match = env['HTTP_IF_NONE_MATCH']
if if_none_match == etag
@@ -27,8 +28,8 @@ module Gitlab
private
- def get_etag(env)
- cache_key = env['PATH_INFO']
+ def get_etag(request)
+ cache_key = request.path
store = Gitlab::EtagCaching::Store.new
current_value = store.get(cache_key)
cached_value_present = current_value.present?
diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb
index 2f9d8bfc266..dccc66b3918 100644
--- a/lib/gitlab/etag_caching/router.rb
+++ b/lib/gitlab/etag_caching/router.rb
@@ -9,8 +9,8 @@ module Gitlab
# - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route
# - Ending in `issues/id`/realtime_changes` for the `issue_title` route
USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes
- commit pipelines merge_requests new
- environments].freeze
+ commit pipelines merge_requests builds
+ new environments].freeze
RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape)))
@@ -44,13 +44,17 @@ module Gitlab
'project_pipeline'
),
Gitlab::EtagCaching::Router::Route.new(
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/builds/\d+\.json\z),
+ 'project_build'
+ ),
+ Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
'environments'
)
].freeze
- def self.match(env)
- ROUTES.find { |route| route.regexp.match(env['PATH_INFO']) }
+ def self.match(request)
+ ROUTES.find { |route| route.regexp.match(request.path_info) }
end
end
end
diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb
index d60e607b02b..33a7624e303 100644
--- a/lib/gitlab/git/blob.rb
+++ b/lib/gitlab/git/blob.rb
@@ -123,6 +123,7 @@ module Gitlab
@loaded_all_data = true
@data = repository.lookup(id).content
@loaded_size = @data.bytesize
+ @binary = nil
end
def name
diff --git a/lib/gitlab/git/compare.rb b/lib/gitlab/git/compare.rb
index 696a2acd5e3..78e440395a5 100644
--- a/lib/gitlab/git/compare.rb
+++ b/lib/gitlab/git/compare.rb
@@ -3,7 +3,7 @@ module Gitlab
class Compare
attr_reader :head, :base, :straight
- def initialize(repository, base, head, straight = false)
+ def initialize(repository, base, head, straight: false)
@repository = repository
@straight = straight
diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb
index 0594ac8e213..8926aa19925 100644
--- a/lib/gitlab/git/diff.rb
+++ b/lib/gitlab/git/diff.rb
@@ -20,13 +20,25 @@ module Gitlab
# We need this accessor because of `to_hash` and `init_from_hash`
attr_accessor :too_large
- # The maximum size of a diff to display.
- SIZE_LIMIT = 100.kilobytes
+ class << self
+ # The maximum size of a diff to display.
+ def size_limit
+ if Feature.enabled?('gitlab_git_diff_size_limit_increase')
+ 200.kilobytes
+ else
+ 100.kilobytes
+ end
+ end
- # The maximum size before a diff is collapsed.
- COLLAPSE_LIMIT = 10.kilobytes
+ # The maximum size before a diff is collapsed.
+ def collapse_limit
+ if Feature.enabled?('gitlab_git_diff_size_limit_increase')
+ 100.kilobytes
+ else
+ 10.kilobytes
+ end
+ end
- class << self
def between(repo, head, base, options = {}, *paths)
straight = options.delete(:straight) || false
@@ -189,7 +201,7 @@ module Gitlab
prune_diff_if_eligible
when Rugged::Patch, Rugged::Diff::Delta
init_from_rugged(raw_diff)
- when Gitaly::CommitDiffResponse
+ when Gitlab::GitalyClient::Diff
init_from_gitaly(raw_diff)
prune_diff_if_eligible
when Gitaly::CommitDelta
@@ -231,7 +243,7 @@ module Gitlab
def too_large?
if @too_large.nil?
- @too_large = @diff.bytesize >= SIZE_LIMIT
+ @too_large = @diff.bytesize >= self.class.size_limit
else
@too_large
end
@@ -246,7 +258,7 @@ module Gitlab
def collapsed?
return @collapsed if defined?(@collapsed)
- @collapsed = !expanded && @diff.bytesize >= COLLAPSE_LIMIT
+ @collapsed = !expanded && @diff.bytesize >= self.class.collapse_limit
end
def collapse!
@@ -290,15 +302,15 @@ module Gitlab
end
end
- def init_from_gitaly(msg)
- @diff = msg.raw_chunks.join if msg.respond_to?(:raw_chunks)
- @new_path = encode!(msg.to_path.dup)
- @old_path = encode!(msg.from_path.dup)
- @a_mode = msg.old_mode.to_s(8)
- @b_mode = msg.new_mode.to_s(8)
- @new_file = msg.from_id == BLANK_SHA
- @renamed_file = msg.from_path != msg.to_path
- @deleted_file = msg.to_id == BLANK_SHA
+ def init_from_gitaly(diff)
+ @diff = diff.patch if diff.respond_to?(:patch)
+ @new_path = encode!(diff.to_path.dup)
+ @old_path = encode!(diff.from_path.dup)
+ @a_mode = diff.old_mode.to_s(8)
+ @b_mode = diff.new_mode.to_s(8)
+ @new_file = diff.from_id == BLANK_SHA
+ @renamed_file = diff.from_path != diff.to_path
+ @deleted_file = diff.to_id == BLANK_SHA
end
def prune_diff_if_eligible
@@ -318,14 +330,14 @@ module Gitlab
hunk.each_line do |line|
size += line.content.bytesize
- if size >= SIZE_LIMIT
+ if size >= self.class.size_limit
too_large!
return true
end
end
end
- if !expanded && size >= COLLAPSE_LIMIT
+ if !expanded && size >= self.class.collapse_limit
collapse!
return true
end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 9d6adbdb4ac..85695d0a4df 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -962,11 +962,6 @@ module Gitlab
end
end
- # Checks if the blob should be diffable according to its attributes
- def diffable?(blob)
- attributes(blob.path).fetch('diff') { blob.text? }
- end
-
# Returns the Git attributes for the given file path.
#
# See `Gitlab::Git::Attributes` for more information.
diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb
index 99724db8da2..0a19d24eb20 100644
--- a/lib/gitlab/git_access.rb
+++ b/lib/gitlab/git_access.rb
@@ -3,33 +3,39 @@
module Gitlab
class GitAccess
UnauthorizedError = Class.new(StandardError)
+ NotFoundError = Class.new(StandardError)
ERROR_MESSAGES = {
upload: 'You are not allowed to upload code for this project.',
download: 'You are not allowed to download code from this project.',
deploy_key_upload:
'This deploy key does not have write access to this project.',
- no_repo: 'A repository for this project does not exist yet.'
+ no_repo: 'A repository for this project does not exist yet.',
+ project_not_found: 'The project you were looking for could not be found.',
+ account_blocked: 'Your account has been blocked.',
+ command_not_allowed: "The command you're trying to execute is not allowed.",
+ upload_pack_disabled_over_http: 'Pulling over HTTP is not allowed.',
+ receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.'
}.freeze
DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive }.freeze
PUSH_COMMANDS = %w{ git-receive-pack }.freeze
ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS
- attr_reader :actor, :project, :protocol, :user_access, :authentication_abilities
+ attr_reader :actor, :project, :protocol, :authentication_abilities
def initialize(actor, project, protocol, authentication_abilities:)
@actor = actor
@project = project
@protocol = protocol
@authentication_abilities = authentication_abilities
- @user_access = UserAccess.new(user, project: project)
end
def check(cmd, changes)
check_protocol!
check_active_user!
check_project_accessibility!
+ check_command_disabled!(cmd)
check_command_existence!(cmd)
check_repository_existence!
@@ -40,9 +46,7 @@ module Gitlab
check_push_access!(changes)
end
- build_status_object(true)
- rescue UnauthorizedError => ex
- build_status_object(false, ex.message)
+ true
end
def guest_can_download_code?
@@ -73,19 +77,39 @@ module Gitlab
return if deploy_key?
if user && !user_access.allowed?
- raise UnauthorizedError, "Your account has been blocked."
+ raise UnauthorizedError, ERROR_MESSAGES[:account_blocked]
end
end
def check_project_accessibility!
if project.blank? || !can_read_project?
- raise UnauthorizedError, 'The project you were looking for could not be found.'
+ raise NotFoundError, ERROR_MESSAGES[:project_not_found]
+ end
+ end
+
+ def check_command_disabled!(cmd)
+ if upload_pack?(cmd)
+ check_upload_pack_disabled!
+ elsif receive_pack?(cmd)
+ check_receive_pack_disabled!
+ end
+ end
+
+ def check_upload_pack_disabled!
+ if http? && upload_pack_disabled_over_http?
+ raise UnauthorizedError, ERROR_MESSAGES[:upload_pack_disabled_over_http]
+ end
+ end
+
+ def check_receive_pack_disabled!
+ if http? && receive_pack_disabled_over_http?
+ raise UnauthorizedError, ERROR_MESSAGES[:receive_pack_disabled_over_http]
end
end
def check_command_existence!(cmd)
unless ALL_COMMANDS.include?(cmd)
- raise UnauthorizedError, "The command you're trying to execute is not allowed."
+ raise UnauthorizedError, ERROR_MESSAGES[:command_not_allowed]
end
end
@@ -138,11 +162,9 @@ module Gitlab
# Iterate over all changes to find if user allowed all of them to be applied
changes_list.each do |change|
- status = check_single_change_access(change)
- unless status.allowed?
- # If user does not have access to make at least one change - cancel all push
- raise UnauthorizedError, status.message
- end
+ # If user does not have access to make at least one change, cancel all
+ # push by allowing the exception to bubble up
+ check_single_change_access(change)
end
end
@@ -168,14 +190,40 @@ module Gitlab
actor.is_a?(DeployKey)
end
+ def ci?
+ actor == :ci
+ end
+
def can_read_project?
- if deploy_key
+ if deploy_key?
deploy_key.has_access_to?(project)
elsif user
user.can?(:read_project, project)
+ elsif ci?
+ true # allow CI (build without a user) for backwards compatibility
end || Guest.can?(:read_project, project)
end
+ def http?
+ protocol == 'http'
+ end
+
+ def upload_pack?(command)
+ command == 'git-upload-pack'
+ end
+
+ def receive_pack?(command)
+ command == 'git-receive-pack'
+ end
+
+ def upload_pack_disabled_over_http?
+ !Gitlab.config.gitlab_shell.upload_pack
+ end
+
+ def receive_pack_disabled_over_http?
+ !Gitlab.config.gitlab_shell.receive_pack
+ end
+
protected
def user
@@ -185,15 +233,19 @@ module Gitlab
case actor
when User
actor
- when DeployKey
- nil
when Key
- actor.user
+ actor.user unless actor.is_a?(DeployKey)
+ when :ci
+ nil
end
end
- def build_status_object(status, message = '')
- Gitlab::GitAccessStatus.new(status, message)
+ def user_access
+ @user_access ||= if ci?
+ CiAccess.new
+ else
+ UserAccess.new(user, project: project)
+ end
end
end
end
diff --git a/lib/gitlab/git_access_status.rb b/lib/gitlab/git_access_status.rb
deleted file mode 100644
index 09bb01be694..00000000000
--- a/lib/gitlab/git_access_status.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-module Gitlab
- class GitAccessStatus
- attr_accessor :status, :message
- alias_method :allowed?, :status
-
- def initialize(status, message = '')
- @status = status
- @message = message
- end
-
- def to_json(opts = nil)
- { status: @status, message: @message }.to_json(opts)
- end
- end
-end
diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb
index 67eaa5e088d..1fe5155c093 100644
--- a/lib/gitlab/git_access_wiki.rb
+++ b/lib/gitlab/git_access_wiki.rb
@@ -1,5 +1,9 @@
module Gitlab
class GitAccessWiki < GitAccess
+ ERROR_MESSAGES = {
+ write_to_wiki: "You are not allowed to write to this project's wiki."
+ }.freeze
+
def guest_can_download_code?
Guest.can?(:download_wiki_code, project)
end
@@ -9,11 +13,11 @@ module Gitlab
end
def check_single_change_access(change)
- if user_access.can_do_action?(:create_wiki)
- build_status_object(true)
- else
- build_status_object(false, "You are not allowed to write to this project's wiki.")
+ unless user_access.can_do_action?(:create_wiki)
+ raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki]
end
+
+ true
end
end
end
diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb
index 4491903d788..ba3da781dad 100644
--- a/lib/gitlab/gitaly_client/commit.rb
+++ b/lib/gitlab/gitaly_client/commit.rb
@@ -26,7 +26,7 @@ module Gitlab
request_params[:ignore_whitespace_change] = options.fetch(:ignore_whitespace_change, false)
response = diff_service_stub.commit_diff(Gitaly::CommitDiffRequest.new(request_params))
- Gitlab::Git::DiffCollection.new(response, options)
+ Gitlab::Git::DiffCollection.new(GitalyClient::DiffStitcher.new(response), options)
end
def commit_deltas(commit)
diff --git a/lib/gitlab/gitaly_client/diff.rb b/lib/gitlab/gitaly_client/diff.rb
new file mode 100644
index 00000000000..1e117b7e74a
--- /dev/null
+++ b/lib/gitlab/gitaly_client/diff.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module GitalyClient
+ class Diff
+ FIELDS = %i(from_path to_path old_mode new_mode from_id to_id patch).freeze
+
+ attr_accessor(*FIELDS)
+
+ def initialize(params)
+ params.each do |key, val|
+ public_send(:"#{key}=", val)
+ end
+ end
+
+ def ==(other)
+ FIELDS.all? do |field|
+ public_send(field) == other.public_send(field)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/diff_stitcher.rb b/lib/gitlab/gitaly_client/diff_stitcher.rb
new file mode 100644
index 00000000000..d84e8d752dc
--- /dev/null
+++ b/lib/gitlab/gitaly_client/diff_stitcher.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module GitalyClient
+ class DiffStitcher
+ include Enumerable
+
+ def initialize(rpc_response)
+ @rpc_response = rpc_response
+ end
+
+ def each
+ current_diff = nil
+
+ @rpc_response.each do |diff_msg|
+ if current_diff.nil?
+ diff_params = diff_msg.to_h.slice(*GitalyClient::Diff::FIELDS)
+ diff_params[:patch] = diff_msg.raw_patch_data
+
+ current_diff = GitalyClient::Diff.new(diff_params)
+ else
+ current_diff.patch += diff_msg.raw_patch_data
+ end
+
+ if diff_msg.end_of_patch
+ yield current_diff
+ current_diff = nil
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/google_code_import/client.rb b/lib/gitlab/google_code_import/client.rb
index 890bd9a3554..b1dbf554e41 100644
--- a/lib/gitlab/google_code_import/client.rb
+++ b/lib/gitlab/google_code_import/client.rb
@@ -14,7 +14,7 @@ module Gitlab
end
def valid?
- raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.has_key?("projects")
+ raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.key?("projects")
end
def repos
diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb
index 1b43440673c..ab38c0c3e34 100644
--- a/lib/gitlab/google_code_import/importer.rb
+++ b/lib/gitlab/google_code_import/importer.rb
@@ -95,7 +95,7 @@ module Gitlab
labels = import_issue_labels(raw_issue)
assignee_id = nil
- if raw_issue.has_key?("owner")
+ if raw_issue.key?("owner")
username = user_map[raw_issue["owner"]["name"]]
if username.start_with?("@")
@@ -144,7 +144,7 @@ module Gitlab
def import_issue_comments(issue, comments)
Note.transaction do
while raw_comment = comments.shift
- next if raw_comment.has_key?("deletedBy")
+ next if raw_comment.key?("deletedBy")
content = format_content(raw_comment["content"])
updates = format_updates(raw_comment["updates"])
@@ -235,15 +235,15 @@ module Gitlab
def format_updates(raw_updates)
updates = []
- if raw_updates.has_key?("status")
+ if raw_updates.key?("status")
updates << "*Status: #{raw_updates["status"]}*"
end
- if raw_updates.has_key?("owner")
+ if raw_updates.key?("owner")
updates << "*Owner: #{user_map[raw_updates["owner"]]}*"
end
- if raw_updates.has_key?("cc")
+ if raw_updates.key?("cc")
cc = raw_updates["cc"].map do |l|
deleted = l.start_with?("-")
l = l[1..-1] if deleted
@@ -255,7 +255,7 @@ module Gitlab
updates << "*Cc: #{cc.join(", ")}*"
end
- if raw_updates.has_key?("labels")
+ if raw_updates.key?("labels")
labels = raw_updates["labels"].map do |l|
deleted = l.start_with?("-")
l = l[1..-1] if deleted
@@ -267,11 +267,11 @@ module Gitlab
updates << "*Labels: #{labels.join(", ")}*"
end
- if raw_updates.has_key?("mergedInto")
+ if raw_updates.key?("mergedInto")
updates << "*Merged into: ##{raw_updates["mergedInto"]}*"
end
- if raw_updates.has_key?("blockedOn")
+ if raw_updates.key?("blockedOn")
blocked_ons = raw_updates["blockedOn"].map do |raw_blocked_on|
format_blocking_updates(raw_blocked_on)
end
@@ -279,7 +279,7 @@ module Gitlab
updates << "*Blocked on: #{blocked_ons.join(", ")}*"
end
- if raw_updates.has_key?("blocking")
+ if raw_updates.key?("blocking")
blockings = raw_updates["blocking"].map do |raw_blocked_on|
format_blocking_updates(raw_blocked_on)
end
diff --git a/lib/gitlab/health_checks/prometheus_text_format.rb b/lib/gitlab/health_checks/prometheus_text_format.rb
new file mode 100644
index 00000000000..b3c759b4730
--- /dev/null
+++ b/lib/gitlab/health_checks/prometheus_text_format.rb
@@ -0,0 +1,40 @@
+module Gitlab
+ module HealthChecks
+ class PrometheusTextFormat
+ def marshal(metrics)
+ "#{metrics_with_type_declarations(metrics).join("\n")}\n"
+ end
+
+ private
+
+ def metrics_with_type_declarations(metrics)
+ type_declaration_added = {}
+
+ metrics.flat_map do |metric|
+ metric_lines = []
+
+ unless type_declaration_added.key?(metric.name)
+ type_declaration_added[metric.name] = true
+ metric_lines << metric_type_declaration(metric)
+ end
+
+ metric_lines << metric_text(metric)
+ end
+ end
+
+ def metric_type_declaration(metric)
+ "# TYPE #{metric.name} gauge"
+ end
+
+ def metric_text(metric)
+ labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || ''
+
+ if labels.empty?
+ "#{metric.name} #{metric.value}"
+ else
+ "#{metric.name}{#{labels}} #{metric.value}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/highlight.rb b/lib/gitlab/highlight.rb
index 83bc230df3e..6b24da030df 100644
--- a/lib/gitlab/highlight.rb
+++ b/lib/gitlab/highlight.rb
@@ -5,14 +5,6 @@ module Gitlab
highlight(blob_content, continue: false, plain: plain)
end
- def self.highlight_lines(repository, ref, file_name)
- blob = repository.blob_at(ref, file_name)
- return [] unless blob
-
- blob.load_all_data!(repository)
- highlight(file_name, blob.data, repository: repository).lines.map!(&:html_safe)
- end
-
attr_reader :blob_name
def initialize(blob_name, blob_content, repository: nil)
diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb
index 5ab3eeb3aff..328dd17e452 100644
--- a/lib/gitlab/i18n.rb
+++ b/lib/gitlab/i18n.rb
@@ -5,7 +5,12 @@ module Gitlab
AVAILABLE_LANGUAGES = {
'en' => 'English',
'es' => 'Español',
- 'de' => 'Deutsch'
+ 'de' => 'Deutsch',
+ 'pt_BR' => 'Português(Brasil)',
+ 'zh_CN' => '简体中文',
+ 'zh_HK' => '繁體中文(香港)',
+ 'zh_TW' => '繁體中文(臺灣)',
+ 'bg' => 'български'
}.freeze
def available_locales
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index d0f3cf2b514..ff2b1d08c3c 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -38,6 +38,7 @@ project_tree:
- notes:
- :author
- :events
+ - :stages
- :statuses
- :triggers
- :pipeline_schedules
diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb
index 19e23a4715f..695852526cb 100644
--- a/lib/gitlab/import_export/relation_factory.rb
+++ b/lib/gitlab/import_export/relation_factory.rb
@@ -3,6 +3,7 @@ module Gitlab
class RelationFactory
OVERRIDES = { snippets: :project_snippets,
pipelines: 'Ci::Pipeline',
+ stages: 'Ci::Stage',
statuses: 'commit_status',
triggers: 'Ci::Trigger',
pipeline_schedules: 'Ci::PipelineSchedule',
diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb
index 2d5e47a6f3b..5e299e26c54 100644
--- a/lib/gitlab/ldap/user.rb
+++ b/lib/gitlab/ldap/user.rb
@@ -41,11 +41,6 @@ module Gitlab
def update_user_attributes
if persisted?
- if auth_hash.has_email?
- gl_user.skip_reconfirmation!
- gl_user.email = auth_hash.email
- end
-
# find_or_initialize_by doesn't update `gl_user.identities`, and isn't autosaved.
identity = gl_user.identities.find { |identity| identity.provider == auth_hash.provider }
identity ||= gl_user.identities.build(provider: auth_hash.provider)
@@ -55,10 +50,6 @@ module Gitlab
# For an existing identity with no change in DN, this line changes nothing.
identity.extern_uid = auth_hash.uid
end
-
- gl_user.ldap_email = auth_hash.has_email?
-
- gl_user
end
def changed?
@@ -69,6 +60,10 @@ module Gitlab
ldap_config.block_auto_created_users
end
+ def sync_email_from_provider?
+ true
+ end
+
def allowed?
Gitlab::LDAP::Access.allowed?(gl_user)
end
diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb
index cb8db2f1e9f..4779755bb22 100644
--- a/lib/gitlab/metrics.rb
+++ b/lib/gitlab/metrics.rb
@@ -1,161 +1,10 @@
module Gitlab
module Metrics
- extend Gitlab::CurrentSettings
-
- RAILS_ROOT = Rails.root.to_s
- METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s
- PATH_REGEX = /^#{RAILS_ROOT}\/?/
-
- def self.settings
- @settings ||= {
- enabled: current_application_settings[:metrics_enabled],
- pool_size: current_application_settings[:metrics_pool_size],
- timeout: current_application_settings[:metrics_timeout],
- method_call_threshold: current_application_settings[:metrics_method_call_threshold],
- host: current_application_settings[:metrics_host],
- port: current_application_settings[:metrics_port],
- sample_interval: current_application_settings[:metrics_sample_interval] || 15,
- packet_size: current_application_settings[:metrics_packet_size] || 1
- }
- end
+ extend Gitlab::Metrics::InfluxDb
+ extend Gitlab::Metrics::Prometheus
def self.enabled?
- settings[:enabled] || false
- end
-
- def self.mri?
- RUBY_ENGINE == 'ruby'
- end
-
- def self.method_call_threshold
- # This is memoized since this method is called for every instrumented
- # method. Loading data from an external cache on every method call slows
- # things down too much.
- @method_call_threshold ||= settings[:method_call_threshold]
- end
-
- def self.pool
- @pool
- end
-
- def self.submit_metrics(metrics)
- prepared = prepare_metrics(metrics)
-
- pool.with do |connection|
- prepared.each_slice(settings[:packet_size]) do |slice|
- begin
- connection.write_points(slice)
- rescue StandardError
- end
- end
- end
- rescue Errno::EADDRNOTAVAIL, SocketError => ex
- Gitlab::EnvironmentLogger.error('Cannot resolve InfluxDB address. GitLab Performance Monitoring will not work.')
- Gitlab::EnvironmentLogger.error(ex)
- end
-
- def self.prepare_metrics(metrics)
- metrics.map do |hash|
- new_hash = hash.symbolize_keys
-
- new_hash[:tags].each do |key, value|
- if value.blank?
- new_hash[:tags].delete(key)
- else
- new_hash[:tags][key] = escape_value(value)
- end
- end
-
- new_hash
- end
- end
-
- def self.escape_value(value)
- value.to_s.gsub('=', '\\=')
- end
-
- # Measures the execution time of a block.
- #
- # Example:
- #
- # Gitlab::Metrics.measure(:find_by_username_duration) do
- # User.find_by_username(some_username)
- # end
- #
- # name - The name of the field to store the execution time in.
- #
- # Returns the value yielded by the supplied block.
- def self.measure(name)
- trans = current_transaction
-
- return yield unless trans
-
- real_start = Time.now.to_f
- cpu_start = System.cpu_time
-
- retval = yield
-
- cpu_stop = System.cpu_time
- real_stop = Time.now.to_f
-
- real_time = (real_stop - real_start) * 1000.0
- cpu_time = cpu_stop - cpu_start
-
- trans.increment("#{name}_real_time", real_time)
- trans.increment("#{name}_cpu_time", cpu_time)
- trans.increment("#{name}_call_count", 1)
-
- retval
- end
-
- # Adds a tag to the current transaction (if any)
- #
- # name - The name of the tag to add.
- # value - The value of the tag.
- def self.tag_transaction(name, value)
- trans = current_transaction
-
- trans&.add_tag(name, value)
- end
-
- # Sets the action of the current transaction (if any)
- #
- # action - The name of the action.
- def self.action=(action)
- trans = current_transaction
-
- trans&.action = action
- end
-
- # Tracks an event.
- #
- # See `Gitlab::Metrics::Transaction#add_event` for more details.
- def self.add_event(*args)
- trans = current_transaction
-
- trans&.add_event(*args)
- end
-
- # Returns the prefix to use for the name of a series.
- def self.series_prefix
- @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
- end
-
- # Allow access from other metrics related middlewares
- def self.current_transaction
- Transaction.current
- end
-
- # When enabled this should be set before being used as the usual pattern
- # "@foo ||= bar" is _not_ thread-safe.
- if enabled?
- @pool = ConnectionPool.new(size: settings[:pool_size], timeout: settings[:timeout]) do
- host = settings[:host]
- port = settings[:port]
-
- InfluxDB::Client.
- new(udp: { host: host, port: port })
- end
+ influx_metrics_enabled? || prometheus_metrics_enabled?
end
end
end
diff --git a/lib/gitlab/metrics/influx_db.rb b/lib/gitlab/metrics/influx_db.rb
new file mode 100644
index 00000000000..3a39791edbf
--- /dev/null
+++ b/lib/gitlab/metrics/influx_db.rb
@@ -0,0 +1,170 @@
+module Gitlab
+ module Metrics
+ module InfluxDb
+ extend Gitlab::CurrentSettings
+ extend self
+
+ MUTEX = Mutex.new
+ private_constant :MUTEX
+
+ def influx_metrics_enabled?
+ settings[:enabled] || false
+ end
+
+ RAILS_ROOT = Rails.root.to_s
+ METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s
+ PATH_REGEX = /^#{RAILS_ROOT}\/?/
+
+ def settings
+ @settings ||= {
+ enabled: current_application_settings[:metrics_enabled],
+ pool_size: current_application_settings[:metrics_pool_size],
+ timeout: current_application_settings[:metrics_timeout],
+ method_call_threshold: current_application_settings[:metrics_method_call_threshold],
+ host: current_application_settings[:metrics_host],
+ port: current_application_settings[:metrics_port],
+ sample_interval: current_application_settings[:metrics_sample_interval] || 15,
+ packet_size: current_application_settings[:metrics_packet_size] || 1
+ }
+ end
+
+ def mri?
+ RUBY_ENGINE == 'ruby'
+ end
+
+ def method_call_threshold
+ # This is memoized since this method is called for every instrumented
+ # method. Loading data from an external cache on every method call slows
+ # things down too much.
+ @method_call_threshold ||= settings[:method_call_threshold]
+ end
+
+ def submit_metrics(metrics)
+ prepared = prepare_metrics(metrics)
+
+ pool&.with do |connection|
+ prepared.each_slice(settings[:packet_size]) do |slice|
+ begin
+ connection.write_points(slice)
+ rescue StandardError
+ end
+ end
+ end
+ rescue Errno::EADDRNOTAVAIL, SocketError => ex
+ Gitlab::EnvironmentLogger.error('Cannot resolve InfluxDB address. GitLab Performance Monitoring will not work.')
+ Gitlab::EnvironmentLogger.error(ex)
+ end
+
+ def prepare_metrics(metrics)
+ metrics.map do |hash|
+ new_hash = hash.symbolize_keys
+
+ new_hash[:tags].each do |key, value|
+ if value.blank?
+ new_hash[:tags].delete(key)
+ else
+ new_hash[:tags][key] = escape_value(value)
+ end
+ end
+
+ new_hash
+ end
+ end
+
+ def escape_value(value)
+ value.to_s.gsub('=', '\\=')
+ end
+
+ # Measures the execution time of a block.
+ #
+ # Example:
+ #
+ # Gitlab::Metrics.measure(:find_by_username_duration) do
+ # User.find_by_username(some_username)
+ # end
+ #
+ # name - The name of the field to store the execution time in.
+ #
+ # Returns the value yielded by the supplied block.
+ def measure(name)
+ trans = current_transaction
+
+ return yield unless trans
+
+ real_start = Time.now.to_f
+ cpu_start = System.cpu_time
+
+ retval = yield
+
+ cpu_stop = System.cpu_time
+ real_stop = Time.now.to_f
+
+ real_time = (real_stop - real_start) * 1000.0
+ cpu_time = cpu_stop - cpu_start
+
+ trans.increment("#{name}_real_time", real_time)
+ trans.increment("#{name}_cpu_time", cpu_time)
+ trans.increment("#{name}_call_count", 1)
+
+ retval
+ end
+
+ # Adds a tag to the current transaction (if any)
+ #
+ # name - The name of the tag to add.
+ # value - The value of the tag.
+ def tag_transaction(name, value)
+ trans = current_transaction
+
+ trans&.add_tag(name, value)
+ end
+
+ # Sets the action of the current transaction (if any)
+ #
+ # action - The name of the action.
+ def action=(action)
+ trans = current_transaction
+
+ trans&.action = action
+ end
+
+ # Tracks an event.
+ #
+ # See `Gitlab::Metrics::Transaction#add_event` for more details.
+ def add_event(*args)
+ trans = current_transaction
+
+ trans&.add_event(*args)
+ end
+
+ # Returns the prefix to use for the name of a series.
+ def series_prefix
+ @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
+ end
+
+ # Allow access from other metrics related middlewares
+ def current_transaction
+ Transaction.current
+ end
+
+ # When enabled this should be set before being used as the usual pattern
+ # "@foo ||= bar" is _not_ thread-safe.
+ def pool
+ if influx_metrics_enabled?
+ if @pool.nil?
+ MUTEX.synchronize do
+ @pool ||= ConnectionPool.new(size: settings[:pool_size], timeout: settings[:timeout]) do
+ host = settings[:host]
+ port = settings[:port]
+
+ InfluxDB::Client.
+ new(udp: { host: host, port: port })
+ end
+ end
+ end
+ @pool
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/null_metric.rb b/lib/gitlab/metrics/null_metric.rb
new file mode 100644
index 00000000000..3b5a2907195
--- /dev/null
+++ b/lib/gitlab/metrics/null_metric.rb
@@ -0,0 +1,10 @@
+module Gitlab
+ module Metrics
+ # Mocks ::Prometheus::Client::Metric and all derived metrics
+ class NullMetric
+ def method_missing(name, *args, &block)
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/prometheus.rb b/lib/gitlab/metrics/prometheus.rb
new file mode 100644
index 00000000000..60686509332
--- /dev/null
+++ b/lib/gitlab/metrics/prometheus.rb
@@ -0,0 +1,41 @@
+require 'prometheus/client'
+
+module Gitlab
+ module Metrics
+ module Prometheus
+ include Gitlab::CurrentSettings
+
+ def prometheus_metrics_enabled?
+ @prometheus_metrics_enabled ||= current_application_settings[:prometheus_metrics_enabled] || false
+ end
+
+ def registry
+ @registry ||= ::Prometheus::Client.registry
+ end
+
+ def counter(name, docstring, base_labels = {})
+ provide_metric(name) || registry.counter(name, docstring, base_labels)
+ end
+
+ def summary(name, docstring, base_labels = {})
+ provide_metric(name) || registry.summary(name, docstring, base_labels)
+ end
+
+ def gauge(name, docstring, base_labels = {})
+ provide_metric(name) || registry.gauge(name, docstring, base_labels)
+ end
+
+ def histogram(name, docstring, base_labels = {}, buckets = ::Prometheus::Client::Histogram::DEFAULT_BUCKETS)
+ provide_metric(name) || registry.histogram(name, docstring, base_labels, buckets)
+ end
+
+ def provide_metric(name)
+ if prometheus_metrics_enabled?
+ registry.get(name)
+ else
+ NullMetric.new
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb
index afd24b4dcc5..7307f8c2c87 100644
--- a/lib/gitlab/o_auth/user.rb
+++ b/lib/gitlab/o_auth/user.rb
@@ -12,6 +12,7 @@ module Gitlab
def initialize(auth_hash)
self.auth_hash = auth_hash
+ update_email
end
def persisted?
@@ -174,6 +175,22 @@ module Gitlab
}
end
+ def sync_email_from_provider?
+ auth_hash.provider.to_s == Gitlab.config.omniauth.sync_email_from_provider.to_s
+ end
+
+ def update_email
+ if auth_hash.has_email? && sync_email_from_provider?
+ if persisted?
+ gl_user.skip_reconfirmation!
+ gl_user.email = auth_hash.email
+ end
+
+ gl_user.external_email = true
+ gl_user.email_provider = auth_hash.provider
+ end
+ end
+
def log
Gitlab::AppLogger
end
diff --git a/lib/gitlab/otp_key_rotator.rb b/lib/gitlab/otp_key_rotator.rb
new file mode 100644
index 00000000000..0d541935bc6
--- /dev/null
+++ b/lib/gitlab/otp_key_rotator.rb
@@ -0,0 +1,87 @@
+module Gitlab
+ # The +otp_key_base+ param is used to encrypt the User#otp_secret attribute.
+ #
+ # When +otp_key_base+ is changed, it invalidates the current encrypted values
+ # of User#otp_secret. This class can be used to decrypt all the values with
+ # the old key, encrypt them with the new key, and and update the database
+ # with the new values.
+ #
+ # For persistence between runs, a CSV file is used with the following columns:
+ #
+ # user_id, old_value, new_value
+ #
+ # Only the encrypted values are stored in this file.
+ #
+ # As users may have their 2FA settings changed at any time, this is only
+ # guaranteed to be safe if run offline.
+ class OtpKeyRotator
+ HEADERS = %w[user_id old_value new_value].freeze
+
+ attr_reader :filename
+
+ # Create a new rotator. +filename+ is used to store values by +calculate!+,
+ # and to update the database with new and old values in +apply!+ and
+ # +rollback!+, respectively.
+ def initialize(filename)
+ @filename = filename
+ end
+
+ def rotate!(old_key:, new_key:)
+ old_key ||= Gitlab::Application.secrets.otp_key_base
+
+ raise ArgumentError.new("Old key is the same as the new key") if old_key == new_key
+ raise ArgumentError.new("New key is too short! Must be 256 bits") if new_key.size < 64
+
+ write_csv do |csv|
+ ActiveRecord::Base.transaction do
+ User.with_two_factor.in_batches do |relation|
+ rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt)
+ rows.each do |row|
+ user = %i[id ciphertext iv salt].zip(row).to_h
+ new_value = reencrypt(user, old_key, new_key)
+
+ User.where(id: user[:id]).update_all(encrypted_otp_secret: new_value)
+ csv << [user[:id], user[:ciphertext], new_value]
+ end
+ end
+ end
+ end
+ end
+
+ def rollback!
+ ActiveRecord::Base.transaction do
+ CSV.foreach(filename, headers: HEADERS, return_headers: false) do |row|
+ User.where(id: row['user_id']).update_all(encrypted_otp_secret: row['old_value'])
+ end
+ end
+ end
+
+ private
+
+ attr_reader :old_key, :new_key
+
+ def otp_secret_settings
+ @otp_secret_settings ||= User.encrypted_attributes[:otp_secret]
+ end
+
+ def reencrypt(user, old_key, new_key)
+ original = user[:ciphertext].unpack("m").join
+ opts = {
+ iv: user[:iv].unpack("m").join,
+ salt: user[:salt].unpack("m").join,
+ algorithm: otp_secret_settings[:algorithm],
+ insecure_mode: otp_secret_settings[:insecure_mode]
+ }
+
+ decrypted = Encryptor.decrypt(original, opts.merge(key: old_key))
+ encrypted = Encryptor.encrypt(decrypted, opts.merge(key: new_key))
+ [encrypted].pack("m")
+ end
+
+ def write_csv(&blk)
+ File.open(filename, "w") do |file|
+ yield CSV.new(file, headers: HEADERS, write_headers: false)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index 9ff6829cd49..10eb99fb461 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -49,6 +49,7 @@ module Gitlab
sent_notifications
services
snippets
+ system
teams
u
unicorn_test
diff --git a/lib/gitlab/route_map.rb b/lib/gitlab/route_map.rb
index 36791fae60f..877aa6e6a28 100644
--- a/lib/gitlab/route_map.rb
+++ b/lib/gitlab/route_map.rb
@@ -25,8 +25,8 @@ module Gitlab
def parse_entry(entry)
raise FormatError, 'Route map entry is not a hash' unless entry.is_a?(Hash)
- raise FormatError, 'Route map entry does not have a source key' unless entry.has_key?('source')
- raise FormatError, 'Route map entry does not have a public key' unless entry.has_key?('public')
+ raise FormatError, 'Route map entry does not have a source key' unless entry.key?('source')
+ raise FormatError, 'Route map entry does not have a public key' unless entry.key?('public')
source_pattern = entry['source']
public_path = entry['public']
diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb
index 12a385f90fd..caab8856014 100644
--- a/lib/gitlab/slash_commands/command_definition.rb
+++ b/lib/gitlab/slash_commands/command_definition.rb
@@ -48,17 +48,23 @@ module Gitlab
end
def to_h(opts)
+ context = OpenStruct.new(opts)
+
desc = description
if desc.respond_to?(:call)
- context = OpenStruct.new(opts)
desc = context.instance_exec(&desc) rescue ''
end
+ prms = params
+ if prms.respond_to?(:call)
+ prms = Array(context.instance_exec(&prms)) rescue params
+ end
+
{
name: name,
aliases: aliases,
description: desc,
- params: params
+ params: prms
}
end
diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb
index 614bafbe1b2..1b5b4566d81 100644
--- a/lib/gitlab/slash_commands/dsl.rb
+++ b/lib/gitlab/slash_commands/dsl.rb
@@ -40,8 +40,8 @@ module Gitlab
# command :command_key do |arguments|
# # Awesome code block
# end
- def params(*params)
- @params = params
+ def params(*params, &block)
+ @params = block_given? ? block : params
end
# Allows to give an explanation of what the command will do when
diff --git a/lib/gitlab/uploads_transfer.rb b/lib/gitlab/uploads_transfer.rb
index 7d0c47c5361..b5f41240529 100644
--- a/lib/gitlab/uploads_transfer.rb
+++ b/lib/gitlab/uploads_transfer.rb
@@ -1,7 +1,7 @@
module Gitlab
class UploadsTransfer < ProjectTransfer
def root_dir
- File.join(CarrierWave.root, GitlabUploader.base_dir)
+ File.join(CarrierWave.root, FileUploader.base_dir)
end
end
end
diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb
index ccb456bcc94..23af9318d1a 100644
--- a/lib/gitlab/url_builder.rb
+++ b/lib/gitlab/url_builder.rb
@@ -61,7 +61,12 @@ module Gitlab
elsif object.for_snippet?
snippet = Snippet.find(object.noteable_id)
- project_snippet_url(snippet, anchor: dom_id(object))
+
+ if snippet.is_a?(PersonalSnippet)
+ snippet_url(snippet, anchor: dom_id(object))
+ else
+ project_snippet_url(snippet, anchor: dom_id(object))
+ end
end
end
diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb
index 2e31f4462f9..2b53798e70f 100644
--- a/lib/gitlab/visibility_level.rb
+++ b/lib/gitlab/visibility_level.rb
@@ -41,9 +41,9 @@ module Gitlab
def options
{
- 'Private' => PRIVATE,
- 'Internal' => INTERNAL,
- 'Public' => PUBLIC
+ N_('VisibilityLevel|Private') => PRIVATE,
+ N_('VisibilityLevel|Internal') => INTERNAL,
+ N_('VisibilityLevel|Public') => PUBLIC
}
end
@@ -83,7 +83,7 @@ module Gitlab
end
def valid_level?(level)
- options.has_value?(level)
+ options.value?(level)
end
def level_name(level)
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index 18d8b4f4744..7f27317775c 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -129,7 +129,7 @@ module Gitlab
'MaxSessionTime' => terminal[:max_session_time]
}
}
- details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.has_key?(:ca_pem)
+ details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.key?(:ca_pem)
details
end
diff --git a/lib/rouge/lexers/math.rb b/lib/rouge/lexers/math.rb
index 80784adfd76..939b23a3421 100644
--- a/lib/rouge/lexers/math.rb
+++ b/lib/rouge/lexers/math.rb
@@ -1,21 +1,9 @@
module Rouge
module Lexers
- class Math < Lexer
+ class Math < PlainText
title "A passthrough lexer used for LaTeX input"
- desc "A boring lexer that doesn't highlight anything"
-
+ desc "PLEASE REFACTOR - this should be handled by SyntaxHighlightFilter"
tag 'math'
- mimetypes 'text/plain'
-
- default_options token: 'Text'
-
- def token
- @token ||= Token[option :token]
- end
-
- def stream_tokens(string, &b)
- yield self.token, string
- end
end
end
end
diff --git a/lib/rouge/lexers/plantuml.rb b/lib/rouge/lexers/plantuml.rb
index 7d5700b7f6d..63c461764fc 100644
--- a/lib/rouge/lexers/plantuml.rb
+++ b/lib/rouge/lexers/plantuml.rb
@@ -1,21 +1,9 @@
module Rouge
module Lexers
- class Plantuml < Lexer
+ class Plantuml < PlainText
title "A passthrough lexer used for PlantUML input"
- desc "A boring lexer that doesn't highlight anything"
-
+ desc "PLEASE REFACTOR - this should be handled by SyntaxHighlightFilter"
tag 'plantuml'
- mimetypes 'text/plain'
-
- default_options token: 'Text'
-
- def token
- @token ||= Token[option :token]
- end
-
- def stream_tokens(string, &b)
- yield self.token, string
- end
end
end
end
diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake
index fc0ccc726ed..7728c485e8d 100644
--- a/lib/tasks/gitlab/two_factor.rake
+++ b/lib/tasks/gitlab/two_factor.rake
@@ -19,5 +19,21 @@ namespace :gitlab do
puts "There are currently no users with 2FA enabled.".color(:yellow)
end
end
+
+ namespace :rotate_key do
+ def rotator
+ @rotator ||= Gitlab::OtpKeyRotator.new(ENV['filename'])
+ end
+
+ desc "Encrypt user OTP secrets with a new encryption key"
+ task apply: :environment do |t, args|
+ rotator.rotate!(old_key: ENV['old_key'], new_key: ENV['new_key'])
+ end
+
+ desc "Rollback to secrets encrypted with the old encryption key"
+ task rollback: :environment do
+ rotator.rollback!
+ end
+ end
end
end
diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake
index bc76d7edc55..50b8e331469 100644
--- a/lib/tasks/import.rake
+++ b/lib/tasks/import.rake
@@ -37,7 +37,7 @@ class GithubImport
end
def import!
- @project.import_start
+ @project.force_import_start
timings = Benchmark.measure do
Github::Import.new(@project, @options).execute
diff --git a/locale/bg/gitlab.po b/locale/bg/gitlab.po
new file mode 100644
index 00000000000..e6caf83252d
--- /dev/null
+++ b/locale/bg/gitlab.po
@@ -0,0 +1,260 @@
+# Lyubomir Vasilev <lyubomirv@abv.bg>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-05-04 19:24-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-06-05 09:40-0400\n"
+"Last-Translator: Lyubomir Vasilev <lyubomirv@abv.bg>\n"
+"Language-Team: Bulgarian\n"
+"Language: bg\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+msgid "ByAuthor|by"
+msgstr "от"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Подаване"
+msgstr[1] "Подавания"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"Анализът на циклите дава общ поглед върху това колко време е нужно на една "
+"идея да се превърне в завършена функционалност в проекта."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Програмиране"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Проблем"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Планиране"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Издаване"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Преглед и одобрение"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Подготовка за издаване"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Тестване"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Внедряване"
+msgstr[1] "Внедрявания"
+
+msgid "FirstPushedBy|First"
+msgstr "Първо"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "изпращане на промени от"
+
+msgid "From issue creation until deploy to production"
+msgstr "От създаването на проблема до внедряването в крайната версия"
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+"От прилагането на заявката за сливане до внедряването в крайната версия"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Представяме Ви анализът на циклите"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Последния %d ден"
+msgstr[1] "Последните %d дни"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Ограничено до показване на последното %d събитие"
+msgstr[1] "Ограничено до показване на последните %d събития"
+
+msgid "Median"
+msgstr "Медиана"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Нов проблем"
+msgstr[1] "Нови проблема"
+
+msgid "Not available"
+msgstr "Не е налично"
+
+msgid "Not enough data"
+msgstr "Няма достатъчно данни"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Отворен"
+
+msgid "Pipeline Health"
+msgstr "Състояние"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Етап"
+
+msgid "Read more"
+msgstr "Прочетете повече"
+
+msgid "Related Commits"
+msgstr "Свързани подавания"
+
+msgid "Related Deployed Jobs"
+msgstr "Свързани задачи за внедряване"
+
+msgid "Related Issues"
+msgstr "Свързани проблеми"
+
+msgid "Related Jobs"
+msgstr "Свързани задачи"
+
+msgid "Related Merge Requests"
+msgstr "Свързани заявки за сливане"
+
+msgid "Related Merged Requests"
+msgstr "Свързани приложени заявки за сливане"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Показване на %d събитие"
+msgstr[1] "Показване на %d събития"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"Етапът на програмиране показва времето от първото подаване до създаването на "
+"заявката за сливане. Данните ще бъдат добавени тук автоматично след като "
+"бъде създадена първата заявка за сливане."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "Съвкупността от събития добавени към данните събрани за този етап."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"Етапът на проблемите показва колко е времето от създаването на проблем до "
+"определянето на целеви етап на проекта за него, или до добавянето му в "
+"списък на дъската за проблеми. Започнете да добавяте проблеми, за да видите "
+"данните за този етап."
+
+msgid "The phase of the development lifecycle."
+msgstr "Етапът от цикъла на разработка"
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"Етапът на планиране показва колко е времето от преходната стъпка до "
+"изпращането на първото подаване. Това време ще бъде добавено автоматично "
+"след като изпратите първото си подаване."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"Етапът на издаване показва общото време, което е нужно от създаването на "
+"проблем до внедряването на кода в крайната версия."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"Етапът на преглед и одобрение показва времето от създаването на заявката за "
+"сливане до прилагането ѝ. Данните ще бъдат добавени автоматично след като "
+"приложите първата си заявка за сливане."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"Етапът на подготовка за издаване показва времето между прилагането на "
+"заявката за сливане и внедряването на кода в средата на работещата крайна "
+"версия. Данните ще бъдат добавени автоматично след като направите първото си "
+"внедряване в крайната версия."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"Етапът на тестване показва времето, което е нужно на „Gitlab CI“ да изпълни "
+"всички задачи за свързаната заявка за сливане. Данните ще бъдат добавени "
+"автоматично след като приключи изпълнените на първата Ви такава задача."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "Времето, което отнема всеки запис от данни за съответния етап."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"Стойността, която се намира в средата на последователността от наблюдавани "
+"данни. Например: медианата на 3, 5 и 9 е 5, а медианата на 3, 5, 7 и 8 е "
+"(5+7)/2 = 6."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Време преди един проблем да бъде планиран за работа"
+
+msgid "Time before an issue starts implementation"
+msgstr "Време преди работата по проблем да започне"
+
+msgid "Time between merge request creation and merge/close"
+msgstr ""
+"Време между създаване на заявка за сливане и прилагането/отхвърлянето ѝ"
+
+msgid "Time until first merge request"
+msgstr "Време преди първата заявка за сливане"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "час"
+msgstr[1] "часа"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "мин"
+msgstr[1] "мин"
+
+msgid "Time|s"
+msgstr "сек"
+
+msgid "Total Time"
+msgstr "Общо време"
+
+msgid "Total test time for all commits/merges"
+msgstr "Общо време за тестване на всички подавания/сливания"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "Искате ли да видите данните? Помолете администратор за достъп."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Няма достатъчно данни за този етап."
+
+msgid "You need permission."
+msgstr "Нуждаете се от разрешение."
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "ден"
+msgstr[1] "дни"
+
diff --git a/locale/bg/gitlab.po.time_stamp b/locale/bg/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/bg/gitlab.po.time_stamp
diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po
index 1c44ed4b77c..f9c14210958 100644
--- a/locale/de/gitlab.po
+++ b/locale/de/gitlab.po
Binary files differ
diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po
index a43bafbbe28..ed0ccc61e67 100644
--- a/locale/en/gitlab.po
+++ b/locale/en/gitlab.po
@@ -17,14 +17,169 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"\n"
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Archived project! Repository is read-only"
+msgstr ""
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr ""
+msgid "CI configuration"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "CiStatusLabel|canceled"
+msgstr ""
+
+msgid "CiStatusLabel|created"
+msgstr ""
+
+msgid "CiStatusLabel|failed"
+msgstr ""
+
+msgid "CiStatusLabel|manual action"
+msgstr ""
+
+msgid "CiStatusLabel|passed"
+msgstr ""
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr ""
+
+msgid "CiStatusLabel|pending"
+msgstr ""
+
+msgid "CiStatusLabel|skipped"
+msgstr ""
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr ""
+
+msgid "CiStatusText|blocked"
+msgstr ""
+
+msgid "CiStatusText|canceled"
+msgstr ""
+
+msgid "CiStatusText|created"
+msgstr ""
+
+msgid "CiStatusText|failed"
+msgstr ""
+
+msgid "CiStatusText|manual"
+msgstr ""
+
+msgid "CiStatusText|passed"
+msgstr ""
+
+msgid "CiStatusText|pending"
+msgstr ""
+
+msgid "CiStatusText|skipped"
+msgstr ""
+
+msgid "CiStatus|running"
+msgstr ""
+
msgid "Commit"
msgid_plural "Commits"
msgstr[0] ""
msgstr[1] ""
+msgid "CommitMessage|Add %{file_name}"
+msgstr ""
+
+msgid "Commits"
+msgstr ""
+
+msgid "Commits|History"
+msgstr ""
+
+msgid "Compare"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty bare repository"
+msgstr ""
+
+msgid "Create merge request"
+msgstr ""
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Custom notification events"
+msgstr ""
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr ""
+
+msgid "Cycle Analytics"
+msgstr ""
+
msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
msgstr ""
@@ -49,31 +204,130 @@ msgstr ""
msgid "CycleAnalyticsStage|Test"
msgstr ""
+msgid "Delete"
+msgstr ""
+
msgid "Deploy"
msgid_plural "Deploys"
msgstr[0] ""
msgstr[1] ""
+msgid "Description"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Download tar"
+msgstr ""
+
+msgid "Download tar.bz2"
+msgstr ""
+
+msgid "Download tar.gz"
+msgstr ""
+
+msgid "Download zip"
+msgstr ""
+
+msgid "DownloadArtifacts|Download"
+msgstr ""
+
+msgid "DownloadSource|Download"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Filter"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr ""
msgid "FirstPushedBy|pushed by"
msgstr ""
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "Forks"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr ""
msgid "From merge request merge until deploy to production"
msgstr ""
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Home"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
msgid "Introducing Cycle Analytics"
msgstr ""
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] ""
msgstr[1] ""
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last Update"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
msgid "Limited to showing %d event at most"
msgid_plural "Limited to showing %d events at most"
msgstr[0] ""
@@ -82,29 +336,209 @@ msgstr[1] ""
msgid "Median"
msgstr ""
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] ""
msgstr[1] ""
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+msgid "New issue"
+msgstr ""
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
msgid "Not available"
msgstr ""
msgid "Not enough data"
msgstr ""
+msgid "Notification events"
+msgstr ""
+
+msgid "NotificationEvent|Close issue"
+msgstr ""
+
+msgid "NotificationEvent|Close merge request"
+msgstr ""
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr ""
+
+msgid "NotificationEvent|Merge merge request"
+msgstr ""
+
+msgid "NotificationEvent|New issue"
+msgstr ""
+
+msgid "NotificationEvent|New merge request"
+msgstr ""
+
+msgid "NotificationEvent|New note"
+msgstr ""
+
+msgid "NotificationEvent|Reassign issue"
+msgstr ""
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr ""
+
+msgid "NotificationEvent|Reopen issue"
+msgstr ""
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr ""
+
+msgid "NotificationLevel|Custom"
+msgstr ""
+
+msgid "NotificationLevel|Disabled"
+msgstr ""
+
+msgid "NotificationLevel|Global"
+msgstr ""
+
+msgid "NotificationLevel|On mention"
+msgstr ""
+
+msgid "NotificationLevel|Participate"
+msgstr ""
+
+msgid "NotificationLevel|Watch"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr ""
+msgid "Owner"
+msgstr ""
+
msgid "Pipeline Health"
msgstr ""
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr ""
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr ""
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project export could not be deleted."
+msgstr ""
+
+msgid "Project export has been deleted."
+msgstr ""
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr ""
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+
+msgid "Project home"
+msgstr ""
+
+msgid "ProjectFeature|Disabled"
+msgstr ""
+
+msgid "ProjectFeature|Everyone with access"
+msgstr ""
+
+msgid "ProjectFeature|Only team members"
+msgstr ""
+
+msgid "ProjectFileTree|Name"
+msgstr ""
+
+msgid "ProjectLastActivity|Never"
+msgstr ""
+
msgid "ProjectLifecycle|Stage"
msgstr ""
+msgid "ProjectNetworkGraph|Graph"
+msgstr ""
+
msgid "Read more"
msgstr ""
+msgid "Readme"
+msgstr ""
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
msgid "Related Commits"
msgstr ""
@@ -123,17 +557,82 @@ msgstr ""
msgid "Related Merged Requests"
msgstr ""
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Request Access"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}"
+msgstr ""
+
+msgid "Set up CI"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up auto deploy"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] ""
msgstr[1] ""
+msgid "Source code"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
msgstr ""
msgid "The collection of events added to the data gathered for that stage."
msgstr ""
+msgid "The fork relationship has been removed."
+msgstr ""
+
msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
msgstr ""
@@ -146,6 +645,15 @@ msgstr ""
msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
msgstr ""
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+
+msgid "The repository for this project does not exist."
+msgstr ""
+
msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
msgstr ""
@@ -161,6 +669,9 @@ msgstr ""
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr ""
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr ""
@@ -173,6 +684,129 @@ msgstr ""
msgid "Time until first merge request"
msgstr ""
+msgid "Timeago|%s days ago"
+msgstr ""
+
+msgid "Timeago|%s days remaining"
+msgstr ""
+
+msgid "Timeago|%s hours remaining"
+msgstr ""
+
+msgid "Timeago|%s minutes ago"
+msgstr ""
+
+msgid "Timeago|%s minutes remaining"
+msgstr ""
+
+msgid "Timeago|%s months ago"
+msgstr ""
+
+msgid "Timeago|%s months remaining"
+msgstr ""
+
+msgid "Timeago|%s seconds remaining"
+msgstr ""
+
+msgid "Timeago|%s weeks ago"
+msgstr ""
+
+msgid "Timeago|%s weeks remaining"
+msgstr ""
+
+msgid "Timeago|%s years ago"
+msgstr ""
+
+msgid "Timeago|%s years remaining"
+msgstr ""
+
+msgid "Timeago|1 day remaining"
+msgstr ""
+
+msgid "Timeago|1 hour remaining"
+msgstr ""
+
+msgid "Timeago|1 minute remaining"
+msgstr ""
+
+msgid "Timeago|1 month remaining"
+msgstr ""
+
+msgid "Timeago|1 week remaining"
+msgstr ""
+
+msgid "Timeago|1 year remaining"
+msgstr ""
+
+msgid "Timeago|Past due"
+msgstr ""
+
+msgid "Timeago|a day ago"
+msgstr ""
+
+msgid "Timeago|a month ago"
+msgstr ""
+
+msgid "Timeago|a week ago"
+msgstr ""
+
+msgid "Timeago|a while"
+msgstr ""
+
+msgid "Timeago|a year ago"
+msgstr ""
+
+msgid "Timeago|about %s hours ago"
+msgstr ""
+
+msgid "Timeago|about a minute ago"
+msgstr ""
+
+msgid "Timeago|about an hour ago"
+msgstr ""
+
+msgid "Timeago|in %s days"
+msgstr ""
+
+msgid "Timeago|in %s hours"
+msgstr ""
+
+msgid "Timeago|in %s minutes"
+msgstr ""
+
+msgid "Timeago|in %s months"
+msgstr ""
+
+msgid "Timeago|in %s seconds"
+msgstr ""
+
+msgid "Timeago|in %s weeks"
+msgstr ""
+
+msgid "Timeago|in %s years"
+msgstr ""
+
+msgid "Timeago|in 1 day"
+msgstr ""
+
+msgid "Timeago|in 1 hour"
+msgstr ""
+
+msgid "Timeago|in 1 minute"
+msgstr ""
+
+msgid "Timeago|in 1 month"
+msgstr ""
+
+msgid "Timeago|in 1 week"
+msgstr ""
+
+msgid "Timeago|in 1 year"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
msgid "Time|hr"
msgid_plural "Time|hrs"
msgstr[0] ""
@@ -192,16 +826,88 @@ msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
+msgid "Unstar"
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "VisibilityLevel|Internal"
+msgstr ""
+
+msgid "VisibilityLevel|Private"
+msgstr ""
+
+msgid "VisibilityLevel|Public"
+msgstr ""
+
msgid "Want to see the data? Please ask an administrator for access."
msgstr ""
msgid "We don't have enough data to show this stage."
msgstr ""
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid ""
+"You are going to remove %{project_name_with_namespace}.\n"
+"Removed project CANNOT be restored!\n"
+"Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
msgid "You need permission."
msgstr ""
+msgid "You will not get any notifications via email"
+msgstr ""
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr ""
+
+msgid "You will receive notifications for any activity"
+msgstr ""
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+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 name"
+msgstr ""
+
+msgid "committed"
+msgstr ""
+
msgid "day"
msgid_plural "days"
msgstr[0] ""
msgstr[1] ""
+
+msgid "notification emails"
+msgstr ""
diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po
index b61846b9c7d..1bbc413e24c 100644
--- a/locale/es/gitlab.po
+++ b/locale/es/gitlab.po
Binary files differ
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3967d40ea9e..c967b720885 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-05-04 19:24-0500\n"
-"PO-Revision-Date: 2017-05-04 19:24-0500\n"
+"POT-Creation-Date: 2017-06-08 09:42+0200\n"
+"PO-Revision-Date: 2017-06-08 09:42+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -18,14 +18,169 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Archived project! Repository is read-only"
+msgstr ""
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr ""
+msgid "CI configuration"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "CiStatusLabel|canceled"
+msgstr ""
+
+msgid "CiStatusLabel|created"
+msgstr ""
+
+msgid "CiStatusLabel|failed"
+msgstr ""
+
+msgid "CiStatusLabel|manual action"
+msgstr ""
+
+msgid "CiStatusLabel|passed"
+msgstr ""
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr ""
+
+msgid "CiStatusLabel|pending"
+msgstr ""
+
+msgid "CiStatusLabel|skipped"
+msgstr ""
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr ""
+
+msgid "CiStatusText|blocked"
+msgstr ""
+
+msgid "CiStatusText|canceled"
+msgstr ""
+
+msgid "CiStatusText|created"
+msgstr ""
+
+msgid "CiStatusText|failed"
+msgstr ""
+
+msgid "CiStatusText|manual"
+msgstr ""
+
+msgid "CiStatusText|passed"
+msgstr ""
+
+msgid "CiStatusText|pending"
+msgstr ""
+
+msgid "CiStatusText|skipped"
+msgstr ""
+
+msgid "CiStatus|running"
+msgstr ""
+
msgid "Commit"
msgid_plural "Commits"
msgstr[0] ""
msgstr[1] ""
+msgid "CommitMessage|Add %{file_name}"
+msgstr ""
+
+msgid "Commits"
+msgstr ""
+
+msgid "Commits|History"
+msgstr ""
+
+msgid "Compare"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty bare repository"
+msgstr ""
+
+msgid "Create merge request"
+msgstr ""
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Custom notification events"
+msgstr ""
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr ""
+
+msgid "Cycle Analytics"
+msgstr ""
+
msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
msgstr ""
@@ -50,31 +205,130 @@ msgstr ""
msgid "CycleAnalyticsStage|Test"
msgstr ""
+msgid "Delete"
+msgstr ""
+
msgid "Deploy"
msgid_plural "Deploys"
msgstr[0] ""
msgstr[1] ""
+msgid "Description"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Download tar"
+msgstr ""
+
+msgid "Download tar.bz2"
+msgstr ""
+
+msgid "Download tar.gz"
+msgstr ""
+
+msgid "Download zip"
+msgstr ""
+
+msgid "DownloadArtifacts|Download"
+msgstr ""
+
+msgid "DownloadSource|Download"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Filter"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr ""
msgid "FirstPushedBy|pushed by"
msgstr ""
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "Forks"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr ""
msgid "From merge request merge until deploy to production"
msgstr ""
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Home"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
msgid "Introducing Cycle Analytics"
msgstr ""
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] ""
msgstr[1] ""
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last Update"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
msgid "Limited to showing %d event at most"
msgid_plural "Limited to showing %d events at most"
msgstr[0] ""
@@ -83,29 +337,209 @@ msgstr[1] ""
msgid "Median"
msgstr ""
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] ""
msgstr[1] ""
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+msgid "New issue"
+msgstr ""
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
msgid "Not available"
msgstr ""
msgid "Not enough data"
msgstr ""
+msgid "Notification events"
+msgstr ""
+
+msgid "NotificationEvent|Close issue"
+msgstr ""
+
+msgid "NotificationEvent|Close merge request"
+msgstr ""
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr ""
+
+msgid "NotificationEvent|Merge merge request"
+msgstr ""
+
+msgid "NotificationEvent|New issue"
+msgstr ""
+
+msgid "NotificationEvent|New merge request"
+msgstr ""
+
+msgid "NotificationEvent|New note"
+msgstr ""
+
+msgid "NotificationEvent|Reassign issue"
+msgstr ""
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr ""
+
+msgid "NotificationEvent|Reopen issue"
+msgstr ""
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr ""
+
+msgid "NotificationLevel|Custom"
+msgstr ""
+
+msgid "NotificationLevel|Disabled"
+msgstr ""
+
+msgid "NotificationLevel|Global"
+msgstr ""
+
+msgid "NotificationLevel|On mention"
+msgstr ""
+
+msgid "NotificationLevel|Participate"
+msgstr ""
+
+msgid "NotificationLevel|Watch"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr ""
+msgid "Owner"
+msgstr ""
+
msgid "Pipeline Health"
msgstr ""
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr ""
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr ""
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project export could not be deleted."
+msgstr ""
+
+msgid "Project export has been deleted."
+msgstr ""
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr ""
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+
+msgid "Project home"
+msgstr ""
+
+msgid "ProjectFeature|Disabled"
+msgstr ""
+
+msgid "ProjectFeature|Everyone with access"
+msgstr ""
+
+msgid "ProjectFeature|Only team members"
+msgstr ""
+
+msgid "ProjectFileTree|Name"
+msgstr ""
+
+msgid "ProjectLastActivity|Never"
+msgstr ""
+
msgid "ProjectLifecycle|Stage"
msgstr ""
+msgid "ProjectNetworkGraph|Graph"
+msgstr ""
+
msgid "Read more"
msgstr ""
+msgid "Readme"
+msgstr ""
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
msgid "Related Commits"
msgstr ""
@@ -124,17 +558,82 @@ msgstr ""
msgid "Related Merged Requests"
msgstr ""
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Request Access"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}"
+msgstr ""
+
+msgid "Set up CI"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up auto deploy"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] ""
msgstr[1] ""
+msgid "Source code"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
msgstr ""
msgid "The collection of events added to the data gathered for that stage."
msgstr ""
+msgid "The fork relationship has been removed."
+msgstr ""
+
msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
msgstr ""
@@ -147,6 +646,15 @@ msgstr ""
msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
msgstr ""
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+
+msgid "The repository for this project does not exist."
+msgstr ""
+
msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
msgstr ""
@@ -162,6 +670,9 @@ msgstr ""
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr ""
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr ""
@@ -174,6 +685,129 @@ msgstr ""
msgid "Time until first merge request"
msgstr ""
+msgid "Timeago|%s days ago"
+msgstr ""
+
+msgid "Timeago|%s days remaining"
+msgstr ""
+
+msgid "Timeago|%s hours remaining"
+msgstr ""
+
+msgid "Timeago|%s minutes ago"
+msgstr ""
+
+msgid "Timeago|%s minutes remaining"
+msgstr ""
+
+msgid "Timeago|%s months ago"
+msgstr ""
+
+msgid "Timeago|%s months remaining"
+msgstr ""
+
+msgid "Timeago|%s seconds remaining"
+msgstr ""
+
+msgid "Timeago|%s weeks ago"
+msgstr ""
+
+msgid "Timeago|%s weeks remaining"
+msgstr ""
+
+msgid "Timeago|%s years ago"
+msgstr ""
+
+msgid "Timeago|%s years remaining"
+msgstr ""
+
+msgid "Timeago|1 day remaining"
+msgstr ""
+
+msgid "Timeago|1 hour remaining"
+msgstr ""
+
+msgid "Timeago|1 minute remaining"
+msgstr ""
+
+msgid "Timeago|1 month remaining"
+msgstr ""
+
+msgid "Timeago|1 week remaining"
+msgstr ""
+
+msgid "Timeago|1 year remaining"
+msgstr ""
+
+msgid "Timeago|Past due"
+msgstr ""
+
+msgid "Timeago|a day ago"
+msgstr ""
+
+msgid "Timeago|a month ago"
+msgstr ""
+
+msgid "Timeago|a week ago"
+msgstr ""
+
+msgid "Timeago|a while"
+msgstr ""
+
+msgid "Timeago|a year ago"
+msgstr ""
+
+msgid "Timeago|about %s hours ago"
+msgstr ""
+
+msgid "Timeago|about a minute ago"
+msgstr ""
+
+msgid "Timeago|about an hour ago"
+msgstr ""
+
+msgid "Timeago|in %s days"
+msgstr ""
+
+msgid "Timeago|in %s hours"
+msgstr ""
+
+msgid "Timeago|in %s minutes"
+msgstr ""
+
+msgid "Timeago|in %s months"
+msgstr ""
+
+msgid "Timeago|in %s seconds"
+msgstr ""
+
+msgid "Timeago|in %s weeks"
+msgstr ""
+
+msgid "Timeago|in %s years"
+msgstr ""
+
+msgid "Timeago|in 1 day"
+msgstr ""
+
+msgid "Timeago|in 1 hour"
+msgstr ""
+
+msgid "Timeago|in 1 minute"
+msgstr ""
+
+msgid "Timeago|in 1 month"
+msgstr ""
+
+msgid "Timeago|in 1 week"
+msgstr ""
+
+msgid "Timeago|in 1 year"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
msgid "Time|hr"
msgid_plural "Time|hrs"
msgstr[0] ""
@@ -193,16 +827,88 @@ msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
+msgid "Unstar"
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "VisibilityLevel|Internal"
+msgstr ""
+
+msgid "VisibilityLevel|Private"
+msgstr ""
+
+msgid "VisibilityLevel|Public"
+msgstr ""
+
msgid "Want to see the data? Please ask an administrator for access."
msgstr ""
msgid "We don't have enough data to show this stage."
msgstr ""
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid ""
+"You are going to remove %{project_name_with_namespace}.\n"
+"Removed project CANNOT be restored!\n"
+"Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
msgid "You need permission."
msgstr ""
+msgid "You will not get any notifications via email"
+msgstr ""
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr ""
+
+msgid "You will receive notifications for any activity"
+msgstr ""
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+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 name"
+msgstr ""
+
+msgid "committed"
+msgstr ""
+
msgid "day"
msgid_plural "days"
msgstr[0] ""
msgstr[1] ""
+
+msgid "notification emails"
+msgstr ""
diff --git a/locale/pt_BR/gitlab.po b/locale/pt_BR/gitlab.po
new file mode 100644
index 00000000000..5ad41f92b64
--- /dev/null
+++ b/locale/pt_BR/gitlab.po
@@ -0,0 +1,260 @@
+# Alexandre Alencar <alexandre.alencar@gmail.com>, 2017. #zanata
+# Fabio Beneditto <fabiobeneditto@gmail.com>, 2017. #zanata
+# Leandro Nunes dos Santos <leandronunes@gmail.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-05-04 19:24-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-06-05 03:29-0400\n"
+"Last-Translator: Alexandre Alencar <alexandre.alencar@gmail.com>\n"
+"Language-Team: Portuguese (Brazil)\n"
+"Language: pt-BR\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+msgid "ByAuthor|by"
+msgstr "por"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Commit"
+msgstr[1] "Commits"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"A Análise de Ciclo fornece uma visão geral de quanto tempo uma ideia demora "
+"para ir para produção em seu projeto."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Código"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Tarefa"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Plano"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Produção"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Revisão"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Homologação"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Teste"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Implantação"
+msgstr[1] "Implantações"
+
+msgid "FirstPushedBy|First"
+msgstr "Primeiro"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "publicado por"
+
+msgid "From issue creation until deploy to production"
+msgstr "Da criação de tarefas até a implantação para a produção"
+
+msgid "From merge request merge until deploy to production"
+msgstr "Da incorporação do merge request até a implantação em produção"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Apresentando a Análise de Ciclo"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Último %d dia"
+msgstr[1] "Últimos %d dias"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limitado a mostrar %d evento no máximo"
+msgstr[1] "Limitado a mostrar %d eventos no máximo"
+
+msgid "Median"
+msgstr "Mediana"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nova Tarefa"
+msgstr[1] "Novas Tarefas"
+
+msgid "Not available"
+msgstr "Não disponível"
+
+msgid "Not enough data"
+msgstr "Dados insuficientes"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Aberto"
+
+msgid "Pipeline Health"
+msgstr "Saúde da Pipeline"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Etapa"
+
+msgid "Read more"
+msgstr "Ler mais"
+
+msgid "Related Commits"
+msgstr "Commits Relacionados"
+
+msgid "Related Deployed Jobs"
+msgstr "Jobs Relacionados Incorporados"
+
+msgid "Related Issues"
+msgstr "Tarefas Relacionadas"
+
+msgid "Related Jobs"
+msgstr "Jobs Relacionados"
+
+msgid "Related Merge Requests"
+msgstr "Merge Requests Relacionados"
+
+msgid "Related Merged Requests"
+msgstr "Merge Requests Relacionados"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Mostrando %d evento"
+msgstr[1] "Mostrando %d eventos"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"O estágio de codificação mostra o tempo desde o primeiro commit até a "
+"criação do merge request. \n"
+"Os dados serão automaticamente adicionados aqui uma vez que você tenha "
+"criado seu primeiro merge request."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr ""
+"A coleção de eventos adicionados aos dados coletados para esse estágio."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"O estágio em questão mostra o tempo que leva desde a criação de uma tarefa "
+"até a sua assinatura para um milestone, ou a sua adição para a lista no seu "
+"Painel de Tarefas. Comece a criar tarefas para ver dados para esta etapa."
+
+msgid "The phase of the development lifecycle."
+msgstr "A fase do ciclo de vida do desenvolvimento."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"A fase de planejamento mostra o tempo do passo anterior até empurrar o seu "
+"primeiro commit. Este tempo será adicionado automaticamente assim que você "
+"realizar seu primeiro commit."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"O estágio de produção mostra o tempo total que leva entre criar uma tarefa e "
+"implantar o código na produção. Os dados serão adicionados automaticamente "
+"até que você complete todo o ciclo de produção."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"A etapa de revisão mostra o tempo de criação de um merge request até que o "
+"merge seja feito. Os dados serão automaticamente adicionados depois que você "
+"fizer seu primeiro merge request."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"O estágio de estágio mostra o tempo entre a fusão do MR e o código de "
+"implantação para o ambiente de produção. Os dados serão automaticamente "
+"adicionados depois de implantar na produção pela primeira vez."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"A fase de teste mostra o tempo que o GitLab CI leva para executar cada "
+"pipeline para o merge request relacionado. Os dados serão automaticamente "
+"adicionados após a conclusão do primeiro pipeline."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "O tempo necessário para cada entrada de dados reunida por essa etapa."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"O valor situado no ponto médio de uma série de valores observados. Ex., "
+"entre 3, 5, 9, a mediana é 5. Entre 3, 5, 7, 8, a mediana é (5 + 7) / 2 = 6."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Tempo até que uma tarefa seja planejada"
+
+msgid "Time before an issue starts implementation"
+msgstr "Tempo até que uma tarefa comece a ser implementada"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Tempo entre a criação do merge request e o merge/fechamento"
+
+msgid "Time until first merge request"
+msgstr "Tempo até o primeiro merge request"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "h"
+msgstr[1] "hs"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "mins"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Tempo Total"
+
+msgid "Total test time for all commits/merges"
+msgstr "Tempo de teste total para todos os commits/merges"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "Precisa visualizar os dados? Solicite acesso ao administrador."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Não temos dados suficientes para mostrar esta fase."
+
+msgid "You need permission."
+msgstr "Você precisa de permissão."
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "dia"
+msgstr[1] "dias"
+
diff --git a/locale/pt_BR/gitlab.po.time_stamp b/locale/pt_BR/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/pt_BR/gitlab.po.time_stamp
diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po
new file mode 100644
index 00000000000..176dd68b52f
--- /dev/null
+++ b/locale/zh_CN/gitlab.po
@@ -0,0 +1,916 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-05-04 19:24-0500\n"
+"Last-Translator: HuangTao <htve@outlook.com>, 2017\n"
+"Language-Team: Chinese (China) (https://www.transifex.com/gitlab-zh/teams/7517"
+"7/zh_CN/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_CN\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Archived project! Repository is read-only"
+msgstr ""
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "CI configuration"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "CiStatusLabel|canceled"
+msgstr ""
+
+msgid "CiStatusLabel|created"
+msgstr ""
+
+msgid "CiStatusLabel|failed"
+msgstr ""
+
+msgid "CiStatusLabel|manual action"
+msgstr ""
+
+msgid "CiStatusLabel|passed"
+msgstr ""
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr ""
+
+msgid "CiStatusLabel|pending"
+msgstr ""
+
+msgid "CiStatusLabel|skipped"
+msgstr ""
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr ""
+
+msgid "CiStatusText|blocked"
+msgstr ""
+
+msgid "CiStatusText|canceled"
+msgstr ""
+
+msgid "CiStatusText|created"
+msgstr ""
+
+msgid "CiStatusText|failed"
+msgstr ""
+
+msgid "CiStatusText|manual"
+msgstr ""
+
+msgid "CiStatusText|passed"
+msgstr ""
+
+msgid "CiStatusText|pending"
+msgstr ""
+
+msgid "CiStatusText|skipped"
+msgstr ""
+
+msgid "CiStatus|running"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "提交"
+
+#, fuzzy
+msgid "CommitMessage|Add %{file_name}"
+msgstr "提交"
+
+#, fuzzy
+msgid "Commits"
+msgstr "提交"
+
+#, fuzzy
+msgid "Commits|History"
+msgstr "提交"
+
+msgid "Compare"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty bare repository"
+msgstr ""
+
+#, fuzzy
+msgid "Create merge request"
+msgstr "相关的合并请求"
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Custom notification events"
+msgstr ""
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr ""
+
+msgid "Cycle Analytics"
+msgstr ""
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr "周期分析概述了项目从想法到产品实现的各阶段所需的时间。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "编码"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "议题"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "计划"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "生产"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "评审"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "预发布"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "测试"
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "Description"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Download tar"
+msgstr ""
+
+msgid "Download tar.bz2"
+msgstr ""
+
+msgid "Download tar.gz"
+msgstr ""
+
+msgid "Download zip"
+msgstr ""
+
+msgid "DownloadArtifacts|Download"
+msgstr ""
+
+msgid "DownloadSource|Download"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Filter"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr "首次推送"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推送者:"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "Forks"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr "从创建议题到部署至生产环境"
+
+msgid "From merge request merge until deploy to production"
+msgstr "从合并请求被合并后到部署至生产环境"
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Home"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr "周期分析简介"
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最后 %d 天"
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last Update"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多显示 %d 个事件"
+
+msgid "Median"
+msgstr "中位数"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新议题"
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+#, fuzzy
+msgid "New issue"
+msgstr "新议题"
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "Not available"
+msgstr "数据不足"
+
+msgid "Not enough data"
+msgstr "数据不足"
+
+msgid "Notification events"
+msgstr ""
+
+msgid "NotificationEvent|Close issue"
+msgstr ""
+
+msgid "NotificationEvent|Close merge request"
+msgstr ""
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr ""
+
+msgid "NotificationEvent|Merge merge request"
+msgstr ""
+
+msgid "NotificationEvent|New issue"
+msgstr ""
+
+msgid "NotificationEvent|New merge request"
+msgstr ""
+
+msgid "NotificationEvent|New note"
+msgstr ""
+
+msgid "NotificationEvent|Reassign issue"
+msgstr ""
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr ""
+
+msgid "NotificationEvent|Reopen issue"
+msgstr ""
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr ""
+
+msgid "NotificationLevel|Custom"
+msgstr ""
+
+msgid "NotificationLevel|Disabled"
+msgstr ""
+
+msgid "NotificationLevel|Global"
+msgstr ""
+
+msgid "NotificationLevel|On mention"
+msgstr ""
+
+msgid "NotificationLevel|Participate"
+msgstr ""
+
+msgid "NotificationLevel|Watch"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "开始于"
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr "流水线健康指标"
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr ""
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr ""
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project export could not be deleted."
+msgstr ""
+
+msgid "Project export has been deleted."
+msgstr ""
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr ""
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+
+msgid "Project home"
+msgstr ""
+
+msgid "ProjectFeature|Disabled"
+msgstr ""
+
+msgid "ProjectFeature|Everyone with access"
+msgstr ""
+
+msgid "ProjectFeature|Only team members"
+msgstr ""
+
+msgid "ProjectFileTree|Name"
+msgstr ""
+
+msgid "ProjectLastActivity|Never"
+msgstr ""
+
+msgid "ProjectLifecycle|Stage"
+msgstr "项目生命周期"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr ""
+
+msgid "Read more"
+msgstr "了解更多"
+
+#, fuzzy
+msgid "Readme"
+msgstr "了解更多"
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Related Commits"
+msgstr "相关的提交"
+
+msgid "Related Deployed Jobs"
+msgstr "相关的部署作业"
+
+msgid "Related Issues"
+msgstr "相关的议题"
+
+msgid "Related Jobs"
+msgstr "相关的作业"
+
+msgid "Related Merge Requests"
+msgstr "相关的合并请求"
+
+msgid "Related Merged Requests"
+msgstr "相关已合并的合并请求"
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Request Access"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}"
+msgstr ""
+
+msgid "Set up CI"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up auto deploy"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "显示 %d 个事件"
+
+msgid "Source code"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr "编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "与该阶段相关的事件。"
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr "议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"
+
+msgid "The phase of the development lifecycle."
+msgstr "项目生命周期中的各个阶段。"
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr "计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr "生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"
+
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+
+msgid "The repository for this project does not exist."
+msgstr ""
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr "评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr "预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr "测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "该阶段每条数据所花的时间"
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr "中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "Time before an issue gets scheduled"
+msgstr "议题被列入日程表的时间"
+
+msgid "Time before an issue starts implementation"
+msgstr "开始进行编码前的时间"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "从创建合并请求到被合并或关闭的时间"
+
+msgid "Time until first merge request"
+msgstr "创建第一个合并请求之前的时间"
+
+#, fuzzy
+msgid "Timeago|%s days ago"
+msgstr "天"
+
+#, fuzzy
+msgid "Timeago|%s days remaining"
+msgstr "天"
+
+msgid "Timeago|%s hours remaining"
+msgstr ""
+
+msgid "Timeago|%s minutes ago"
+msgstr ""
+
+msgid "Timeago|%s minutes remaining"
+msgstr ""
+
+msgid "Timeago|%s months ago"
+msgstr ""
+
+msgid "Timeago|%s months remaining"
+msgstr ""
+
+msgid "Timeago|%s seconds remaining"
+msgstr ""
+
+msgid "Timeago|%s weeks ago"
+msgstr ""
+
+msgid "Timeago|%s weeks remaining"
+msgstr ""
+
+msgid "Timeago|%s years ago"
+msgstr ""
+
+msgid "Timeago|%s years remaining"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|1 day remaining"
+msgstr "天"
+
+msgid "Timeago|1 hour remaining"
+msgstr ""
+
+msgid "Timeago|1 minute remaining"
+msgstr ""
+
+msgid "Timeago|1 month remaining"
+msgstr ""
+
+msgid "Timeago|1 week remaining"
+msgstr ""
+
+msgid "Timeago|1 year remaining"
+msgstr ""
+
+msgid "Timeago|Past due"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|a day ago"
+msgstr "天"
+
+msgid "Timeago|a month ago"
+msgstr ""
+
+msgid "Timeago|a week ago"
+msgstr ""
+
+msgid "Timeago|a while"
+msgstr ""
+
+msgid "Timeago|a year ago"
+msgstr ""
+
+msgid "Timeago|about %s hours ago"
+msgstr ""
+
+msgid "Timeago|about a minute ago"
+msgstr ""
+
+msgid "Timeago|about an hour ago"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|in %s days"
+msgstr "天"
+
+msgid "Timeago|in %s hours"
+msgstr ""
+
+msgid "Timeago|in %s minutes"
+msgstr ""
+
+msgid "Timeago|in %s months"
+msgstr ""
+
+msgid "Timeago|in %s seconds"
+msgstr ""
+
+msgid "Timeago|in %s weeks"
+msgstr ""
+
+msgid "Timeago|in %s years"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|in 1 day"
+msgstr "天"
+
+msgid "Timeago|in 1 hour"
+msgstr ""
+
+msgid "Timeago|in 1 minute"
+msgstr ""
+
+msgid "Timeago|in 1 month"
+msgstr ""
+
+msgid "Timeago|in 1 week"
+msgstr ""
+
+msgid "Timeago|in 1 year"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "小时"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分钟"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "总时间"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有提交和合并的总测试时间"
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "VisibilityLevel|Internal"
+msgstr ""
+
+msgid "VisibilityLevel|Private"
+msgstr ""
+
+msgid "VisibilityLevel|Public"
+msgstr ""
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "权限不足。如需查看相关数据,请向管理员申请权限。"
+
+msgid "We don't have enough data to show this stage."
+msgstr "该阶段的数据不足,无法显示。"
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid ""
+"You are going to remove %{project_name_with_namespace}.\n"
+"Removed project CANNOT be restored!\n"
+"Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need permission."
+msgstr "您需要相关的权限。"
+
+msgid "You will not get any notifications via email"
+msgstr ""
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr ""
+
+msgid "You will receive notifications for any activity"
+msgstr ""
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+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 name"
+msgstr ""
+
+msgid "committed"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
+
+msgid "notification emails"
+msgstr ""
diff --git a/locale/zh_CN/gitlab.po.time_stamp b/locale/zh_CN/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_CN/gitlab.po.time_stamp
diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po
new file mode 100644
index 00000000000..311d44afb42
--- /dev/null
+++ b/locale/zh_HK/gitlab.po
@@ -0,0 +1,916 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-05-04 19:24-0500\n"
+"Last-Translator: HuangTao <htve@outlook.com>, 2017\n"
+"Language-Team: Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/"
+"75177/zh_HK/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_HK\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Archived project! Repository is read-only"
+msgstr ""
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "CI configuration"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "CiStatusLabel|canceled"
+msgstr ""
+
+msgid "CiStatusLabel|created"
+msgstr ""
+
+msgid "CiStatusLabel|failed"
+msgstr ""
+
+msgid "CiStatusLabel|manual action"
+msgstr ""
+
+msgid "CiStatusLabel|passed"
+msgstr ""
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr ""
+
+msgid "CiStatusLabel|pending"
+msgstr ""
+
+msgid "CiStatusLabel|skipped"
+msgstr ""
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr ""
+
+msgid "CiStatusText|blocked"
+msgstr ""
+
+msgid "CiStatusText|canceled"
+msgstr ""
+
+msgid "CiStatusText|created"
+msgstr ""
+
+msgid "CiStatusText|failed"
+msgstr ""
+
+msgid "CiStatusText|manual"
+msgstr ""
+
+msgid "CiStatusText|passed"
+msgstr ""
+
+msgid "CiStatusText|pending"
+msgstr ""
+
+msgid "CiStatusText|skipped"
+msgstr ""
+
+msgid "CiStatus|running"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "提交"
+
+#, fuzzy
+msgid "CommitMessage|Add %{file_name}"
+msgstr "提交"
+
+#, fuzzy
+msgid "Commits"
+msgstr "提交"
+
+#, fuzzy
+msgid "Commits|History"
+msgstr "提交"
+
+msgid "Compare"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty bare repository"
+msgstr ""
+
+#, fuzzy
+msgid "Create merge request"
+msgstr "相關的合併請求"
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Custom notification events"
+msgstr ""
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr ""
+
+msgid "Cycle Analytics"
+msgstr ""
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr "週期分析概述了項目從想法到產品實現的各階段所需的時間。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "編碼"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "議題"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "計劃"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "生產"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "評審"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "預發布"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "測試"
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "Description"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Download tar"
+msgstr ""
+
+msgid "Download tar.bz2"
+msgstr ""
+
+msgid "Download tar.gz"
+msgstr ""
+
+msgid "Download zip"
+msgstr ""
+
+msgid "DownloadArtifacts|Download"
+msgstr ""
+
+msgid "DownloadSource|Download"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Filter"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr "首次推送"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推送者:"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "Forks"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr "從創建議題到部署到生產環境"
+
+msgid "From merge request merge until deploy to production"
+msgstr "從合併請求的合併到部署至生產環境"
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Home"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr "週期分析簡介"
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最後 %d 天"
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last Update"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多顯示 %d 個事件"
+
+msgid "Median"
+msgstr "中位數"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新議題"
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+#, fuzzy
+msgid "New issue"
+msgstr "新議題"
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "Not available"
+msgstr "不可用"
+
+msgid "Not enough data"
+msgstr "數據不足"
+
+msgid "Notification events"
+msgstr ""
+
+msgid "NotificationEvent|Close issue"
+msgstr ""
+
+msgid "NotificationEvent|Close merge request"
+msgstr ""
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr ""
+
+msgid "NotificationEvent|Merge merge request"
+msgstr ""
+
+msgid "NotificationEvent|New issue"
+msgstr ""
+
+msgid "NotificationEvent|New merge request"
+msgstr ""
+
+msgid "NotificationEvent|New note"
+msgstr ""
+
+msgid "NotificationEvent|Reassign issue"
+msgstr ""
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr ""
+
+msgid "NotificationEvent|Reopen issue"
+msgstr ""
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr ""
+
+msgid "NotificationLevel|Custom"
+msgstr ""
+
+msgid "NotificationLevel|Disabled"
+msgstr ""
+
+msgid "NotificationLevel|Global"
+msgstr ""
+
+msgid "NotificationLevel|On mention"
+msgstr ""
+
+msgid "NotificationLevel|Participate"
+msgstr ""
+
+msgid "NotificationLevel|Watch"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "開始於"
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr "流水線健康指標"
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr ""
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr ""
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project export could not be deleted."
+msgstr ""
+
+msgid "Project export has been deleted."
+msgstr ""
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr ""
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+
+msgid "Project home"
+msgstr ""
+
+msgid "ProjectFeature|Disabled"
+msgstr ""
+
+msgid "ProjectFeature|Everyone with access"
+msgstr ""
+
+msgid "ProjectFeature|Only team members"
+msgstr ""
+
+msgid "ProjectFileTree|Name"
+msgstr ""
+
+msgid "ProjectLastActivity|Never"
+msgstr ""
+
+msgid "ProjectLifecycle|Stage"
+msgstr "項目生命週期"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr ""
+
+msgid "Read more"
+msgstr "了解更多"
+
+#, fuzzy
+msgid "Readme"
+msgstr "了解更多"
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Related Commits"
+msgstr "相關的提交"
+
+msgid "Related Deployed Jobs"
+msgstr "相關的部署作業"
+
+msgid "Related Issues"
+msgstr "相關的議題"
+
+msgid "Related Jobs"
+msgstr "相關的作業"
+
+msgid "Related Merge Requests"
+msgstr "相關的合併請求"
+
+msgid "Related Merged Requests"
+msgstr "相關已合併的合並請求"
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Request Access"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}"
+msgstr ""
+
+msgid "Set up CI"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up auto deploy"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "顯示 %d 個事件"
+
+msgid "Source code"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr "編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "與該階段相關的事件。"
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr "議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"
+
+msgid "The phase of the development lifecycle."
+msgstr "項目生命週期中的各個階段。"
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr "計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr "生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"
+
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+
+msgid "The repository for this project does not exist."
+msgstr ""
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr "評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr "預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr "測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "該階段每條數據所花的時間"
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "Time before an issue gets scheduled"
+msgstr "議題被列入日程表的時間"
+
+msgid "Time before an issue starts implementation"
+msgstr "開始進行編碼前的時間"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "從創建合併請求到被合並或關閉的時間"
+
+msgid "Time until first merge request"
+msgstr "創建第壹個合併請求之前的時間"
+
+#, fuzzy
+msgid "Timeago|%s days ago"
+msgstr "天"
+
+#, fuzzy
+msgid "Timeago|%s days remaining"
+msgstr "天"
+
+msgid "Timeago|%s hours remaining"
+msgstr ""
+
+msgid "Timeago|%s minutes ago"
+msgstr ""
+
+msgid "Timeago|%s minutes remaining"
+msgstr ""
+
+msgid "Timeago|%s months ago"
+msgstr ""
+
+msgid "Timeago|%s months remaining"
+msgstr ""
+
+msgid "Timeago|%s seconds remaining"
+msgstr ""
+
+msgid "Timeago|%s weeks ago"
+msgstr ""
+
+msgid "Timeago|%s weeks remaining"
+msgstr ""
+
+msgid "Timeago|%s years ago"
+msgstr ""
+
+msgid "Timeago|%s years remaining"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|1 day remaining"
+msgstr "天"
+
+msgid "Timeago|1 hour remaining"
+msgstr ""
+
+msgid "Timeago|1 minute remaining"
+msgstr ""
+
+msgid "Timeago|1 month remaining"
+msgstr ""
+
+msgid "Timeago|1 week remaining"
+msgstr ""
+
+msgid "Timeago|1 year remaining"
+msgstr ""
+
+msgid "Timeago|Past due"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|a day ago"
+msgstr "天"
+
+msgid "Timeago|a month ago"
+msgstr ""
+
+msgid "Timeago|a week ago"
+msgstr ""
+
+msgid "Timeago|a while"
+msgstr ""
+
+msgid "Timeago|a year ago"
+msgstr ""
+
+msgid "Timeago|about %s hours ago"
+msgstr ""
+
+msgid "Timeago|about a minute ago"
+msgstr ""
+
+msgid "Timeago|about an hour ago"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|in %s days"
+msgstr "天"
+
+msgid "Timeago|in %s hours"
+msgstr ""
+
+msgid "Timeago|in %s minutes"
+msgstr ""
+
+msgid "Timeago|in %s months"
+msgstr ""
+
+msgid "Timeago|in %s seconds"
+msgstr ""
+
+msgid "Timeago|in %s weeks"
+msgstr ""
+
+msgid "Timeago|in %s years"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|in 1 day"
+msgstr "天"
+
+msgid "Timeago|in 1 hour"
+msgstr ""
+
+msgid "Timeago|in 1 minute"
+msgstr ""
+
+msgid "Timeago|in 1 month"
+msgstr ""
+
+msgid "Timeago|in 1 week"
+msgstr ""
+
+msgid "Timeago|in 1 year"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "小時"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分鐘"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "總時間"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有提交和合併的總測試時間"
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "VisibilityLevel|Internal"
+msgstr ""
+
+msgid "VisibilityLevel|Private"
+msgstr ""
+
+msgid "VisibilityLevel|Public"
+msgstr ""
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "權限不足。如需查看相關數據,請向管理員申請權限。"
+
+msgid "We don't have enough data to show this stage."
+msgstr "該階段的數據不足,無法顯示。"
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid ""
+"You are going to remove %{project_name_with_namespace}.\n"
+"Removed project CANNOT be restored!\n"
+"Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need permission."
+msgstr "您需要相關的權限。"
+
+msgid "You will not get any notifications via email"
+msgstr ""
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr ""
+
+msgid "You will receive notifications for any activity"
+msgstr ""
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+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 name"
+msgstr ""
+
+msgid "committed"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
+
+msgid "notification emails"
+msgstr ""
diff --git a/locale/zh_HK/gitlab.po.time_stamp b/locale/zh_HK/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_HK/gitlab.po.time_stamp
diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po
new file mode 100644
index 00000000000..47d8b889b61
--- /dev/null
+++ b/locale/zh_TW/gitlab.po
@@ -0,0 +1,916 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-05-04 19:24-0500\n"
+"Last-Translator: HuangTao <htve@outlook.com>, 2017\n"
+"Language-Team: Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/751"
+"77/zh_TW/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_TW\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Archived project! Repository is read-only"
+msgstr ""
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "CI configuration"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "CiStatusLabel|canceled"
+msgstr ""
+
+msgid "CiStatusLabel|created"
+msgstr ""
+
+msgid "CiStatusLabel|failed"
+msgstr ""
+
+msgid "CiStatusLabel|manual action"
+msgstr ""
+
+msgid "CiStatusLabel|passed"
+msgstr ""
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr ""
+
+msgid "CiStatusLabel|pending"
+msgstr ""
+
+msgid "CiStatusLabel|skipped"
+msgstr ""
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr ""
+
+msgid "CiStatusText|blocked"
+msgstr ""
+
+msgid "CiStatusText|canceled"
+msgstr ""
+
+msgid "CiStatusText|created"
+msgstr ""
+
+msgid "CiStatusText|failed"
+msgstr ""
+
+msgid "CiStatusText|manual"
+msgstr ""
+
+msgid "CiStatusText|passed"
+msgstr ""
+
+msgid "CiStatusText|pending"
+msgstr ""
+
+msgid "CiStatusText|skipped"
+msgstr ""
+
+msgid "CiStatus|running"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "送交"
+
+#, fuzzy
+msgid "CommitMessage|Add %{file_name}"
+msgstr "送交"
+
+#, fuzzy
+msgid "Commits"
+msgstr "送交"
+
+#, fuzzy
+msgid "Commits|History"
+msgstr "送交"
+
+msgid "Compare"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty bare repository"
+msgstr ""
+
+#, fuzzy
+msgid "Create merge request"
+msgstr "相關的合併請求"
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Custom notification events"
+msgstr ""
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr ""
+
+msgid "Cycle Analytics"
+msgstr ""
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr "週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "程式開發"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "議題"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "計劃"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "上線"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "複閱"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "預備"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "測試"
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "Description"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Download tar"
+msgstr ""
+
+msgid "Download tar.bz2"
+msgstr ""
+
+msgid "Download tar.gz"
+msgstr ""
+
+msgid "Download zip"
+msgstr ""
+
+msgid "DownloadArtifacts|Download"
+msgstr ""
+
+msgid "DownloadSource|Download"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Filter"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr "首次推送"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推送者:"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "Forks"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr "從議題建立至線上部署"
+
+msgid "From merge request merge until deploy to production"
+msgstr "從請求被合併後至線上部署"
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Home"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr "週期分析簡介"
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最後 %d 天"
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last Update"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多顯示 %d 個事件"
+
+msgid "Median"
+msgstr "中位數"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新議題"
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+#, fuzzy
+msgid "New issue"
+msgstr "新議題"
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "Not available"
+msgstr "無法使用"
+
+msgid "Not enough data"
+msgstr "資料不足"
+
+msgid "Notification events"
+msgstr ""
+
+msgid "NotificationEvent|Close issue"
+msgstr ""
+
+msgid "NotificationEvent|Close merge request"
+msgstr ""
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr ""
+
+msgid "NotificationEvent|Merge merge request"
+msgstr ""
+
+msgid "NotificationEvent|New issue"
+msgstr ""
+
+msgid "NotificationEvent|New merge request"
+msgstr ""
+
+msgid "NotificationEvent|New note"
+msgstr ""
+
+msgid "NotificationEvent|Reassign issue"
+msgstr ""
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr ""
+
+msgid "NotificationEvent|Reopen issue"
+msgstr ""
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr ""
+
+msgid "NotificationLevel|Custom"
+msgstr ""
+
+msgid "NotificationLevel|Disabled"
+msgstr ""
+
+msgid "NotificationLevel|Global"
+msgstr ""
+
+msgid "NotificationLevel|On mention"
+msgstr ""
+
+msgid "NotificationLevel|Participate"
+msgstr ""
+
+msgid "NotificationLevel|Watch"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "開始於"
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr "流水線健康指標"
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr ""
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr ""
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project export could not be deleted."
+msgstr ""
+
+msgid "Project export has been deleted."
+msgstr ""
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr ""
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+
+msgid "Project home"
+msgstr ""
+
+msgid "ProjectFeature|Disabled"
+msgstr ""
+
+msgid "ProjectFeature|Everyone with access"
+msgstr ""
+
+msgid "ProjectFeature|Only team members"
+msgstr ""
+
+msgid "ProjectFileTree|Name"
+msgstr ""
+
+msgid "ProjectLastActivity|Never"
+msgstr ""
+
+msgid "ProjectLifecycle|Stage"
+msgstr "專案生命週期"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr ""
+
+msgid "Read more"
+msgstr "了解更多"
+
+#, fuzzy
+msgid "Readme"
+msgstr "了解更多"
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Related Commits"
+msgstr "相關的送交"
+
+msgid "Related Deployed Jobs"
+msgstr "相關的部署作業"
+
+msgid "Related Issues"
+msgstr "相關的議題"
+
+msgid "Related Jobs"
+msgstr "相關的作業"
+
+msgid "Related Merge Requests"
+msgstr "相關的合併請求"
+
+msgid "Related Merged Requests"
+msgstr "相關已合併的請求"
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Request Access"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}"
+msgstr ""
+
+msgid "Set up CI"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up auto deploy"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "顯示 %d 個事件"
+
+msgid "Source code"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr "程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "與該階段相關的事件。"
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr "議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"
+
+msgid "The phase of the development lifecycle."
+msgstr "專案開發生命週期的各個階段。"
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr "計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr "上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"
+
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+
+msgid "The repository for this project does not exist."
+msgstr ""
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr "複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr "預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr "測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "每筆該階段相關資料所花的時間。"
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "Time before an issue gets scheduled"
+msgstr "議題被列入日程表的時間"
+
+msgid "Time before an issue starts implementation"
+msgstr "議題等待開始實作的時間"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "合併請求被合併或是關閉的時間"
+
+msgid "Time until first merge request"
+msgstr "第一個合併請求被建立前的時間"
+
+#, fuzzy
+msgid "Timeago|%s days ago"
+msgstr "天"
+
+#, fuzzy
+msgid "Timeago|%s days remaining"
+msgstr "天"
+
+msgid "Timeago|%s hours remaining"
+msgstr ""
+
+msgid "Timeago|%s minutes ago"
+msgstr ""
+
+msgid "Timeago|%s minutes remaining"
+msgstr ""
+
+msgid "Timeago|%s months ago"
+msgstr ""
+
+msgid "Timeago|%s months remaining"
+msgstr ""
+
+msgid "Timeago|%s seconds remaining"
+msgstr ""
+
+msgid "Timeago|%s weeks ago"
+msgstr ""
+
+msgid "Timeago|%s weeks remaining"
+msgstr ""
+
+msgid "Timeago|%s years ago"
+msgstr ""
+
+msgid "Timeago|%s years remaining"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|1 day remaining"
+msgstr "天"
+
+msgid "Timeago|1 hour remaining"
+msgstr ""
+
+msgid "Timeago|1 minute remaining"
+msgstr ""
+
+msgid "Timeago|1 month remaining"
+msgstr ""
+
+msgid "Timeago|1 week remaining"
+msgstr ""
+
+msgid "Timeago|1 year remaining"
+msgstr ""
+
+msgid "Timeago|Past due"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|a day ago"
+msgstr "天"
+
+msgid "Timeago|a month ago"
+msgstr ""
+
+msgid "Timeago|a week ago"
+msgstr ""
+
+msgid "Timeago|a while"
+msgstr ""
+
+msgid "Timeago|a year ago"
+msgstr ""
+
+msgid "Timeago|about %s hours ago"
+msgstr ""
+
+msgid "Timeago|about a minute ago"
+msgstr ""
+
+msgid "Timeago|about an hour ago"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|in %s days"
+msgstr "天"
+
+msgid "Timeago|in %s hours"
+msgstr ""
+
+msgid "Timeago|in %s minutes"
+msgstr ""
+
+msgid "Timeago|in %s months"
+msgstr ""
+
+msgid "Timeago|in %s seconds"
+msgstr ""
+
+msgid "Timeago|in %s weeks"
+msgstr ""
+
+msgid "Timeago|in %s years"
+msgstr ""
+
+#, fuzzy
+msgid "Timeago|in 1 day"
+msgstr "天"
+
+msgid "Timeago|in 1 hour"
+msgstr ""
+
+msgid "Timeago|in 1 minute"
+msgstr ""
+
+msgid "Timeago|in 1 month"
+msgstr ""
+
+msgid "Timeago|in 1 week"
+msgstr ""
+
+msgid "Timeago|in 1 year"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "小時"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分鐘"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "總時間"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有送交和合併的總測試時間"
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "VisibilityLevel|Internal"
+msgstr ""
+
+msgid "VisibilityLevel|Private"
+msgstr ""
+
+msgid "VisibilityLevel|Public"
+msgstr ""
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "權限不足。如需查看相關資料,請向管理員申請權限。"
+
+msgid "We don't have enough data to show this stage."
+msgstr "因該階段的資料不足而無法顯示相關資訊"
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid ""
+"You are going to remove %{project_name_with_namespace}.\n"
+"Removed project CANNOT be restored!\n"
+"Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need permission."
+msgstr "您需要相關的權限。"
+
+msgid "You will not get any notifications via email"
+msgstr ""
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr ""
+
+msgid "You will receive notifications for any activity"
+msgstr ""
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+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 name"
+msgstr ""
+
+msgid "committed"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
+
+msgid "notification emails"
+msgstr ""
diff --git a/locale/zh_TW/gitlab.po.time_stamp b/locale/zh_TW/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_TW/gitlab.po.time_stamp
diff --git a/rubocop/cop/activerecord_serialize.rb b/rubocop/cop/activerecord_serialize.rb
index bfa0cff9a67..9bdcc3b4c34 100644
--- a/rubocop/cop/activerecord_serialize.rb
+++ b/rubocop/cop/activerecord_serialize.rb
@@ -1,24 +1,18 @@
+require_relative '../model_helpers'
+
module RuboCop
module Cop
# Cop that prevents the use of `serialize` in ActiveRecord models.
class ActiverecordSerialize < RuboCop::Cop::Cop
+ include ModelHelpers
+
MSG = 'Do not store serialized data in the database, use separate columns and/or tables instead'.freeze
def on_send(node)
- return unless in_models?(node)
+ return unless in_model?(node)
add_offense(node, :selector) if node.children[1] == :serialize
end
-
- def models_path
- File.join(Dir.pwd, 'app', 'models')
- end
-
- def in_models?(node)
- path = node.location.expression.source_buffer.name
-
- path.start_with?(models_path)
- end
end
end
end
diff --git a/rubocop/cop/polymorphic_associations.rb b/rubocop/cop/polymorphic_associations.rb
new file mode 100644
index 00000000000..7d554704550
--- /dev/null
+++ b/rubocop/cop/polymorphic_associations.rb
@@ -0,0 +1,23 @@
+require_relative '../model_helpers'
+
+module RuboCop
+ module Cop
+ # Cop that prevents the use of polymorphic associations
+ class PolymorphicAssociations < RuboCop::Cop::Cop
+ include ModelHelpers
+
+ MSG = 'Do not use polymorphic associations, use separate tables instead'.freeze
+
+ def on_send(node)
+ return unless in_model?(node)
+ return unless node.children[1] == :belongs_to
+
+ node.children.last.each_node(:pair) do |pair|
+ key_name = pair.children[0].children[0]
+
+ add_offense(pair, :expression) if key_name == :polymorphic
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/redirect_with_status.rb b/rubocop/cop/redirect_with_status.rb
new file mode 100644
index 00000000000..36810642c88
--- /dev/null
+++ b/rubocop/cop/redirect_with_status.rb
@@ -0,0 +1,44 @@
+module RuboCop
+ module Cop
+ # This cop prevents usage of 'redirect_to' in actions 'destroy' without specifying 'status'.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/31840
+ class RedirectWithStatus < RuboCop::Cop::Cop
+ MSG = 'Do not use "redirect_to" without "status" in "destroy" action'.freeze
+
+ def on_def(node)
+ return unless in_controller?(node)
+ return unless destroy?(node) || destroy_all?(node)
+
+ node.each_descendant(:send) do |def_node|
+ next unless redirect_to?(def_node)
+
+ methods = []
+
+ def_node.children.last.each_node(:pair) do |pair|
+ methods << pair.children.first.children.first
+ end
+
+ add_offense(def_node, :selector) unless methods.include?(:status)
+ end
+ end
+
+ private
+
+ def in_controller?(node)
+ node.location.expression.source_buffer.name.end_with?('_controller.rb')
+ end
+
+ def destroy?(node)
+ node.children.first == :destroy
+ end
+
+ def destroy_all?(node)
+ node.children.first == :destroy_all
+ end
+
+ def redirect_to?(node)
+ node.children[1] == :redirect_to
+ end
+ end
+ end
+end
diff --git a/rubocop/model_helpers.rb b/rubocop/model_helpers.rb
new file mode 100644
index 00000000000..309723dc34c
--- /dev/null
+++ b/rubocop/model_helpers.rb
@@ -0,0 +1,11 @@
+module RuboCop
+ module ModelHelpers
+ # Returns true if the given node originated from the models directory.
+ def in_model?(node)
+ path = node.location.expression.source_buffer.name
+ models_path = File.join(Dir.pwd, 'app', 'models')
+
+ path.start_with?(models_path)
+ end
+ end
+end
diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb
index 17d2bf6aa1c..dae30969abf 100644
--- a/rubocop/rubocop.rb
+++ b/rubocop/rubocop.rb
@@ -1,6 +1,8 @@
require_relative 'cop/custom_error_class'
require_relative 'cop/gem_fetcher'
require_relative 'cop/activerecord_serialize'
+require_relative 'cop/redirect_with_status'
+require_relative 'cop/polymorphic_associations'
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'
diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb
index c29b2fe8946..ddf38967dd7 100644
--- a/spec/controllers/admin/groups_controller_spec.rb
+++ b/spec/controllers/admin/groups_controller_spec.rb
@@ -36,6 +36,15 @@ describe Admin::GroupsController do
expect(group.users).to include group_user
end
+ it 'can add unlimited members' do
+ put :members_update, id: group,
+ user_ids: 1.upto(1000).to_a.join(','),
+ access_level: Gitlab::Access::GUEST
+
+ expect(response).to set_flash.to 'Users were successfully added.'
+ expect(response).to redirect_to(admin_group_path(group))
+ end
+
it 'adds no user to members' do
put :members_update, id: group,
user_ids: '',
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 2ab2ca1b667..7d6c317482f 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -10,15 +10,26 @@ describe Admin::UsersController do
describe 'DELETE #user with projects' do
let(:project) { create(:empty_project, namespace: user.namespace) }
+ let!(:issue) { create(:issue, author: user) }
before do
project.team << [user, :developer]
end
- it 'deletes user' do
+ it 'deletes user and ghosts their contributions' do
delete :destroy, id: user.username, format: :json
+
+ expect(response).to have_http_status(200)
+ expect(User.exists?(user.id)).to be_falsy
+ expect(issue.reload.author).to be_ghost
+ end
+
+ it 'deletes the user and their contributions when hard delete is specified' do
+ delete :destroy, id: user.username, hard_delete: true, format: :json
+
expect(response).to have_http_status(200)
- expect { User.find(user.id) }.to raise_exception(ActiveRecord::RecordNotFound)
+ expect(User.exists?(user.id)).to be_falsy
+ expect(Issue.exists?(issue.id)).to be_falsy
end
end
diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb
index 2c9d1ffc9c2..4c3a5ec49ef 100644
--- a/spec/controllers/autocomplete_controller_spec.rb
+++ b/spec/controllers/autocomplete_controller_spec.rb
@@ -170,22 +170,32 @@ describe AutocompleteController do
end
context 'author of issuable included' do
- before do
- sign_in(user)
- end
-
let(:body) { JSON.parse(response.body) }
- it 'includes the author' do
- get(:users, author_id: non_member.id)
+ context 'authenticated' do
+ before do
+ sign_in(user)
+ end
+
+ it 'includes the author' do
+ get(:users, author_id: non_member.id)
+
+ expect(body.first["username"]).to eq non_member.username
+ end
+
+ it 'rejects non existent user ids' do
+ get(:users, author_id: 99999)
- expect(body.first["username"]).to eq non_member.username
+ expect(body.collect { |u| u['id'] }).not_to include(99999)
+ end
end
- it 'rejects non existent user ids' do
- get(:users, author_id: 99999)
+ context 'without authenticating' do
+ it 'returns empty result' do
+ get(:users, author_id: non_member.id)
- expect(body.collect { |u| u['id'] }).not_to include(99999)
+ expect(body).to be_empty
+ end
end
end
diff --git a/spec/controllers/dashboard/milestones_controller_spec.rb b/spec/controllers/dashboard/milestones_controller_spec.rb
new file mode 100644
index 00000000000..424f39fd3b8
--- /dev/null
+++ b/spec/controllers/dashboard/milestones_controller_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Dashboard::MilestonesController do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+ let(:project_milestone) { create(:milestone, project: project) }
+ let(:milestone) do
+ DashboardMilestone.build(
+ [project],
+ project_milestone.title
+ )
+ end
+ let(:issue) { create(:issue, project: project, milestone: project_milestone) }
+ let!(:label) { create(:label, project: project, title: 'Issue Label', issues: [issue]) }
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: project_milestone) }
+ let(:milestone_path) { dashboard_milestone_path(milestone.safe_title, title: milestone.title) }
+
+ before do
+ sign_in(user)
+ project.team << [user, :master]
+ end
+
+ it_behaves_like 'milestone tabs'
+
+ describe "#show" do
+ render_views
+
+ def view_milestone
+ get :show, id: milestone.safe_title, title: milestone.title
+ end
+
+ it 'shows milestone page' do
+ view_milestone
+
+ expect(response).to have_http_status(200)
+ end
+ end
+end
diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb
index 60db0192dfd..ed4ad7b600e 100644
--- a/spec/controllers/groups/group_members_controller_spec.rb
+++ b/spec/controllers/groups/group_members_controller_spec.rb
@@ -124,6 +124,13 @@ describe Groups::GroupMembersController do
expect(response).to redirect_to(dashboard_groups_path)
expect(group.users).not_to include user
end
+
+ it 'supports json request' do
+ delete :leave, group_id: group, format: :json
+
+ expect(response).to have_http_status(200)
+ expect(json_response['notice']).to eq "You left the \"#{group.name}\" group."
+ end
end
context 'and is an owner' do
diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb
index b8b6e0c3a88..e7c19b47a6a 100644
--- a/spec/controllers/health_controller_spec.rb
+++ b/spec/controllers/health_controller_spec.rb
@@ -54,43 +54,4 @@ describe HealthController do
end
end
end
-
- describe '#metrics' do
- context 'authorization token provided' do
- before do
- request.headers['TOKEN'] = token
- end
-
- it 'returns DB ping metrics' do
- get :metrics
- expect(response.body).to match(/^db_ping_timeout 0$/)
- expect(response.body).to match(/^db_ping_success 1$/)
- expect(response.body).to match(/^db_ping_latency [0-9\.]+$/)
- end
-
- it 'returns Redis ping metrics' do
- get :metrics
- expect(response.body).to match(/^redis_ping_timeout 0$/)
- expect(response.body).to match(/^redis_ping_success 1$/)
- expect(response.body).to match(/^redis_ping_latency [0-9\.]+$/)
- end
-
- it 'returns file system check metrics' do
- get :metrics
- expect(response.body).to match(/^filesystem_access_latency{shard="default"} [0-9\.]+$/)
- expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/)
- expect(response.body).to match(/^filesystem_write_latency{shard="default"} [0-9\.]+$/)
- expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/)
- expect(response.body).to match(/^filesystem_read_latency{shard="default"} [0-9\.]+$/)
- expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/)
- end
- end
-
- context 'without authorization token' do
- it 'returns proper response' do
- get :metrics
- expect(response.status).to eq(404)
- end
- end
- end
end
diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb
new file mode 100644
index 00000000000..044c9f179ed
--- /dev/null
+++ b/spec/controllers/metrics_controller_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe MetricsController do
+ include StubENV
+
+ let(:token) { current_application_settings.health_check_access_token }
+ let(:json_response) { JSON.parse(response.body) }
+ let(:metrics_multiproc_dir) { Dir.mktmpdir }
+
+ before do
+ stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
+ stub_env('prometheus_multiproc_dir', metrics_multiproc_dir)
+ allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(true)
+ end
+
+ describe '#index' do
+ context 'authorization token provided' do
+ before do
+ request.headers['TOKEN'] = token
+ end
+
+ it 'returns DB ping metrics' do
+ get :index
+
+ expect(response.body).to match(/^db_ping_timeout 0$/)
+ expect(response.body).to match(/^db_ping_success 1$/)
+ expect(response.body).to match(/^db_ping_latency [0-9\.]+$/)
+ end
+
+ it 'returns Redis ping metrics' do
+ get :index
+
+ expect(response.body).to match(/^redis_ping_timeout 0$/)
+ expect(response.body).to match(/^redis_ping_success 1$/)
+ expect(response.body).to match(/^redis_ping_latency [0-9\.]+$/)
+ end
+
+ it 'returns file system check metrics' do
+ get :index
+
+ expect(response.body).to match(/^filesystem_access_latency{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/)
+ expect(response.body).to match(/^filesystem_write_latency{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/)
+ expect(response.body).to match(/^filesystem_read_latency{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/)
+ end
+
+ context 'prometheus metrics are disabled' do
+ before do
+ allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false)
+ end
+
+ it 'returns proper response' do
+ get :index
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+
+ context 'without authorization token' do
+ it 'returns proper response' do
+ get :index
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb
index 61e4fae46fb..363ed410bc0 100644
--- a/spec/controllers/profiles/keys_controller_spec.rb
+++ b/spec/controllers/profiles/keys_controller_spec.rb
@@ -49,7 +49,7 @@ describe Profiles::KeysController do
expect(response.body).to eq(user.all_ssh_keys.join("\n"))
expect(response.body).to include(key.key.sub(' dummy@gitlab.com', ''))
- expect(response.body).to include(another_key.key)
+ expect(response.body).to include(another_key.key.sub(' dummy@gitlab.com', ''))
expect(response.body).not_to include(deploy_key.key)
end
diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb
new file mode 100644
index 00000000000..9d60dab12d1
--- /dev/null
+++ b/spec/controllers/profiles_controller_spec.rb
@@ -0,0 +1,31 @@
+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)
+ sign_in(user)
+
+ put :update,
+ user: { email: "john@gmail.com", name: "John" }
+
+ user.reload
+
+ expect(response.status).to eq(302)
+ expect(user.unconfirmed_email).to eq('john@gmail.com')
+ end
+
+ it "ignores an email update from a user with an external email address" do
+ ldap_user = create(:omniauth_user, external_email: true)
+ sign_in(ldap_user)
+
+ put :update,
+ user: { email: "john@gmail.com", name: "John" }
+
+ ldap_user.reload
+
+ expect(response.status).to eq(302)
+ expect(ldap_user.unconfirmed_email).not_to eq('john@gmail.com')
+ end
+ end
+end
diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb
index 432f3c53c90..0f2664262e8 100644
--- a/spec/controllers/projects/boards/lists_controller_spec.rb
+++ b/spec/controllers/projects/boards/lists_controller_spec.rb
@@ -27,7 +27,7 @@ describe Projects::Boards::ListsController do
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('lists')
- expect(parsed_response.length).to eq 2
+ expect(parsed_response.length).to eq 3
end
context 'with unauthorized user' do
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index 838bdae1445..7211acc53dc 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -101,26 +101,49 @@ describe Projects::JobsController do
end
describe 'GET show' do
- context 'when build exists' do
- let!(:build) { create(:ci_build, pipeline: pipeline) }
+ let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
- before do
- get_show(id: build.id)
+ context 'when requesting HTML' do
+ context 'when build exists' do
+ before do
+ get_show(id: build.id)
+ end
+
+ it 'has a build' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:build).id).to eq(build.id)
+ end
end
- it 'has a build' do
- expect(response).to have_http_status(:ok)
- expect(assigns(:build).id).to eq(build.id)
+ context 'when build does not exist' do
+ before do
+ get_show(id: 1234)
+ end
+
+ it 'renders not_found' do
+ expect(response).to have_http_status(:not_found)
+ end
end
end
- context 'when build does not exist' do
+ context 'when requesting JSON' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
before do
- get_show(id: 1234)
+ project.add_developer(user)
+ sign_in(user)
+
+ allow_any_instance_of(Ci::Build).to receive(:merge_request).and_return(merge_request)
+
+ get_show(id: build.id, format: :json)
end
- it 'renders not_found' do
- expect(response).to have_http_status(:not_found)
+ it 'exposes needed information' do
+ expect(response).to have_http_status(:ok)
+ expect(json_response['raw_path']).to match(/builds\/\d+\/raw\z/)
+ expect(json_response.dig('merge_request', 'path')).to match(/merge_requests\/\d+\z/)
+ expect(json_response['new_issue_path'])
+ .to include('/issues/new')
end
end
diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb
index 130b0b744b5..bf1776eb320 100644
--- a/spec/controllers/projects/labels_controller_spec.rb
+++ b/spec/controllers/projects/labels_controller_spec.rb
@@ -117,7 +117,7 @@ describe Projects::LabelsController do
let!(:promoted_label_name) { "Promoted Label" }
let!(:label_1) { create(:label, title: promoted_label_name, project: project) }
- context 'not group owner' do
+ context 'not group reporters' do
it 'denies access' do
post :promote, namespace_id: project.namespace.to_param, project_id: project, id: label_1.to_param
@@ -125,9 +125,9 @@ describe Projects::LabelsController do
end
end
- context 'group owner' do
+ context 'group reporter' do
before do
- GroupMember.add_users(group, [user], :owner)
+ group.add_reporter(user)
end
it 'gives access' do
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index a25db7a65fb..0a64fe2beda 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -121,13 +121,18 @@ describe Projects::MergeRequestsController do
context 'number of queries' do
it 'verifies number of queries' do
+ RequestStore.begin!
+
# pre-create objects
merge_request
recorded = ActiveRecord::QueryRecorder.new { go(format: :json) }
- expect(recorded.count).to be_within(5).of(59)
+ expect(recorded.count).to be_within(5).of(30)
expect(recorded.cached_count).to eq(0)
+
+ RequestStore.end!
+ RequestStore.clear!
end
end
end
diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb
index a4b4392d7cc..2294d5df581 100644
--- a/spec/controllers/projects/project_members_controller_spec.rb
+++ b/spec/controllers/projects/project_members_controller_spec.rb
@@ -36,7 +36,7 @@ describe Projects::ProjectMembersController do
before { project.team << [user, :master] }
it 'adds user to members' do
- expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(true)
+ expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :success)
post :create, namespace_id: project.namespace,
project_id: project,
@@ -48,14 +48,14 @@ describe Projects::ProjectMembersController do
end
it 'adds no user to members' do
- expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(false)
+ expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :failure, message: 'Message')
post :create, namespace_id: project.namespace,
project_id: project,
user_ids: '',
access_level: Gitlab::Access::GUEST
- expect(response).to set_flash.to 'No users specified.'
+ expect(response).to set_flash.to 'Message'
expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project))
end
end
diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb
index 24a59caff4e..8c23c46798e 100644
--- a/spec/controllers/projects/snippets_controller_spec.rb
+++ b/spec/controllers/projects/snippets_controller_spec.rb
@@ -78,8 +78,18 @@ describe Projects::SnippetsController do
post :create, {
namespace_id: project.namespace.to_param,
project_id: project,
- project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params)
+ project_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params)
}.merge(additional_params)
+
+ Snippet.last
+ end
+
+ it 'creates the snippet correctly' do
+ snippet = create_snippet(project, visibility_level: Snippet::PRIVATE)
+
+ expect(snippet.title).to eq('Title')
+ expect(snippet.content).to eq('Content')
+ expect(snippet.description).to eq('Description')
end
context 'when the snippet is spam' do
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 71dd9ef3eb4..634563fc290 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -77,7 +77,7 @@ describe RegistrationsController do
end
it 'schedules the user for destruction' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {})
post(:destroy)
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 038132cffe0..e87e24a33a1 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -1,6 +1,37 @@
require 'spec_helper'
describe SessionsController do
+ describe '#new' do
+ before do
+ @request.env['devise.mapping'] = Devise.mappings[:user]
+ end
+
+ context 'when auto sign-in is enabled' do
+ before do
+ stub_omniauth_setting(auto_sign_in_with_provider: :saml)
+ allow(controller).to receive(:omniauth_authorize_path).with(:user, :saml).
+ and_return('/saml')
+ end
+
+ context 'and no auto_sign_in param is passed' do
+ it 'redirects to :omniauth_authorize_path' do
+ get(:new)
+
+ expect(response).to have_http_status(302)
+ expect(response).to redirect_to('/saml')
+ end
+ end
+
+ context 'and auto_sign_in=false param is passed' do
+ it 'responds with 200' do
+ get(:new, auto_sign_in: 'false')
+
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+ end
+
describe '#create' do
before do
@request.env['devise.mapping'] = Devise.mappings[:user]
diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb
index 930415a4778..9073c39f562 100644
--- a/spec/controllers/snippets_controller_spec.rb
+++ b/spec/controllers/snippets_controller_spec.rb
@@ -171,12 +171,50 @@ describe SnippetsController do
sign_in(user)
post :create, {
- personal_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params)
+ personal_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params)
}.merge(additional_params)
Snippet.last
end
+ it 'creates the snippet correctly' do
+ snippet = create_snippet(visibility_level: Snippet::PRIVATE)
+
+ expect(snippet.title).to eq('Title')
+ expect(snippet.content).to eq('Content')
+ expect(snippet.description).to eq('Description')
+ end
+
+ context 'when the snippet description contains a file' do
+ let(:picture_file) { '/temp/secret56/picture.jpg' }
+ let(:text_file) { '/temp/secret78/text.txt' }
+ let(:description) do
+ "Description with picture: ![picture](/uploads#{picture_file}) and "\
+ "text: [text.txt](/uploads#{text_file})"
+ end
+
+ before do
+ allow(FileUtils).to receive(:mkdir_p)
+ allow(FileUtils).to receive(:move)
+ end
+
+ subject { create_snippet({ description: description }, { files: [picture_file, text_file] }) }
+
+ it 'creates the snippet' do
+ expect { subject }.to change { Snippet.count }.by(1)
+ end
+
+ it 'stores the snippet description correctly' do
+ snippet = subject
+
+ expected_description = "Description with picture: "\
+ "![picture](/uploads/personal_snippet/#{snippet.id}/secret56/picture.jpg) and "\
+ "text: [text.txt](/uploads/personal_snippet/#{snippet.id}/secret78/text.txt)"
+
+ expect(snippet.description).to eq(expected_description)
+ end
+ end
+
context 'when the snippet is spam' do
before do
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index 8000c9dec61..01a0659479b 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -92,6 +92,40 @@ describe UploadsController do
end
end
end
+
+ context 'temporal with valid image' do
+ subject do
+ post :create, model: 'personal_snippet', file: jpg, format: :json
+ end
+
+ it 'returns a content with original filename, new link, and correct type.' do
+ subject
+
+ expect(response.body).to match '\"alt\":\"rails_sample\"'
+ expect(response.body).to match "\"url\":\"/uploads/temp"
+ end
+
+ it 'does not create an Upload record' do
+ expect { subject }.not_to change { Upload.count }
+ end
+ end
+
+ context 'temporal with valid non-image file' do
+ subject do
+ post :create, model: 'personal_snippet', file: txt, format: :json
+ end
+
+ it 'returns a content with original filename, new link, and correct type.' do
+ subject
+
+ expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
+ expect(response.body).to match "\"url\":\"/uploads/temp"
+ end
+
+ it 'does not create an Upload record' do
+ expect { subject }.not_to change { Upload.count }
+ end
+ end
end
end
diff --git a/spec/db/production/settings.rb b/spec/db/production/settings.rb
deleted file mode 100644
index 3cbb173c4cc..00000000000
--- a/spec/db/production/settings.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require 'spec_helper'
-
-describe 'seed production settings', lib: true do
- include StubENV
-
- context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do
- before do
- stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789')
- end
-
- it 'writes the token to the database' do
- load(File.join(__dir__, '../../../db/fixtures/production/010_settings.rb'))
- expect(ApplicationSetting.current.runners_registration_token).to eq('013456789')
- end
- end
-end
diff --git a/spec/db/production/settings_spec.rb b/spec/db/production/settings_spec.rb
new file mode 100644
index 00000000000..a9d015e0666
--- /dev/null
+++ b/spec/db/production/settings_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+require 'rainbow/ext/string'
+
+describe 'seed production settings', lib: true do
+ include StubENV
+ let(:settings_file) { Rails.root.join('db/fixtures/production/010_settings.rb') }
+ let(:settings) { Gitlab::CurrentSettings.current_application_settings }
+
+ context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do
+ before do
+ stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789')
+ end
+
+ it 'writes the token to the database' do
+ load(settings_file)
+
+ expect(settings.runners_registration_token).to eq('013456789')
+ end
+ end
+
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is set in the environment' do
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is true' do
+ before do
+ stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', 'true')
+ end
+
+ it 'prometheus_metrics_enabled is set to true ' do
+ load(settings_file)
+
+ expect(settings.prometheus_metrics_enabled).to eq(true)
+ end
+ end
+
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do
+ before do
+ stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', 'false')
+ end
+
+ it 'prometheus_metrics_enabled is set to false' do
+ load(settings_file)
+
+ expect(settings.prometheus_metrics_enabled).to eq(false)
+ end
+ end
+
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do
+ before do
+ stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', '')
+ end
+
+ it 'prometheus_metrics_enabled is set to false' do
+ load(settings_file)
+
+ expect(settings.prometheus_metrics_enabled).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index f5e99fdf00b..0bb5a86d9b9 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -64,7 +64,8 @@ FactoryGirl.define do
trait :teardown_environment do
environment 'staging'
options environment: { name: 'staging',
- action: 'stop' }
+ action: 'stop',
+ url: 'http://staging.example.com/$CI_JOB_NAME' }
end
trait :allowed_to_fail do
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 03e3c62effe..35803f0c37f 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -9,14 +9,14 @@ FactoryGirl.define do
factory :ci_pipeline_without_jobs do
after(:build) do |pipeline|
- allow(pipeline).to receive(:ci_yaml_file) { YAML.dump({}) }
+ pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({}))
end
end
factory :ci_pipeline_with_one_job do
after(:build) do |pipeline|
allow(pipeline).to receive(:ci_yaml_file) do
- YAML.dump({ rspec: { script: "ls" } })
+ pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({ rspec: { script: "ls" } }))
end
end
end
@@ -34,17 +34,14 @@ FactoryGirl.define do
transient { config nil }
after(:build) do |pipeline, evaluator|
- allow(pipeline).to receive(:ci_yaml_file) do
- if evaluator.config
- YAML.dump(evaluator.config)
- else
- File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
- end
- end
+ if evaluator.config
+ pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump(evaluator.config))
- # Populates pipeline with errors
- #
- pipeline.config_processor if evaluator.config
+ # Populates pipeline with errors
+ pipeline.config_processor if evaluator.config
+ else
+ pipeline.instance_variable_set(:@ci_yaml_file, File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')))
+ end
end
trait :invalid do
diff --git a/spec/factories/ci/stages.rb b/spec/factories/ci/stages.rb
index 7f557b25ccb..d3c8bf9d54f 100644
--- a/spec/factories/ci/stages.rb
+++ b/spec/factories/ci/stages.rb
@@ -1,5 +1,7 @@
FactoryGirl.define do
- factory :ci_stage, class: Ci::Stage do
+ factory :ci_stage, class: Ci::LegacyStage do
+ skip_create
+
transient do
name 'test'
status nil
@@ -8,7 +10,9 @@ FactoryGirl.define do
end
initialize_with do
- Ci::Stage.new(pipeline, name: name, status: status, warnings: warnings)
+ Ci::LegacyStage.new(pipeline, name: name,
+ status: status,
+ warnings: warnings)
end
end
end
diff --git a/spec/factories/ci/trigger_requests.rb b/spec/factories/ci/trigger_requests.rb
index b8d8fab0e0b..10e0ab4fd3c 100644
--- a/spec/factories/ci/trigger_requests.rb
+++ b/spec/factories/ci/trigger_requests.rb
@@ -1,8 +1,8 @@
FactoryGirl.define do
factory :ci_trigger_request, class: Ci::TriggerRequest do
- factory :ci_trigger_request_with_variables do
- trigger factory: :ci_trigger
+ trigger factory: :ci_trigger
+ factory :ci_trigger_request_with_variables do
variables do
{
TRIGGER_KEY_1: 'TRIGGER_VALUE_1',
diff --git a/spec/factories/commits.rb b/spec/factories/commits.rb
index 89e260cf65b..36b9645438a 100644
--- a/spec/factories/commits.rb
+++ b/spec/factories/commits.rb
@@ -4,19 +4,14 @@ FactoryGirl.define do
factory :commit do
git_commit RepoHelpers.sample_commit
project factory: :empty_project
+ author { build(:author) }
initialize_with do
new(git_commit, project)
end
- after(:build) do |commit|
- allow(commit).to receive(:author).and_return build(:author)
- end
-
trait :without_author do
- after(:build) do |commit|
- allow(commit).to receive(:author).and_return nil
- end
+ author nil
end
end
end
diff --git a/spec/factories/conversational_development_index_metrics.rb b/spec/factories/conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..a5412629195
--- /dev/null
+++ b/spec/factories/conversational_development_index_metrics.rb
@@ -0,0 +1,33 @@
+FactoryGirl.define do
+ factory :conversational_development_index_metric, class: ConversationalDevelopmentIndex::Metric do
+ leader_issues 9.256
+ instance_issues 1.234
+
+ leader_notes 30.33333
+ instance_notes 28.123
+
+ leader_milestones 16.2456
+ instance_milestones 1.234
+
+ leader_boards 5.2123
+ instance_boards 3.254
+
+ leader_merge_requests 1.2
+ instance_merge_requests 0.6
+
+ leader_ci_pipelines 12.1234
+ instance_ci_pipelines 2.344
+
+ leader_environments 3.3333
+ instance_environments 2.2222
+
+ leader_deployments 1.200
+ instance_deployments 0.771
+
+ leader_projects_prometheus_active 0.111
+ instance_projects_prometheus_active 0.109
+
+ leader_service_desk_issues 15.891
+ instance_service_desk_issues 13.345
+ end
+end
diff --git a/spec/factories/file_uploader.rb b/spec/factories/file_uploaders.rb
index bc74aeecc3b..d397dd705a5 100644
--- a/spec/factories/file_uploader.rb
+++ b/spec/factories/file_uploaders.rb
@@ -1,5 +1,7 @@
FactoryGirl.define do
factory :file_uploader do
+ skip_create
+
project factory: :empty_project
secret nil
diff --git a/spec/factories/forked_project_links.rb b/spec/factories/forked_project_links.rb
index b16c1272e68..66b0f248959 100644
--- a/spec/factories/forked_project_links.rb
+++ b/spec/factories/forked_project_links.rb
@@ -7,5 +7,9 @@ FactoryGirl.define do
link.forked_from_project.reload
link.forked_to_project.reload
end
+
+ trait :forked_to_empty_project do
+ association :forked_to_project, factory: :empty_project
+ end
end
end
diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb
index 4e140102492..a13b6e3596e 100644
--- a/spec/factories/keys.rb
+++ b/spec/factories/keys.rb
@@ -1,27 +1,18 @@
+require_relative '../support/helpers/key_generator_helper'
+
FactoryGirl.define do
factory :key do
title
- key do
- 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0= dummy@gitlab.com'
- end
+ key { Spec::Support::Helpers::KeyGeneratorHelper.new(1024).generate + ' dummy@gitlab.com' }
- factory :deploy_key, class: 'DeployKey' do
- key do
- 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O96x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaCrzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy05qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz'
- end
- end
+ factory :deploy_key, class: 'DeployKey'
factory :personal_key do
user
end
factory :another_key do
- key do
- 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmTillFzNTrrGgwaCKaSj+QCz81E6jBc/s9av0+3b1Hwfxgkqjl4nAK/OD2NjgyrONDTDfR8cRN4eAAy6nY8GLkOyYBDyuc5nTMqs5z3yVuTwf3koGm/YQQCmo91psZ2BgDFTor8SVEE5Mm1D1k3JDMhDFxzzrOtRYFPci9lskTJaBjpqWZ4E9rDTD2q/QZntCqbC3wE9uSemRQB5f8kik7vD/AD8VQXuzKladrZKkzkONCPWsXDspUitjM8HkQdOf0PsYn1CMUC1xKYbCxkg5TkEosIwGv6CoEArUrdu/4+10LVslq494mAvEItywzrluCLCnwELfW+h/m8UHoVhZ'
- end
-
- factory :another_deploy_key, class: 'DeployKey' do
- end
+ factory :another_deploy_key, class: 'DeployKey'
end
factory :write_access_key, class: 'DeployKey' do
diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb
index f6a78811cbe..48142d3c49b 100644
--- a/spec/factories/lists.rb
+++ b/spec/factories/lists.rb
@@ -6,6 +6,12 @@ FactoryGirl.define do
sequence(:position)
end
+ factory :backlog_list, parent: :list do
+ list_type :backlog
+ label nil
+ position nil
+ end
+
factory :closed_list, parent: :list do
list_type :closed
label nil
diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb
index 72d43096216..6c2ed7c6581 100644
--- a/spec/factories/project_statistics.rb
+++ b/spec/factories/project_statistics.rb
@@ -1,6 +1,10 @@
FactoryGirl.define do
factory :project_statistics do
- project { create :project }
- namespace { project.namespace }
+ project
+
+ initialize_with do
+ # statistics are automatically created when a project is created
+ project&.statistics || new
+ end
end
end
diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb
index a3403fd76ae..ae222d5e69a 100644
--- a/spec/factories/project_wikis.rb
+++ b/spec/factories/project_wikis.rb
@@ -1,5 +1,7 @@
FactoryGirl.define do
factory :project_wiki do
+ skip_create
+
project factory: :empty_project
user factory: :user
initialize_with { new(project, user) }
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index e8a9b688319..e17e50db143 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -1,3 +1,5 @@
+require_relative '../support/test_env'
+
FactoryGirl.define do
# Project without repository
#
@@ -24,6 +26,22 @@ FactoryGirl.define do
visibility_level Gitlab::VisibilityLevel::PRIVATE
end
+ trait :import_scheduled do
+ import_status :scheduled
+ end
+
+ trait :import_started do
+ import_status :started
+ end
+
+ trait :import_finished do
+ import_status :finished
+ end
+
+ trait :import_failed do
+ import_status :failed
+ end
+
trait :archived do
archived true
end
diff --git a/spec/factories/snippets.rb b/spec/factories/snippets.rb
index 18cb0f5de26..388f662e6e5 100644
--- a/spec/factories/snippets.rb
+++ b/spec/factories/snippets.rb
@@ -3,6 +3,7 @@ FactoryGirl.define do
author
title { generate(:title) }
content { generate(:title) }
+ description { generate(:title) }
file_name { generate(:filename) }
trait :public do
diff --git a/spec/factories/uploads.rb b/spec/factories/uploads.rb
new file mode 100644
index 00000000000..1383420fb44
--- /dev/null
+++ b/spec/factories/uploads.rb
@@ -0,0 +1,8 @@
+FactoryGirl.define do
+ factory :upload do
+ model { build(:project) }
+ path { "uploads/system/project/avatar/avatar.jpg" }
+ size 100.kilobytes
+ uploader "AvatarUploader"
+ end
+end
diff --git a/spec/factories/wiki_directories.rb b/spec/factories/wiki_directories.rb
index 3f3c864ac2b..3b4cfc380b8 100644
--- a/spec/factories/wiki_directories.rb
+++ b/spec/factories/wiki_directories.rb
@@ -1,5 +1,7 @@
FactoryGirl.define do
factory :wiki_directory do
+ skip_create
+
slug '/path_up_to/dir'
initialize_with { new(slug) }
end
diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb
index 786e1456f5f..09b3c0b0994 100644
--- a/spec/factories_spec.rb
+++ b/spec/factories_spec.rb
@@ -3,14 +3,20 @@ require 'spec_helper'
describe 'factories' do
FactoryGirl.factories.each do |factory|
describe "#{factory.name} factory" do
- let(:entity) { build(factory.name) }
+ it 'does not raise error when built' do
+ expect { build(factory.name) }.not_to raise_error
+ end
it 'does not raise error when created' do
- expect { entity }.not_to raise_error
+ expect { create(factory.name) }.not_to raise_error
end
- it 'is valid', if: factory.build_class < ActiveRecord::Base do
- expect(entity).to be_valid
+ factory.definition.defined_traits.map(&:name).each do |trait_name|
+ describe "linting #{trait_name} trait" do
+ skip 'does not raise error when created' do
+ expect { create(factory.name, trait_name) }.not_to raise_error
+ end
+ end
end
end
end
diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb
index 96d715ef383..595366ce352 100644
--- a/spec/features/admin/admin_appearance_spec.rb
+++ b/spec/features/admin/admin_appearance_spec.rb
@@ -63,11 +63,11 @@ feature 'Admin Appearance', feature: true do
end
def logo_selector
- '//img[@src^="/uploads/appearance/logo"]'
+ '//img[@src^="/uploads/system/appearance/logo"]'
end
def header_logo_selector
- '//img[@src^="/uploads/appearance/header_logo"]'
+ '//img[@src^="/uploads/system/appearance/header_logo"]'
end
def logo_fixture
diff --git a/spec/features/admin/admin_conversational_development_index_spec.rb b/spec/features/admin/admin_conversational_development_index_spec.rb
new file mode 100644
index 00000000000..739ab907a29
--- /dev/null
+++ b/spec/features/admin/admin_conversational_development_index_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe 'Admin Conversational Development Index' do
+ before do
+ login_as :admin
+ end
+
+ context 'when usage ping is disabled' do
+ it 'shows empty state' do
+ stub_application_setting(usage_ping_enabled: false)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content('Usage ping is not enabled')
+ end
+ end
+
+ context 'when there is no data to display' do
+ it 'shows empty state' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content('Data is still calculating')
+ end
+ end
+
+ context 'when there is data to display' do
+ it 'shows numbers for each metric' do
+ stub_application_setting(usage_ping_enabled: true)
+ create(:conversational_development_index_metric)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content(
+ 'Issues created per active user 1.2 You 9.3 Lead 13.3%'
+ )
+ end
+ end
+end
diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb
index c0b6995a84a..5f5fa4e932a 100644
--- a/spec/features/admin/admin_deploy_keys_spec.rb
+++ b/spec/features/admin/admin_deploy_keys_spec.rb
@@ -11,40 +11,67 @@ RSpec.describe 'admin deploy keys', type: :feature do
it 'show all public deploy keys' do
visit admin_deploy_keys_path
- expect(page).to have_content(deploy_key.title)
- expect(page).to have_content(another_deploy_key.title)
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).to have_content(deploy_key.title)
+ expect(page).to have_content(another_deploy_key.title)
+ end
end
- describe 'create new deploy key' do
+ describe 'create a new deploy key' do
+ let(:new_ssh_key) { attributes_for(:key)[:key] }
+
before do
visit admin_deploy_keys_path
click_link 'New deploy key'
end
- it 'creates new deploy key' do
- fill_deploy_key
+ it 'creates a new deploy key' do
+ fill_in 'deploy_key_title', with: 'laptop'
+ fill_in 'deploy_key_key', with: new_ssh_key
+ check 'deploy_key_can_push'
click_button 'Create'
- expect_renders_new_key
- end
+ expect(current_path).to eq admin_deploy_keys_path
- it 'creates new deploy key with write access' do
- fill_deploy_key
- check "deploy_key_can_push"
- click_button "Create"
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).to have_content('laptop')
+ expect(page).to have_content('Yes')
+ end
+ end
+ end
- expect_renders_new_key
- expect(page).to have_content('Yes')
+ describe 'update an existing deploy key' do
+ before do
+ visit admin_deploy_keys_path
+ find('tr', text: deploy_key.title).click_link('Edit')
end
- def expect_renders_new_key
+ it 'updates an existing deploy key' do
+ fill_in 'deploy_key_title', with: 'new-title'
+ check 'deploy_key_can_push'
+ click_button 'Save changes'
+
expect(current_path).to eq admin_deploy_keys_path
- expect(page).to have_content('laptop')
+
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).to have_content('new-title')
+ expect(page).to have_content('Yes')
+ end
end
+ end
- def fill_deploy_key
- fill_in 'deploy_key_title', with: 'laptop'
- fill_in 'deploy_key_key', with: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop'
+ describe 'remove an existing deploy key' do
+ before do
+ visit admin_deploy_keys_path
+ end
+
+ it 'removes an existing deploy key' do
+ find('tr', text: deploy_key.title).click_link('Remove')
+
+ expect(current_path).to eq admin_deploy_keys_path
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).not_to have_content(deploy_key.title)
+ end
end
end
end
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index d5f595894d6..cf9d7bca255 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -24,7 +24,9 @@ feature 'Admin Groups', feature: true do
it 'creates new group' do
visit admin_groups_path
- click_link "New group"
+ page.within '#content-body' do
+ click_link "New group"
+ end
path_component = 'gitlab'
group_name = 'GitLab group name'
group_description = 'Description of group for GitLab'
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 376e80571d0..301a47169a4 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -22,7 +22,8 @@ describe "Admin::Users", feature: true do
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
expect(page).to have_link('Block', href: block_admin_user_path(user))
- expect(page).to have_link('Delete', href: admin_user_path(user))
+ expect(page).to have_link('Remove user', href: admin_user_path(user))
+ expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true))
end
describe 'Two-factor Authentication filters' do
@@ -116,6 +117,9 @@ describe "Admin::Users", feature: true do
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
+ expect(page).to have_link('Block user', href: block_admin_user_path(user))
+ expect(page).to have_link('Remove user', href: admin_user_path(user))
+ expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true))
end
describe 'Impersonation' do
diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb
index 32ac265814f..2b8edac4f10 100644
--- a/spec/features/boards/add_issues_modal_spec.rb
+++ b/spec/features/boards/add_issues_modal_spec.rb
@@ -231,7 +231,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
click_button 'Add 1 issue'
end
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.card')
end
end
@@ -247,7 +247,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
click_button 'Add 1 issue'
end
- page.within(find('.board:nth-child(2)')) do
+ page.within(find('.board:nth-child(3)')) do
expect(page).to have_selector('.card')
end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index ba27db23ced..c80453b8227 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -19,7 +19,7 @@ describe 'Issue Boards', feature: true, js: true do
before do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'shows blank state' do
@@ -36,18 +36,18 @@ describe 'Issue Boards', feature: true, js: true do
page.within(find('.board-blank-state')) do
click_button("Nevermind, I'll use my own")
end
- expect(page).to have_selector('.board', count: 1)
+ expect(page).to have_selector('.board', count: 2)
end
it 'creates default lists' do
- lists = ['To Do', 'Doing', 'Closed']
+ lists = ['Backlog', 'To Do', 'Doing', 'Closed']
page.within(find('.board-blank-state')) do
click_button('Add default lists')
end
wait_for_requests
- expect(page).to have_selector('.board', count: 3)
+ expect(page).to have_selector('.board', count: 4)
page.all('.board').each_with_index do |list, i|
expect(list.find('.board-title')).to have_content(lists[i])
@@ -85,29 +85,25 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- expect(page).to have_selector('.board', count: 3)
- expect(find('.board:nth-child(1)')).to have_selector('.card')
+ expect(page).to have_selector('.board', count: 4)
expect(find('.board:nth-child(2)')).to have_selector('.card')
expect(find('.board:nth-child(3)')).to have_selector('.card')
- end
-
- it 'shows lists' do
- expect(page).to have_selector('.board', count: 3)
+ expect(find('.board:nth-child(4)')).to have_selector('.card')
end
it 'shows description tooltip on list title' do
- page.within('.board:nth-child(1)') do
+ page.within('.board:nth-child(2)') do
expect(find('.board-title span.has-tooltip')[:title]).to eq('Test')
end
end
it 'shows issues in lists' do
- wait_for_board_cards(1, 8)
- wait_for_board_cards(2, 2)
+ wait_for_board_cards(2, 8)
+ wait_for_board_cards(3, 2)
end
it 'shows confidential issues with icon' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.confidential-icon', count: 1)
end
end
@@ -118,9 +114,9 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- expect(find('.board:nth-child(1)')).to have_selector('.card', count: 0)
expect(find('.board:nth-child(2)')).to have_selector('.card', count: 0)
- expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 0)
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1)
end
it 'search list' do
@@ -129,32 +125,32 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- expect(find('.board:nth-child(1)')).to have_selector('.card', count: 1)
- expect(find('.board:nth-child(2)')).to have_selector('.card', count: 0)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
expect(find('.board:nth-child(3)')).to have_selector('.card', count: 0)
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 0)
end
it 'allows user to delete board' do
- page.within(find('.board:nth-child(1)')) do
+ page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'removes checkmark in new list dropdown after deleting' do
click_button 'Add list'
wait_for_requests
- page.within(find('.board:nth-child(1)')) do
+ page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'infinite scrolls list' do
@@ -165,18 +161,18 @@ describe 'Issue Boards', feature: true, js: true do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_requests
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('58')
expect(page).to have_selector('.card', count: 20)
expect(page).to have_content('Showing 20 of 58 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
wait_for_requests
expect(page).to have_selector('.card', count: 40)
expect(page).to have_content('Showing 40 of 58 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
wait_for_requests
expect(page).to have_selector('.card', count: 58)
@@ -186,83 +182,83 @@ describe 'Issue Boards', feature: true, js: true do
context 'closed' do
it 'shows list of closed issues' do
- wait_for_board_cards(3, 1)
+ wait_for_board_cards(4, 1)
wait_for_requests
end
it 'moves issue to closed' do
- drag(list_from_index: 0, list_to_index: 2)
+ drag(list_from_index: 1, list_to_index: 3)
- wait_for_board_cards(1, 7)
- wait_for_board_cards(2, 2)
+ wait_for_board_cards(2, 7)
wait_for_board_cards(3, 2)
+ wait_for_board_cards(4, 2)
- expect(find('.board:nth-child(1)')).not_to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).to have_selector('.card', count: 2)
- expect(find('.board:nth-child(3)')).to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).not_to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).not_to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2)
+ expect(find('.board:nth-child(4)')).to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
end
it 'removes all of the same issue to closed' do
- drag(list_from_index: 0, list_to_index: 2)
+ drag(list_from_index: 1, list_to_index: 3)
- wait_for_board_cards(1, 7)
- wait_for_board_cards(2, 2)
+ wait_for_board_cards(2, 7)
wait_for_board_cards(3, 2)
+ wait_for_board_cards(4, 2)
- expect(find('.board:nth-child(1)')).not_to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).not_to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).not_to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
end
end
context 'lists' do
it 'changes position of list' do
- drag(list_from_index: 1, list_to_index: 0, selector: '.board-header')
+ drag(list_from_index: 2, list_to_index: 1, selector: '.board-header')
- wait_for_board_cards(1, 2)
- wait_for_board_cards(2, 8)
- wait_for_board_cards(3, 1)
+ wait_for_board_cards(2, 2)
+ wait_for_board_cards(3, 8)
+ wait_for_board_cards(4, 1)
- expect(find('.board:nth-child(1)')).to have_content(development.title)
- expect(find('.board:nth-child(1)')).to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).to have_content(development.title)
+ expect(find('.board:nth-child(2)')).to have_content(planning.title)
end
it 'issue moves between lists' do
- drag(list_from_index: 0, from_index: 1, list_to_index: 1)
+ drag(list_from_index: 1, from_index: 1, list_to_index: 2)
- wait_for_board_cards(1, 7)
- wait_for_board_cards(2, 2)
- wait_for_board_cards(3, 1)
+ wait_for_board_cards(2, 7)
+ wait_for_board_cards(3, 2)
+ wait_for_board_cards(4, 1)
- expect(find('.board:nth-child(2)')).to have_content(issue6.title)
- expect(find('.board:nth-child(2)').all('.card').last).not_to have_content(development.title)
+ expect(find('.board:nth-child(3)')).to have_content(issue6.title)
+ expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title)
end
it 'issue moves between lists' do
- drag(list_from_index: 1, list_to_index: 0)
+ drag(list_from_index: 2, list_to_index: 1)
- wait_for_board_cards(1, 9)
- wait_for_board_cards(2, 1)
+ wait_for_board_cards(2, 9)
wait_for_board_cards(3, 1)
+ wait_for_board_cards(4, 1)
- expect(find('.board:nth-child(1)')).to have_content(issue7.title)
- expect(find('.board:nth-child(1)').all('.card').first).not_to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).to have_content(issue7.title)
+ expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title)
end
it 'issue moves from closed' do
- drag(list_from_index: 2, list_to_index: 1)
+ drag(list_from_index: 3, list_to_index: 2)
- expect(find('.board:nth-child(2)')).to have_content(issue8.title)
+ expect(find('.board:nth-child(3)')).to have_content(issue8.title)
- wait_for_board_cards(1, 8)
- wait_for_board_cards(2, 3)
- wait_for_board_cards(3, 0)
+ wait_for_board_cards(2, 8)
+ wait_for_board_cards(3, 3)
+ wait_for_board_cards(4, 0)
end
context 'issue card' do
it 'shows assignee' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.avatar', count: 1)
end
end
@@ -290,7 +286,7 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
it 'creates new list for Backlog label' do
@@ -303,7 +299,7 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
it 'creates new list for Closed label' do
@@ -316,7 +312,7 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
it 'keeps dropdown open after adding new list' do
@@ -348,7 +344,7 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
end
end
@@ -360,8 +356,8 @@ describe 'Issue Boards', feature: true, js: true do
submit_filter
wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by assignee' do
@@ -371,8 +367,8 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by milestone' do
@@ -381,9 +377,9 @@ describe 'Issue Boards', feature: true, js: true do
submit_filter
wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_board_cards(2, 0)
+ wait_for_board_cards(2, 1)
wait_for_board_cards(3, 0)
+ wait_for_board_cards(4, 0)
end
it 'filters by label' do
@@ -392,8 +388,8 @@ describe 'Issue Boards', feature: true, js: true do
submit_filter
wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by label with space after reload' do
@@ -403,17 +399,17 @@ describe 'Issue Boards', feature: true, js: true do
# Test after reload
page.evaluate_script 'window.location.reload()'
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
wait_for_requests
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
- page.within(find('.board:nth-child(2)')) do
+ page.within(find('.board:nth-child(3)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
@@ -424,12 +420,12 @@ describe 'Issue Boards', feature: true, js: true do
click_filter_link(testing.title)
submit_filter
- wait_for_board_cards(1, 1)
+ wait_for_board_cards(2, 1)
find('.clear-search').click
submit_filter
- wait_for_board_cards(1, 8)
+ wait_for_board_cards(2, 8)
end
it 'infinite scrolls list with label filter' do
@@ -443,17 +439,17 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('51')
expect(page).to have_selector('.card', count: 20)
expect(page).to have_content('Showing 20 of 51 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
expect(page).to have_selector('.card', count: 40)
expect(page).to have_content('Showing 40 of 51 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
expect(page).to have_selector('.card', count: 51)
expect(page).to have_content('Showing all issues')
@@ -471,12 +467,12 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by clicking label button on issue' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.card', count: 8)
expect(find('.card', match: :first)).to have_content(bug.title)
click_button(bug.title)
@@ -489,12 +485,12 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'removes label filter by clicking label button on issue' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
page.within(find('.card', match: :first)) do
click_button(bug.title)
end
diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb
index 6c40cb2c9eb..1c289993e28 100644
--- a/spec/features/boards/issue_ordering_spec.rb
+++ b/spec/features/boards/issue_ordering_spec.rb
@@ -25,11 +25,11 @@ describe 'Issue Boards', :feature, :js do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'has un-ordered issue as last issue' do
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
expect(all('.card').last).to have_content(issue4.title)
end
end
@@ -39,7 +39,7 @@ describe 'Issue Boards', :feature, :js do
wait_for_requests
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
expect(first('.card')).to have_content(issue4.title)
end
end
@@ -50,7 +50,7 @@ describe 'Issue Boards', :feature, :js do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'moves from middle to top' do
@@ -113,50 +113,50 @@ describe 'Issue Boards', :feature, :js do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_requests
- expect(page).to have_selector('.board', count: 3)
+ expect(page).to have_selector('.board', count: 4)
end
it 'moves to top of another list' do
- drag(list_from_index: 0, list_to_index: 1)
+ drag(list_from_index: 1, list_to_index: 2)
wait_for_requests
- expect(first('.board')).to have_selector('.card', count: 2)
- expect(all('.board')[1]).to have_selector('.card', count: 4)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2)
+ expect(all('.board')[2]).to have_selector('.card', count: 4)
- page.within(all('.board')[1]) do
+ page.within(all('.board')[2]) do
expect(first('.card')).to have_content(issue3.title)
end
end
it 'moves to bottom of another list' do
- drag(list_from_index: 0, list_to_index: 1, to_index: 2)
+ drag(list_from_index: 1, list_to_index: 2, to_index: 2)
wait_for_requests
- expect(first('.board')).to have_selector('.card', count: 2)
- expect(all('.board')[1]).to have_selector('.card', count: 4)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2)
+ expect(all('.board')[2]).to have_selector('.card', count: 4)
- page.within(all('.board')[1]) do
+ page.within(all('.board')[2]) do
expect(all('.card').last).to have_content(issue3.title)
end
end
it 'moves to index of another list' do
- drag(list_from_index: 0, list_to_index: 1, to_index: 1)
+ drag(list_from_index: 1, list_to_index: 2, to_index: 1)
wait_for_requests
- expect(first('.board')).to have_selector('.card', count: 2)
- expect(all('.board')[1]).to have_selector('.card', count: 4)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2)
+ expect(all('.board')[2]).to have_selector('.card', count: 4)
- page.within(all('.board')[1]) do
+ page.within(all('.board')[2]) do
expect(all('.card')[1]).to have_content(issue3.title)
end
end
end
- def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0)
+ def drag(selector: '.board-list', list_from_index: 1, from_index: 0, to_index: 0, list_to_index: 1)
drag_to(selector: selector,
scrollable: '#board-app',
list_from_index: list_from_index,
diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb
index 0e98f994018..056224dc436 100644
--- a/spec/features/boards/new_issue_spec.rb
+++ b/spec/features/boards/new_issue_spec.rb
@@ -15,15 +15,15 @@ describe 'Issue Boards new issue', feature: true, js: true do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'displays new issue button' do
- expect(page).to have_selector('.board-issue-count-holder .btn', count: 1)
+ expect(first('.board')).to have_selector('.board-issue-count-holder .btn', count: 1)
end
it 'does not display new issue button in closed list' do
- page.within('.board:nth-child(2)') do
+ page.within('.board:nth-child(3)') do
expect(page).not_to have_selector('.board-issue-count-holder .btn')
end
end
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index 34f4d765117..235e4899707 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -13,7 +13,7 @@ describe 'Issue Boards', feature: true, js: true do
let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) }
let(:board) { create(:board, project: project) }
let!(:list) { create(:list, board: board, label: development, position: 0) }
- let(:card) { first('.board').first('.card') }
+ let(:card) { find('.board:nth-child(2)').first('.card') }
before do
Timecop.freeze
@@ -74,7 +74,7 @@ describe 'Issue Boards', feature: true, js: true do
wait_for_requests
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.card', count: 1)
end
end
@@ -101,7 +101,7 @@ describe 'Issue Boards', feature: true, js: true do
end
it 'removes the assignee' do
- card_two = first('.board').find('.card:nth-child(2)')
+ card_two = find('.board:nth-child(2)').find('.card:nth-child(2)')
click_card(card_two)
page.within('.assignee') do
@@ -154,7 +154,7 @@ describe 'Issue Boards', feature: true, js: true do
expect(page).to have_content(user.name)
end
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
find('.card:nth-child(2)').trigger('click')
end
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index b0e2953dda2..7eb254f8451 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -6,40 +6,124 @@ describe 'Dashboard Groups page', js: true, feature: true do
let!(:nested_group) { create(:group, :nested) }
let!(:another_group) { create(:group) }
- before do
+ it 'shows groups user is member of' do
group.add_owner(user)
nested_group.add_owner(user)
login_as(user)
-
visit dashboard_groups_path
- end
- it 'shows groups user is member of' do
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)
end
- it 'filters groups' do
- fill_in 'filter_groups', with: group.name
- wait_for_requests
+ describe 'when filtering groups' do
+ before do
+ group.add_owner(user)
+ nested_group.add_owner(user)
- 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)
+ login_as(user)
+
+ visit dashboard_groups_path
+ end
+
+ it 'filters groups' do
+ fill_in 'filter_groups', with: 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)
+ end
+
+ it 'resets search when user cleans the input' do
+ fill_in 'filter_groups', with: group.name
+ wait_for_requests
+
+ fill_in 'filter_groups', 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.all('.js-groups-list-holder .content-list li').length).to eq 2
+ end
end
- it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
- wait_for_requests
+ describe 'group with subgroups' do
+ let!(:subgroup) { create(:group, :public, parent: group) }
- fill_in 'filter_groups', with: ""
- wait_for_requests
+ before do
+ group.add_owner(user)
+ subgroup.add_owner(user)
- 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.all('.js-groups-list-holder .content-list li').length).to eq 2
+ login_as(user)
+
+ visit dashboard_groups_path
+ 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)
+ 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")
+
+ # Collapse
+ find("#group-#{group.id}").trigger('click')
+
+ 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}")
+
+ # Expand
+ find("#group-#{group.id}").trigger('click')
+
+ 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}")
+ end
+ end
+
+ describe 'when using pagination' do
+ let(:group2) { create(:group) }
+
+ before do
+ group.add_owner(user)
+ group2.add_owner(user)
+
+ allow(Kaminari.config).to receive(:default_per_page).and_return(1)
+
+ login_as(user)
+ visit dashboard_groups_path
+ end
+
+ it 'shows pagination' do
+ expect(page).to have_selector('.gl-pagination')
+ expect(page).to have_selector('.gl-pagination .page', count: 2)
+ end
+
+ it 'loads results for next page' do
+ # Check first page
+ expect(page).to have_content(group2.full_name)
+ expect(page).to have_selector("#group-#{group2.id}")
+ expect(page).not_to have_content(group.full_name)
+ expect(page).not_to have_selector("#group-#{group.id}")
+
+ # Go to next page
+ find(".gl-pagination .page:not(.active) a").trigger('click')
+
+ wait_for_requests
+
+ # Check second page
+ expect(page).to have_content(group.full_name)
+ expect(page).to have_selector("#group-#{group.id}")
+ expect(page).not_to have_content(group2.full_name)
+ expect(page).not_to have_selector("#group-#{group2.id}")
+ end
end
end
diff --git a/spec/features/dashboard/milestone_tabs_spec.rb b/spec/features/dashboard/milestone_tabs_spec.rb
new file mode 100644
index 00000000000..0c7b992c500
--- /dev/null
+++ b/spec/features/dashboard/milestone_tabs_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe 'Dashboard milestone tabs', :js, :feature do
+ let(:user) { create(:user) }
+ let(:project) { create(:empty_project) }
+ let!(:label) { create(:label, project: project) }
+ let(:project_milestone) { create(:milestone, project: project) }
+ let(:milestone) do
+ DashboardMilestone.build(
+ [project],
+ project_milestone.title
+ )
+ end
+ let!(:merge_request) { create(:labeled_merge_request, source_project: project, target_project: project, milestone: project_milestone, labels: [label]) }
+
+ before do
+ project.add_master(user)
+ login_as(user)
+
+ visit dashboard_milestone_path(milestone.safe_title, title: milestone.title)
+ end
+
+ it 'loads merge requests async' do
+ click_link 'Merge Requests'
+
+ expect(page).to have_selector('.merge_requests-sortable-list')
+ end
+
+ it 'loads participants async' do
+ click_link 'Participants'
+
+ expect(page).to have_selector('#tab-participants .bordered-list')
+ end
+
+ it 'loads labels async' do
+ click_link 'Labels'
+
+ expect(page).to have_selector('#tab-labels .bordered-list')
+ end
+end
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index fa3435ab719..3568954a548 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -15,6 +15,15 @@ RSpec.describe 'Dashboard Projects', feature: true do
expect(page).to have_content('awesome stuff')
end
+ it 'shows the last_activity_at attribute as the update date' do
+ now = Time.now
+ project.update_column(:last_activity_at, now)
+
+ visit dashboard_projects_path
+
+ expect(page).to have_xpath("//time[@datetime='#{now.getutc.iso8601}']")
+ end
+
context 'when on Starred projects tab' do
it 'shows only starred projects' do
user.toggle_star(project2)
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 0cb75538311..c4d5077e5e1 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -5,6 +5,11 @@ feature 'Expand and collapse diffs', js: true, feature: true do
let(:project) { create(:project, :repository) }
before do
+ # Set the limits to those when these specs were written, to avoid having to
+ # update the test repo every time we change them.
+ allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes)
+ allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes)
+
login_as :admin
# Ensure that undiffable.md is in .gitattributes
@@ -62,18 +67,6 @@ feature 'Expand and collapse diffs', js: true, feature: true do
expect(small_diff).not_to have_selector('.nothing-here-block')
end
- it 'collapses large diffs by default' do
- expect(large_diff).not_to have_selector('.code')
- expect(large_diff).to have_selector('.nothing-here-block')
- end
-
- it 'collapses large diffs for renamed files by default' do
- expect(large_diff_renamed).not_to have_selector('.code')
- expect(large_diff_renamed).to have_selector('.nothing-here-block')
- expect(large_diff_renamed).to have_selector('.js-file-title .deletion')
- expect(large_diff_renamed).to have_selector('.js-file-title .addition')
- end
-
it 'shows non-renderable diffs as such immediately, regardless of their size' do
expect(undiffable).not_to have_selector('.code')
expect(undiffable).to have_selector('.nothing-here-block')
diff --git a/spec/features/explore/new_menu_spec.rb b/spec/features/explore/new_menu_spec.rb
new file mode 100644
index 00000000000..15a6354211b
--- /dev/null
+++ b/spec/features/explore/new_menu_spec.rb
@@ -0,0 +1,172 @@
+require 'spec_helper'
+
+feature 'Top Plus Menu', feature: true, js: true do
+ let(:user) { create :user }
+ let(:guest_user) { create :user}
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :repository, creator: user, namespace: user.namespace) }
+ let(:public_project) { create(:project, :public) }
+
+ before do
+ group.add_owner(user)
+ group.add_guest(guest_user)
+
+ project.add_guest(guest_user)
+ end
+
+ context 'used by full user' do
+ before do
+ login_as(user)
+ end
+
+ scenario 'click on New project shows new project page' do
+ visit root_dashboard_path
+
+ click_topmenuitem("New project")
+
+ expect(page).to have_content('Project path')
+ expect(page).to have_content('Project name')
+ end
+
+ scenario 'click on New group shows new group page' do
+ visit root_dashboard_path
+
+ click_topmenuitem("New group")
+
+ expect(page).to have_content('Group path')
+ expect(page).to have_content('Group name')
+ end
+
+ scenario 'click on New snippet shows new snippet page' do
+ visit root_dashboard_path
+
+ click_topmenuitem("New snippet")
+
+ expect(page).to have_content('New Snippet')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'click on New issue shows new issue page' do
+ visit namespace_project_path(project.namespace, project)
+
+ click_topmenuitem("New issue")
+
+ expect(page).to have_content('New Issue')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'click on New merge request shows new merge request page' do
+ visit namespace_project_path(project.namespace, project)
+
+ click_topmenuitem("New merge request")
+
+ expect(page).to have_content('New Merge Request')
+ expect(page).to have_content('Source branch')
+ expect(page).to have_content('Target branch')
+ end
+
+ scenario 'click on New project snippet shows new snippet page' do
+ visit namespace_project_path(project.namespace, project)
+
+ page.within '.header-content' do
+ find('.header-new-dropdown-toggle').trigger('click')
+ expect(page).to have_selector('.header-new.dropdown.open', count: 1)
+ find('.header-new-project-snippet a').trigger('click')
+ end
+
+ expect(page).to have_content('New Snippet')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'Click on New subgroup shows new group page' do
+ visit group_path(group)
+
+ click_topmenuitem("New subgroup")
+
+ expect(page).to have_content('Group path')
+ expect(page).to have_content('Group name')
+ end
+
+ scenario 'Click on New project in group shows new project page' do
+ visit group_path(group)
+
+ page.within '.header-content' do
+ find('.header-new-dropdown-toggle').trigger('click')
+ expect(page).to have_selector('.header-new.dropdown.open', count: 1)
+ find('.header-new-group-project a').trigger('click')
+ end
+
+ expect(page).to have_content('Project path')
+ expect(page).to have_content('Project name')
+ end
+ end
+
+ context 'used by guest user' do
+ before do
+ login_as(guest_user)
+ end
+
+ scenario 'click on New issue shows new issue page' do
+ visit namespace_project_path(project.namespace, project)
+
+ click_topmenuitem("New issue")
+
+ expect(page).to have_content('New Issue')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'has no New merge request menu item' do
+ visit namespace_project_path(project.namespace, project)
+
+ hasnot_topmenuitem("New merge request")
+ end
+
+ scenario 'has no New project snippet menu item' do
+ visit namespace_project_path(project.namespace, project)
+
+ expect(find('.header-new.dropdown')).not_to have_selector('.header-new-project-snippet')
+ end
+
+ scenario 'public project has no New Issue Button' do
+ visit namespace_project_path(public_project.namespace, public_project)
+
+ hasnot_topmenuitem("New issue")
+ end
+
+ scenario 'public project has no New merge request menu item' do
+ visit namespace_project_path(public_project.namespace, public_project)
+
+ hasnot_topmenuitem("New merge request")
+ end
+
+ scenario 'public project has no New project snippet menu item' do
+ visit namespace_project_path(public_project.namespace, public_project)
+
+ expect(find('.header-new.dropdown')).not_to have_selector('.header-new-project-snippet')
+ end
+
+ scenario 'has no New subgroup menu item' do
+ visit group_path(group)
+
+ hasnot_topmenuitem("New subgroup")
+ end
+
+ scenario 'has no New project for group menu item' do
+ visit group_path(group)
+
+ expect(find('.header-new.dropdown')).not_to have_selector('.header-new-group-project')
+ end
+ end
+
+ def click_topmenuitem(item_name)
+ page.within '.header-content' do
+ find('.header-new-dropdown-toggle').trigger('click')
+ expect(page).to have_selector('.header-new.dropdown.open', count: 1)
+ click_link item_name
+ end
+ end
+
+ def hasnot_topmenuitem(item_name)
+ expect(find('.header-new.dropdown')).not_to have_content(item_name)
+ end
+end
diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb
index 0a6f645b27e..95b4930cd32 100644
--- a/spec/features/issues/bulk_assignment_labels_spec.rb
+++ b/spec/features/issues/bulk_assignment_labels_spec.rb
@@ -18,13 +18,13 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'can bulk assign' do
before do
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
context 'a label' do
context 'to all issues' do
before do
- check 'check_all_issues'
+ check 'check-all-issues'
open_labels_dropdown ['bug']
update_issues
end
@@ -52,7 +52,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'multiple labels' do
context 'to all issues' do
before do
- check 'check_all_issues'
+ check 'check-all-issues'
open_labels_dropdown %w(bug feature)
update_issues
end
@@ -86,9 +86,10 @@ feature 'Issues > Labels bulk assignment', feature: true do
before do
issue2.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
- check 'check_all_issues'
+ enable_bulk_update
+ check 'check-all-issues'
+
open_labels_dropdown ['bug']
update_issues
end
@@ -107,9 +108,8 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue2.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
-
- check 'check_all_issues'
+ enable_bulk_update
+ check 'check-all-issues'
unmark_labels_in_dropdown %w(bug feature)
update_issues
end
@@ -127,8 +127,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
-
+ enable_bulk_update
check_issue issue1
unmark_labels_in_dropdown ['bug']
update_issues
@@ -147,8 +146,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue2.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
-
+ enable_bulk_update
check_issue issue1
check_issue issue2
unmark_labels_in_dropdown ['bug']
@@ -171,14 +169,15 @@ feature 'Issues > Labels bulk assignment', feature: true do
before do
issue1.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps labels' do
expect(find("#issue_#{issue1.id}")).to have_content 'bug'
expect(find("#issue_#{issue2.id}")).to have_content 'feature'
- check 'check_all_issues'
+ check 'check-all-issues'
+
open_milestone_dropdown(['First Release'])
update_issues
@@ -192,14 +191,13 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'setting a milestone and adding another label' do
before do
issue1.labels << bug
-
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps existing label and new label is present' do
expect(find("#issue_#{issue1.id}")).to have_content 'bug'
- check 'check_all_issues'
+ check 'check-all-issues'
open_milestone_dropdown ['First Release']
open_labels_dropdown ['feature']
update_issues
@@ -218,7 +216,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << feature
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps existing label and new label is present' do
@@ -226,7 +224,8 @@ feature 'Issues > Labels bulk assignment', feature: true do
expect(find("#issue_#{issue1.id}")).to have_content 'bug'
expect(find("#issue_#{issue2.id}")).to have_content 'feature'
- check 'check_all_issues'
+ check 'check-all-issues'
+
open_milestone_dropdown ['First Release']
unmark_labels_in_dropdown ['feature']
update_issues
@@ -248,7 +247,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps labels' do
@@ -257,7 +256,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
expect(find("#issue_#{issue2.id}")).to have_content 'feature'
expect(find("#issue_#{issue2.id}")).to have_content 'First Release'
- check 'check_all_issues'
+ check 'check-all-issues'
open_milestone_dropdown(['No Milestone'])
update_issues
@@ -272,8 +271,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'toggling checked issues' do
before do
issue1.labels << bug
-
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it do
@@ -298,14 +296,14 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << feature
issue2.labels << bug
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'applies label from filtered results' do
- check 'check_all_issues'
+ check 'check-all-issues'
- page.within('.issues_bulk_update') do
- click_button 'Labels'
+ page.within('.issues-bulk-update') do
+ click_button 'Select labels'
wait_for_requests
expect(find('.dropdown-menu-labels li', text: 'bug')).to have_css('.is-active')
@@ -340,15 +338,16 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'cannot bulk assign labels' do
it do
- expect(page).not_to have_css '.check_all_issues'
+ expect(page).not_to have_button 'Edit Issues'
+ expect(page).not_to have_css '.check-all-issues'
expect(page).not_to have_css '.issue-check'
end
end
end
def open_milestone_dropdown(items = [])
- page.within('.issues_bulk_update') do
- click_button 'Milestone'
+ page.within('.issues-bulk-update') do
+ click_button 'Select milestone'
wait_for_requests
items.map do |item|
click_link item
@@ -357,8 +356,8 @@ feature 'Issues > Labels bulk assignment', feature: true do
end
def open_labels_dropdown(items = [], unmark = false)
- page.within('.issues_bulk_update') do
- click_button 'Labels'
+ page.within('.issues-bulk-update') do
+ click_button 'Select labels'
wait_for_requests
items.map do |item|
click_link item
@@ -391,7 +390,12 @@ feature 'Issues > Labels bulk assignment', feature: true do
end
def update_issues
- click_button 'Update issues'
+ click_button 'Update all'
wait_for_requests
end
+
+ def enable_bulk_update
+ visit namespace_project_issues_path(project.namespace, project)
+ click_button 'Edit Issues'
+ end
end
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index e5e4ba06b5a..863f8f75cd8 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -777,17 +777,17 @@ describe 'Filter issues', js: true, feature: true do
end
it 'open state' do
- find('.issues-state-filters a', text: 'Closed').click
+ find('.issues-state-filters [data-state="closed"]').click
wait_for_requests
- find('.issues-state-filters a', text: 'Open').click
+ find('.issues-state-filters [data-state="opened"]').click
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 4)
end
it 'closed state' do
- find('.issues-state-filters a', text: 'Closed').click
+ find('.issues-state-filters [data-state="closed"]').click
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 1)
@@ -795,7 +795,7 @@ describe 'Filter issues', js: true, feature: true do
end
it 'all state' do
- find('.issues-state-filters a', text: 'All').click
+ find('.issues-state-filters [data-state="all"]').click
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 5)
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index dbbafc9e004..ff32b0c7d11 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -34,7 +34,7 @@ describe 'Visual tokens', js: true, feature: true do
describe 'editing author token' do
before do
input_filtered_search('author:@root assignee:none', submit: false)
- first('.tokens-container .filtered-search-token').double_click
+ first('.tokens-container .filtered-search-token').click
end
it 'opens author dropdown' do
@@ -331,7 +331,7 @@ describe 'Visual tokens', js: true, feature: true do
it 'does not tokenize incomplete token' do
filtered_search.send_keys('author:')
- find('#content-body').click
+ find('body').click
token = page.all('.tokens-container .js-visual-token')[1]
expect_filtered_search_input_empty
diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb
index 8949dbcb663..96d37e33f3d 100644
--- a/spec/features/issues/form_spec.rb
+++ b/spec/features/issues/form_spec.rb
@@ -24,37 +24,17 @@ describe 'New/edit issue', :feature, :js do
visit new_namespace_project_issue_path(project.namespace, project)
end
- describe 'shorten users API pagination limit' do
+ describe 'shorten users API pagination limit (CE)' do
before do
+ # Using `allow_any_instance_of`/`and_wrap_original`, `original` would
+ # somehow refer to the very block we defined to _wrap_ that method, instead of
+ # the original method, resulting in infinite recurison when called.
+ # This is likely a bug with helper modules included into dynamically generated view classes.
+ # To work around this, we have to hold on to and call to the original implementation manually.
+ original_issue_dropdown_options = FormHelper.instance_method(:issue_dropdown_options)
allow_any_instance_of(FormHelper).to receive(:issue_dropdown_options).and_wrap_original do |original, *args|
- has_multiple_assignees = *args[1]
-
- options = {
- toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
- title: 'Select assignee',
- filter: true,
- dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
- placeholder: 'Search users',
- data: {
- per_page: 1,
- null_user: true,
- current_user: true,
- project_id: project.try(:id),
- field_name: "issue[assignee_ids][]",
- default_label: 'Assignee',
- 'max-select': 1,
- 'dropdown-header': 'Assignee',
- multi_select: true,
- 'input-meta': 'name',
- 'always-show-selectbox': true
- }
- }
-
- if has_multiple_assignees
- options[:title] = 'Select assignee(s)'
- options[:data][:'dropdown-header'] = 'Assignee(s)'
- options[:data].delete(:'max-select')
- end
+ options = original_issue_dropdown_options.bind(original.receiver).call(*args)
+ options[:data][:per_page] = 2
options
end
@@ -74,6 +54,7 @@ describe 'New/edit issue', :feature, :js do
click_link user2.name
end
+ find('.js-assignee-search').click
find('.js-dropdown-input-clear').click
page.within '.dropdown-menu-user' do
@@ -83,7 +64,7 @@ describe 'New/edit issue', :feature, :js do
end
end
- describe 'single assignee' do
+ describe 'single assignee (CE)' do
before do
click_button 'Unassigned'
diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb
index 80f57906506..2c0a6ffd3cb 100644
--- a/spec/features/issues/note_polling_spec.rb
+++ b/spec/features/issues/note_polling_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
feature 'Issue notes polling', :feature, :js do
+ include NoteInteractionHelpers
+
let(:project) { create(:empty_project, :public) }
let(:issue) { create(:issue, project: project) }
@@ -48,7 +50,7 @@ feature 'Issue notes polling', :feature, :js do
end
it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do
- find("#note_#{existing_note.id} .js-note-edit").click
+ click_edit_action(existing_note)
expect(page).to have_field("note[note]", with: note_text)
@@ -58,19 +60,18 @@ feature 'Issue notes polling', :feature, :js do
end
it 'when editing but you changed some things, and an update comes in, show a warning' do
- find("#note_#{existing_note.id} .js-note-edit").click
+ click_edit_action(existing_note)
expect(page).to have_field("note[note]", with: note_text)
find("#note_#{existing_note.id} .js-note-text").set('something random')
-
update_note(existing_note, updated_text)
expect(page).to have_selector(".alert")
end
it 'when editing but you changed some things, an update comes in, and you press cancel, show the updated content' do
- find("#note_#{existing_note.id} .js-note-edit").click
+ click_edit_action(existing_note)
expect(page).to have_field("note[note]", with: note_text)
@@ -128,4 +129,12 @@ feature 'Issue notes polling', :feature, :js do
note.update(note: new_text)
page.execute_script('notes.refresh();')
end
+
+ def click_edit_action(note)
+ note_element = find("#note_#{note.id}")
+
+ open_more_actions_dropdown(note)
+
+ note_element.find('.js-note-edit').click
+ end
end
diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb
index 0911f1db9ba..8595847d313 100644
--- a/spec/features/issues/update_issues_spec.rb
+++ b/spec/features/issues/update_issues_spec.rb
@@ -14,7 +14,8 @@ feature 'Multiple issue updating from issues#index', feature: true do
it 'sets to closed' do
visit namespace_project_issues_path(project.namespace, project)
- find('#check_all_issues').click
+ click_button 'Edit Issues'
+ find('#check-all-issues').click
find('.js-issue-status').click
find('.dropdown-menu-status a', text: 'Closed').click
@@ -26,7 +27,8 @@ feature 'Multiple issue updating from issues#index', feature: true do
create_closed
visit namespace_project_issues_path(project.namespace, project, state: 'closed')
- find('#check_all_issues').click
+ click_button 'Edit Issues'
+ find('#check-all-issues').click
find('.js-issue-status').click
find('.dropdown-menu-status a', text: 'Open').click
@@ -39,7 +41,8 @@ feature 'Multiple issue updating from issues#index', feature: true do
it 'updates to current user' do
visit namespace_project_issues_path(project.namespace, project)
- find('#check_all_issues').click
+ click_button 'Edit Issues'
+ find('#check-all-issues').click
click_update_assignee_button
find('.dropdown-menu-user-link', text: user.username).click
@@ -54,7 +57,8 @@ feature 'Multiple issue updating from issues#index', feature: true do
create_assigned
visit namespace_project_issues_path(project.namespace, project)
- find('#check_all_issues').click
+ click_button 'Edit Issues'
+ find('#check-all-issues').click
click_update_assignee_button
click_link 'Unassigned'
@@ -69,8 +73,9 @@ feature 'Multiple issue updating from issues#index', feature: true do
it 'updates milestone' do
visit namespace_project_issues_path(project.namespace, project)
- find('#check_all_issues').click
- find('.issues_bulk_update .js-milestone-select').click
+ click_button 'Edit Issues'
+ find('#check-all-issues').click
+ find('.issues-bulk-update .js-milestone-select').click
find('.dropdown-menu-milestone a', text: milestone.title).click
click_update_issues_button
@@ -84,8 +89,9 @@ feature 'Multiple issue updating from issues#index', feature: true do
expect(first('.issue')).to have_content milestone.title
- find('#check_all_issues').click
- find('.issues_bulk_update .js-milestone-select').click
+ click_button 'Edit Issues'
+ find('#check-all-issues').click
+ find('.issues-bulk-update .js-milestone-select').click
find('.dropdown-menu-milestone a', text: "No Milestone").click
click_update_issues_button
@@ -112,7 +118,7 @@ feature 'Multiple issue updating from issues#index', feature: true do
end
def click_update_issues_button
- find('.update_selected_issues').click
+ find('.update-selected-issues').click
wait_for_requests
end
end
diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb
index 7f669565085..27e2d5d16f3 100644
--- a/spec/features/merge_requests/conflicts_spec.rb
+++ b/spec/features/merge_requests/conflicts_spec.rb
@@ -100,7 +100,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do
context 'in Parallel view mode' do
before do
- click_link('conflicts', href: /\/conflicts\Z/)
+ click_link('conflicts', href: /\/conflicts\Z/)
click_button 'Side-by-side'
end
diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb
index 854e2d1758f..e23dc2cd940 100644
--- a/spec/features/merge_requests/diff_notes_avatars_spec.rb
+++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
feature 'Diff note avatars', feature: true, js: true do
+ include NoteInteractionHelpers
+
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") }
@@ -110,6 +112,8 @@ feature 'Diff note avatars', feature: true, js: true do
end
it 'removes avatar when note is deleted' do
+ open_more_actions_dropdown(note)
+
page.within find(".note-row-#{note.id}") do
find('.js-note-delete').click
end
diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb
index 4860a2a7498..44013df3ea0 100644
--- a/spec/features/merge_requests/diffs_spec.rb
+++ b/spec/features/merge_requests/diffs_spec.rb
@@ -68,9 +68,14 @@ feature 'Diffs URL', js: true, feature: true do
let(:merge_request) { create(:merge_request_with_diffs, source_project: forked_project, target_project: project, author: author_user) }
let(:changelog_id) { Digest::SHA1.hexdigest("CHANGELOG") }
+ before do
+ forked_project.repository.after_import
+ end
+
context 'as author' do
it 'shows direct edit link' do
login_as(author_user)
+
visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
@@ -81,6 +86,7 @@ feature 'Diffs URL', js: true, feature: true do
context 'as user who needs to fork' do
it 'shows fork/cancel confirmation' do
login_as(user)
+
visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
diff --git a/spec/features/merge_requests/filter_merge_requests_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb
index 1e26b3d601e..d086be70d69 100644
--- a/spec/features/merge_requests/filter_merge_requests_spec.rb
+++ b/spec/features/merge_requests/filter_merge_requests_spec.rb
@@ -40,13 +40,13 @@ describe 'Filter merge requests', feature: true do
end
it 'does not change when closed link is clicked' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"]').click
expect_assignee_visual_tokens()
end
it 'does not change when all link is clicked' do
- find('.issues-state-filters a', text: "All").click
+ find('.issues-state-filters [data-state="all"]').click
expect_assignee_visual_tokens()
end
@@ -73,13 +73,13 @@ describe 'Filter merge requests', feature: true do
end
it 'does not change when closed link is clicked' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"]').click
expect_milestone_visual_tokens()
end
it 'does not change when all link is clicked' do
- find('.issues-state-filters a', text: "All").click
+ find('.issues-state-filters [data-state="all"]').click
expect_milestone_visual_tokens()
end
@@ -142,11 +142,9 @@ describe 'Filter merge requests', feature: true do
expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
expect_filtered_search_input_empty
- input_filtered_search_keys("label:~#{label.title} ")
+ input_filtered_search_keys("label:~#{label.title}")
expect_mr_list_count(1)
-
- find("#state-opened[href=\"#{URI.parse(current_url).path}?assignee_username=#{user.username}&label_name%5B%5D=#{label.title}&scope=all&state=opened\"]")
end
context 'assignee and label', js: true do
@@ -163,13 +161,13 @@ describe 'Filter merge requests', feature: true do
end
it 'does not change when closed link is clicked' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"]').click
expect_assignee_label_visual_tokens()
end
it 'does not change when all link is clicked' do
- find('.issues-state-filters a', text: "All").click
+ find('.issues-state-filters [data-state="all"]').click
expect_assignee_label_visual_tokens()
end
diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
index 3ceb91d951d..3a11ea3c8b2 100644
--- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb
+++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
@@ -12,13 +12,39 @@ feature 'Mini Pipeline Graph', :js, :feature do
build.run
login_as(user)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit_merge_request
+ end
+
+ def visit_merge_request(format = :html)
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request, format: format)
end
it 'should display a mini pipeline graph' do
expect(page).to have_selector('.mr-widget-pipeline-graph')
end
+ context 'as json' do
+ let(:artifacts_file1) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') }
+ let(:artifacts_file2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/png') }
+
+ before do
+ create(:ci_build, pipeline: pipeline, artifacts_file: artifacts_file1)
+ create(:ci_build, pipeline: pipeline, when: 'manual')
+ end
+
+ it 'avoids repeated database queries' do
+ before = ActiveRecord::QueryRecorder.new { visit_merge_request(:json) }
+
+ create(:ci_build, pipeline: pipeline, artifacts_file: artifacts_file2)
+ create(:ci_build, pipeline: pipeline, when: 'manual')
+
+ after = ActiveRecord::QueryRecorder.new { visit_merge_request(:json) }
+
+ expect(before.count).to eq(after.count)
+ expect(before.cached_count).to eq(after.cached_count)
+ end
+ end
+
describe 'build list toggle' do
let(:toggle) do
find('.mini-pipeline-graph-dropdown-toggle')
diff --git a/spec/features/merge_requests/update_merge_requests_spec.rb b/spec/features/merge_requests/update_merge_requests_spec.rb
index 4ef59a8aeb8..bcdfdf78a44 100644
--- a/spec/features/merge_requests/update_merge_requests_spec.rb
+++ b/spec/features/merge_requests/update_merge_requests_spec.rb
@@ -98,14 +98,16 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
end
def change_status(text)
- find('#check_all_issues').click
+ click_button 'Edit Merge Requests'
+ find('#check-all-issues').click
find('.js-issue-status').click
find('.dropdown-menu-status a', text: text).click
click_update_merge_requests_button
end
def change_assignee(text)
- find('#check_all_issues').click
+ click_button 'Edit Merge Requests'
+ find('#check-all-issues').click
find('.js-update-assignee').click
wait_for_requests
@@ -117,14 +119,15 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
end
def change_milestone(text)
- find('#check_all_issues').click
- find('.issues_bulk_update .js-milestone-select').click
+ click_button 'Edit Merge Requests'
+ find('#check-all-issues').click
+ find('.issues-bulk-update .js-milestone-select').click
find('.dropdown-menu-milestone a', text: text).click
click_update_merge_requests_button
end
def click_update_merge_requests_button
- find('.update_selected_issues').click
+ find('.update-selected-issues').click
wait_for_requests
end
end
diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb
index 06de072257a..22552529b9e 100644
--- a/spec/features/merge_requests/user_posts_notes_spec.rb
+++ b/spec/features/merge_requests/user_posts_notes_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe 'Merge requests > User posts notes', :js do
+ include NoteInteractionHelpers
+
let(:project) { create(:project) }
let(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
@@ -73,6 +75,8 @@ describe 'Merge requests > User posts notes', :js do
describe 'editing the note' do
before do
find('.note').hover
+ open_more_actions_dropdown(note)
+
find('.js-note-edit').click
end
@@ -100,6 +104,8 @@ describe 'Merge requests > User posts notes', :js do
wait_for_requests
find('.note').hover
+ open_more_actions_dropdown(note)
+
find('.js-note-edit').click
page.within('.current-note-edit-form') do
@@ -126,6 +132,8 @@ describe 'Merge requests > User posts notes', :js do
describe 'deleting an attachment' do
before do
find('.note').hover
+ open_more_actions_dropdown(note)
+
find('.js-note-edit').click
end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 27a20e78a43..7e2e685df26 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -17,6 +17,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do
def disallow_personal_access_token_saves!
allow_any_instance_of(PersonalAccessToken).to receive(:save).and_return(false)
+
errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
end
@@ -91,8 +92,11 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do
context "when revocation fails" do
it "displays an error message" do
- disallow_personal_access_token_saves!
visit profile_personal_access_tokens_path
+ allow_any_instance_of(PersonalAccessToken).to receive(:update!).and_return(false)
+
+ errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
+ allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
click_on "Revoke"
expect(active_personal_access_tokens).to have_text(personal_access_token.name)
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index 82cfbfda157..45fdb36e506 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -3,8 +3,8 @@ require 'spec_helper'
feature 'File blob', :js, feature: true do
let(:project) { create(:project, :public) }
- def visit_blob(path, fragment = nil)
- visit namespace_project_blob_path(project.namespace, project, File.join('master', path), anchor: fragment)
+ def visit_blob(path, anchor: nil, ref: 'master')
+ visit namespace_project_blob_path(project.namespace, project, File.join(ref, path), anchor: anchor)
wait_for_requests
end
@@ -101,7 +101,7 @@ feature 'File blob', :js, feature: true do
context 'visiting with a line number anchor' do
before do
- visit_blob('files/markdown/ruby-style-guide.md', 'L1')
+ visit_blob('files/markdown/ruby-style-guide.md', anchor: 'L1')
end
it 'displays the blob using the simple viewer' do
@@ -352,6 +352,37 @@ feature 'File blob', :js, feature: true do
end
end
+ context 'binary file that appears to be text in the first 1024 bytes' do
+ before do
+ visit_blob('encoding/binary-1.bin', ref: 'binary-encoding')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows a download link
+ expect(page).to have_link('Download (23.8 KB)')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # The specs below verify an arguably incorrect result, but since we only
+ # learn that the file is not actually text once the text viewer content
+ # is loaded asynchronously, there is no straightforward way to get these
+ # synchronously loaded elements to display correctly.
+ #
+ # Clicking the copy button will result in nothing being copied.
+ # Clicking the raw button will result in the binary file being downloaded,
+ # as expected.
+
+ # shows an enabled copy button, incorrectly
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+
+ # shows a raw button, incorrectly
+ expect(page).to have_link('Open raw')
+ end
+ end
+ end
+
context '.gitlab-ci.yml' do
before do
project.add_master(project.creator)
diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb
index 4162f2579d1..ee6985ad993 100644
--- a/spec/features/projects/compare_spec.rb
+++ b/spec/features/projects/compare_spec.rb
@@ -24,6 +24,7 @@ describe "Compare", js: true do
expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("binary-encoding")
click_button "Compare"
+
expect(page).to have_content "Commits"
end
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index cf393afccbb..613b1edba36 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -31,7 +31,7 @@ feature 'Environments page', :feature, :js do
it 'should show one environment' do
visit namespace_project_environments_path(project.namespace, project, scope: 'available')
expect(page).to have_css('.environments-container')
- expect(page.all('tbody > tr').length).to eq(1)
+ expect(page.all('.environment-name').length).to eq(1)
end
end
@@ -59,7 +59,7 @@ feature 'Environments page', :feature, :js do
it 'should show one environment' do
visit namespace_project_environments_path(project.namespace, project, scope: 'stopped')
expect(page).to have_css('.environments-container')
- expect(page.all('tbody > tr').length).to eq(1)
+ expect(page.all('.environment-name').length).to eq(1)
end
end
end
@@ -239,7 +239,9 @@ feature 'Environments page', :feature, :js do
context 'when logged as developer' do
before do
- click_link 'New environment'
+ within(".top-area") do
+ click_link 'New environment'
+ end
end
context 'for valid name' do
diff --git a/spec/features/projects/group_links_spec.rb b/spec/features/projects/group_links_spec.rb
index 4e5682c8636..1b680a56492 100644
--- a/spec/features/projects/group_links_spec.rb
+++ b/spec/features/projects/group_links_spec.rb
@@ -16,15 +16,17 @@ feature 'Project group links', :feature, :js do
before do
visit namespace_project_settings_members_path(project.namespace, project)
+ click_on 'share-with-group-tab'
+
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.current + 4.5.days).strftime('%Y-%m-%d')
page.find('body').click
- click_on 'Share'
+ find('.btn-create').trigger('click')
end
it 'shows the expiration time with a warning class' do
- page.within('.enabled-groups') do
- expect(page).to have_content('expires in 4 days')
+ page.within('.project-members-groups') do
+ expect(page).to have_content('Expires in 4 days')
expect(page).to have_selector('.text-warning')
end
end
@@ -43,6 +45,7 @@ feature 'Project group links', :feature, :js do
it 'does not show ancestors', :nested_groups do
visit namespace_project_settings_members_path(project.namespace, project)
+ click_on 'share-with-group-tab'
click_link 'Search for a group'
page.within '.select2-drop' do
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index c66b9a34b86..b1f9eb15667 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -17,10 +17,10 @@ feature "New project", feature: true do
expect(find_field("project_visibility_level_#{level}")).to be_checked
end
- it 'saves visibility level on validation error' do
+ it "saves visibility level #{level} on validation error" do
visit new_project_path
- choose(key)
+ choose(s_(key))
click_button('Create project')
expect(find_field("project_visibility_level_#{level}")).to be_checked
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
new file mode 100644
index 00000000000..4cc38c5286e
--- /dev/null
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -0,0 +1,78 @@
+require 'spec_helper'
+
+feature 'Repository settings', feature: true do
+ let(:project) { create(:project_empty_repo) }
+ let(:user) { create(:user) }
+ let(:role) { :developer }
+
+ background do
+ project.team << [user, role]
+ login_as(user)
+ end
+
+ context 'for developer' do
+ given(:role) { :developer }
+
+ scenario 'is not allowed to view' do
+ visit namespace_project_settings_repository_path(project.namespace, project)
+
+ expect(page.status_code).to eq(404)
+ end
+ end
+
+ context 'for master' do
+ given(:role) { :master }
+
+ context 'Deploy Keys', js: true do
+ let(:private_deploy_key) { create(:deploy_key, title: 'private_deploy_key', public: false) }
+ let(:public_deploy_key) { create(:another_deploy_key, title: 'public_deploy_key', public: true) }
+ let(:new_ssh_key) { attributes_for(:key)[:key] }
+
+ scenario 'get list of keys' do
+ project.deploy_keys << private_deploy_key
+ project.deploy_keys << public_deploy_key
+
+ visit namespace_project_settings_repository_path(project.namespace, project)
+
+ expect(page.status_code).to eq(200)
+ expect(page).to have_content('private_deploy_key')
+ expect(page).to have_content('public_deploy_key')
+ end
+
+ scenario 'add a new deploy key' do
+ visit namespace_project_settings_repository_path(project.namespace, project)
+
+ fill_in 'deploy_key_title', with: 'new_deploy_key'
+ fill_in 'deploy_key_key', with: new_ssh_key
+ check 'deploy_key_can_push'
+ click_button 'Add key'
+
+ expect(page).to have_content('new_deploy_key')
+ expect(page).to have_content('Write access allowed')
+ end
+
+ scenario 'edit an existing deploy key' do
+ project.deploy_keys << private_deploy_key
+ visit namespace_project_settings_repository_path(project.namespace, project)
+
+ find('li', text: private_deploy_key.title).click_link('Edit')
+
+ fill_in 'deploy_key_title', with: 'updated_deploy_key'
+ check 'deploy_key_can_push'
+ click_button 'Save changes'
+
+ expect(page).to have_content('updated_deploy_key')
+ expect(page).to have_content('Write access allowed')
+ end
+
+ scenario 'remove an existing deploy key' do
+ project.deploy_keys << private_deploy_key
+ visit namespace_project_settings_repository_path(project.namespace, project)
+
+ find('li', text: private_deploy_key.title).click_button('Remove')
+
+ expect(page).not_to have_content(private_deploy_key.title)
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/settings/visibility_settings_spec.rb b/spec/features/projects/settings/visibility_settings_spec.rb
index cef315ac9cd..fac4506bdf6 100644
--- a/spec/features/projects/settings/visibility_settings_spec.rb
+++ b/spec/features/projects/settings/visibility_settings_spec.rb
@@ -14,7 +14,7 @@ feature 'Visibility settings', feature: true, js: true do
visibility_select_container = find('.js-visibility-select')
expect(visibility_select_container.find('.visibility-select').value).to eq project.visibility_level.to_s
- expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.'
+ expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.'
end
scenario 'project visibility description updates on change' do
@@ -41,7 +41,7 @@ feature 'Visibility settings', feature: true, js: true do
expect(visibility_select_container).not_to have_select '.visibility-select'
expect(visibility_select_container).to have_content 'Public'
- expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.'
+ expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.'
end
end
end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
new file mode 100644
index 00000000000..5ac1ca45c74
--- /dev/null
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+feature 'Create Snippet', :js, feature: true do
+ include DropzoneHelper
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository, :public) }
+
+ def fill_form
+ fill_in 'project_snippet_title', with: 'My Snippet Title'
+ fill_in 'project_snippet_description', with: 'My Snippet **Description**'
+ page.within('.file-editor') do
+ find('.ace_editor').native.send_keys('Hello World!')
+ end
+ end
+
+ context 'when a user is authenticated' do
+ before do
+ project.team << [user, :master]
+ login_as(user)
+
+ visit namespace_project_snippets_path(project.namespace, project)
+
+ click_on('New snippet')
+ end
+
+ it 'creates a new snippet' do
+ fill_form
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ expect(page).to have_content('Hello World!')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
+ end
+
+ it 'uploads a file when dragging into textarea' do
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ expect(page.find_field("project_snippet_description").value).to have_content('banana_sample')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z})
+ end
+
+ it 'creates a snippet when all reuiqred fields are filled in after validation failing' do
+ fill_in 'project_snippet_title', with: 'My Snippet Title'
+ click_button('Create snippet')
+
+ expect(page).to have_selector('#error_explanation')
+
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ expect(page).to have_content('Hello World!')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z})
+ end
+ end
+
+ context 'when a user is not authenticated' do
+ it 'shows a public snippet on the index page but not the New snippet button' do
+ snippet = create(:project_snippet, :public, project: project)
+
+ visit namespace_project_snippets_path(project.namespace, project)
+
+ expect(page).to have_content(snippet.title)
+ expect(page).not_to have_content('New snippet')
+ end
+ end
+end
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
index 884d1bbb10c..667895bffa5 100644
--- a/spec/features/protected_branches_spec.rb
+++ b/spec/features/protected_branches_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projected Branches', feature: true, js: true do
+feature 'Protected Branches', feature: true, js: true do
let(:user) { create(:user, :admin) }
let(:project) { create(:project, :repository) }
diff --git a/spec/features/reportable_note/commit_spec.rb b/spec/features/reportable_note/commit_spec.rb
new file mode 100644
index 00000000000..39b1c4acf52
--- /dev/null
+++ b/spec/features/reportable_note/commit_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe 'Reportable note on commit', :feature, :js do
+ include RepoHelpers
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ before do
+ project.add_master(user)
+ login_as user
+ end
+
+ context 'a normal note' do
+ let!(:note) { create(:note_on_commit, commit_id: sample_commit.id, project: project) }
+
+ before do
+ visit namespace_project_commit_path(project.namespace, project, sample_commit.id)
+ end
+
+ it_behaves_like 'reportable note'
+ end
+
+ context 'a diff note' do
+ let!(:note) { create(:diff_note_on_commit, commit_id: sample_commit.id, project: project) }
+
+ before do
+ visit namespace_project_commit_path(project.namespace, project, sample_commit.id)
+ end
+
+ it_behaves_like 'reportable note'
+ end
+end
diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb
new file mode 100644
index 00000000000..5f526818994
--- /dev/null
+++ b/spec/features/reportable_note/issue_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe 'Reportable note on issue', :feature, :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:empty_project) }
+ let(:issue) { create(:issue, project: project) }
+ let!(:note) { create(:note_on_issue, noteable: issue, project: project) }
+
+ before do
+ project.add_master(user)
+ login_as user
+
+ visit namespace_project_issue_path(project.namespace, project, issue)
+ end
+
+ it_behaves_like 'reportable note'
+end
diff --git a/spec/features/reportable_note/merge_request_spec.rb b/spec/features/reportable_note/merge_request_spec.rb
new file mode 100644
index 00000000000..6d053d26626
--- /dev/null
+++ b/spec/features/reportable_note/merge_request_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe 'Reportable note on merge request', :feature, :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ project.add_master(user)
+ login_as user
+
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ context 'a normal note' do
+ let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) }
+
+ it_behaves_like 'reportable note'
+ end
+
+ context 'a diff note' do
+ let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
+
+ it_behaves_like 'reportable note'
+ end
+end
diff --git a/spec/features/reportable_note/snippets_spec.rb b/spec/features/reportable_note/snippets_spec.rb
new file mode 100644
index 00000000000..3f1e0cf9097
--- /dev/null
+++ b/spec/features/reportable_note/snippets_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe 'Reportable note on snippets', :feature, :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:empty_project) }
+
+ before do
+ project.add_master(user)
+ login_as user
+ end
+
+ describe 'on project snippet' do
+ let(:snippet) { create(:project_snippet, :public, project: project, author: user) }
+ let!(:note) { create(:note_on_project_snippet, noteable: snippet, project: project) }
+
+ before do
+ visit namespace_project_snippet_path(project.namespace, project, snippet)
+ end
+
+ it_behaves_like 'reportable note'
+ end
+
+ describe 'on personal snippet' do
+ let(:snippet) { create(:personal_snippet, :public, author: user) }
+ let!(:note) { create(:note_on_personal_snippet, noteable: snippet, author: user) }
+
+ before do
+ visit snippet_path(snippet)
+ end
+
+ it_behaves_like 'reportable note'
+ end
+end
diff --git a/spec/features/snippets/create_snippet_spec.rb b/spec/features/snippets/create_snippet_spec.rb
index 31a2d4ae984..ddd31ede064 100644
--- a/spec/features/snippets/create_snippet_spec.rb
+++ b/spec/features/snippets/create_snippet_spec.rb
@@ -1,24 +1,93 @@
require 'rails_helper'
feature 'Create Snippet', :js, feature: true do
+ include DropzoneHelper
+
before do
login_as :user
visit new_snippet_path
end
- scenario 'Authenticated user creates a snippet' do
+ def fill_form
fill_in 'personal_snippet_title', with: 'My Snippet Title'
+ fill_in 'personal_snippet_description', with: 'My Snippet **Description**'
page.within('.file-editor') do
find('.ace_editor').native.send_keys 'Hello World!'
end
+ end
- click_button 'Create snippet'
+ scenario 'Authenticated user creates a snippet' do
+ fill_form
+
+ click_button('Create snippet')
wait_for_requests
expect(page).to have_content('My Snippet Title')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
expect(page).to have_content('Hello World!')
end
+ scenario 'previews a snippet with file' do
+ fill_in 'personal_snippet_description', with: 'My Snippet'
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ find('.js-md-preview-button').click
+
+ page.within('#new_personal_snippet .md-preview') do
+ expect(page).to have_content('My Snippet')
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/temp/\h{32}/banana_sample\.gif\z})
+
+ visit(link)
+ expect(page.status_code).to eq(200)
+ end
+ end
+
+ scenario 'uploads a file when dragging into textarea' do
+ fill_form
+
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
+
+ visit(link)
+ expect(page.status_code).to eq(200)
+ end
+
+ scenario 'validation fails for the first time' do
+ fill_in 'personal_snippet_title', with: 'My Snippet Title'
+ click_button('Create snippet')
+
+ expect(page).to have_selector('#error_explanation')
+
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
+ expect(page).to have_content('Hello World!')
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
+
+ visit(link)
+ expect(page.status_code).to eq(200)
+ end
+
scenario 'Authenticated user creates a snippet with + in filename' do
fill_in 'personal_snippet_title', with: 'My Snippet Title'
page.within('.file-editor') do
diff --git a/spec/features/snippets/edit_snippet_spec.rb b/spec/features/snippets/edit_snippet_spec.rb
new file mode 100644
index 00000000000..89ae593db88
--- /dev/null
+++ b/spec/features/snippets/edit_snippet_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+feature 'Edit Snippet', :js, feature: true do
+ include DropzoneHelper
+
+ let(:file_name) { 'test.rb' }
+ let(:content) { 'puts "test"' }
+
+ let(:user) { create(:user) }
+ let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, author: user) }
+
+ before do
+ login_as(user)
+
+ visit edit_snippet_path(snippet)
+ wait_for_requests
+ end
+
+ it 'updates the snippet' do
+ fill_in 'personal_snippet_title', with: 'New Snippet Title'
+
+ click_button('Save changes')
+ wait_for_requests
+
+ expect(page).to have_content('New Snippet Title')
+ end
+
+ it 'updates the snippet with files attached' do
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample')
+
+ click_button('Save changes')
+ wait_for_requests
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z})
+ end
+end
diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb
index f7afc174019..44b0c89fac7 100644
--- a/spec/features/snippets/notes_on_personal_snippets_spec.rb
+++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe 'Comments on personal snippets', :js, feature: true do
+ include NoteInteractionHelpers
+
let!(:user) { create(:user) }
let!(:snippet) { create(:personal_snippet, :public) }
let!(:snippet_notes) do
@@ -22,6 +24,8 @@ describe 'Comments on personal snippets', :js, feature: true do
it 'contains notes for a snippet with correct action icons' do
expect(page).to have_selector('#notes-list li', count: 2)
+ open_more_actions_dropdown(snippet_notes[0])
+
# comment authored by current user
page.within("#notes-list li#note_#{snippet_notes[0].id}") do
expect(page).to have_content(snippet_notes[0].note)
@@ -29,6 +33,8 @@ describe 'Comments on personal snippets', :js, feature: true do
expect(page).to have_selector('.note-emoji-button')
end
+ open_more_actions_dropdown(snippet_notes[1])
+
page.within("#notes-list li#note_#{snippet_notes[1].id}") do
expect(page).to have_content(snippet_notes[1].note)
expect(page).not_to have_selector('.js-note-delete')
@@ -68,6 +74,8 @@ describe 'Comments on personal snippets', :js, feature: true do
context 'when editing a note' do
it 'changes the text' do
+ open_more_actions_dropdown(snippet_notes[0])
+
page.within("#notes-list li#note_#{snippet_notes[0].id}") do
click_on 'Edit comment'
end
@@ -89,8 +97,10 @@ describe 'Comments on personal snippets', :js, feature: true do
context 'when deleting a note' do
it 'removes the note from the snippet detail page' do
+ open_more_actions_dropdown(snippet_notes[0])
+
page.within("#notes-list li#note_#{snippet_notes[0].id}") do
- click_on 'Remove comment'
+ click_on 'Delete comment'
end
wait_for_requests
diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb
index a23c4ca2b92..8509551ce4a 100644
--- a/spec/features/unsubscribe_links_spec.rb
+++ b/spec/features/unsubscribe_links_spec.rb
@@ -24,8 +24,8 @@ describe 'Unsubscribe links', feature: true do
visit body_link
expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last)
- expect(page).to have_text(%(Unsubscribe from issue #{issue.title} (#{issue.to_reference})))
- expect(page).to have_text(%(Are you sure you want to unsubscribe from issue #{issue.title} (#{issue.to_reference})?))
+ expect(page).to have_text(%(Unsubscribe from issue))
+ expect(page).to have_text(%(Are you sure you want to unsubscribe from the issue: #{issue.title} (#{issue.to_reference})?))
expect(issue.subscribed?(recipient, project)).to be_truthy
click_link 'Unsubscribe'
diff --git a/spec/features/uploads/user_uploads_avatar_to_group_spec.rb b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb
index f88a515f7fc..d9d6f2e2382 100644
--- a/spec/features/uploads/user_uploads_avatar_to_group_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb
@@ -18,7 +18,7 @@ feature 'User uploads avatar to group', feature: true do
visit group_path(group)
- expect(page).to have_selector(%Q(img[src$="/uploads/group/avatar/#{group.id}/dk.png"]))
+ expect(page).to have_selector(%Q(img[src$="/uploads/system/group/avatar/#{group.id}/dk.png"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(group.reload.avatar.file).to exist
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index 0dfd29045e5..eb8dbd76aab 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -16,7 +16,7 @@ feature 'User uploads avatar to profile', feature: true do
visit user_path(user)
- expect(page).to have_selector(%Q(img[src$="/uploads/user/avatar/#{user.id}/dk.png"]))
+ expect(page).to have_selector(%Q(img[src$="/uploads/system/user/avatar/#{user.id}/dk.png"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
diff --git a/spec/finders/events_finder_spec.rb b/spec/finders/events_finder_spec.rb
new file mode 100644
index 00000000000..30a2bd14f10
--- /dev/null
+++ b/spec/finders/events_finder_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe EventsFinder do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+ let(:project1) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) }
+ let(:project2) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) }
+ let(:closed_issue) { create(:closed_issue, project: project1, author: user) }
+ let(:opened_merge_request) { create(:merge_request, source_project: project2, author: user) }
+ let!(:closed_issue_event) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) }
+ let!(:opened_merge_request_event) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 1, 31)) }
+ let(:closed_issue2) { create(:closed_issue, project: project1, author: user) }
+ let(:opened_merge_request2) { create(:merge_request, source_project: project2, author: user) }
+ let!(:closed_issue_event2) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 2, 2)) }
+ let!(:opened_merge_request_event2) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 2, 2)) }
+
+ context 'when targeting a user' do
+ it 'returns events between specified dates filtered on action and type' do
+ events = described_class.new(source: user, current_user: user, action: 'created', target_type: 'merge_request', after: Date.new(2017, 1, 1), before: Date.new(2017, 2, 1)).execute
+
+ expect(events).to eq([opened_merge_request_event])
+ end
+
+ it 'does not return events the current_user does not have access to' do
+ events = described_class.new(source: user, current_user: other_user).execute
+
+ expect(events).not_to include(opened_merge_request_event)
+ end
+ end
+
+ context 'when targeting a project' do
+ it 'returns project events between specified dates filtered on action and type' do
+ events = described_class.new(source: project1, current_user: user, action: 'closed', target_type: 'issue', after: Date.new(2016, 12, 1), before: Date.new(2017, 1, 1)).execute
+
+ expect(events).to eq([closed_issue_event])
+ end
+
+ it 'does not return events the current_user does not have access to' do
+ events = described_class.new(source: project2, current_user: other_user).execute
+
+ expect(events).to be_empty
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json
index 11a4caf6628..622a1e40d07 100644
--- a/spec/fixtures/api/schemas/list.json
+++ b/spec/fixtures/api/schemas/list.json
@@ -10,7 +10,7 @@
"id": { "type": "integer" },
"list_type": {
"type": "string",
- "enum": ["label", "closed"]
+ "enum": ["backlog", "label", "closed"]
},
"label": {
"type": ["object", "null"],
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 785fb724132..49df91b236f 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -1,3 +1,4 @@
+# coding: utf-8
require 'spec_helper'
describe ApplicationHelper do
@@ -58,13 +59,13 @@ describe ApplicationHelper do
describe 'project_icon' do
it 'returns an url for the avatar' do
project = create(:empty_project, avatar: File.open(uploaded_image_temp_path))
- avatar_url = "/uploads/project/avatar/#{project.id}/banana_sample.gif"
+ avatar_url = "/uploads/system/project/avatar/#{project.id}/banana_sample.gif"
expect(helper.project_icon(project.full_path).to_s).
to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />"
allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host)
- avatar_url = "#{gitlab_host}/uploads/project/avatar/#{project.id}/banana_sample.gif"
+ avatar_url = "#{gitlab_host}/uploads/system/project/avatar/#{project.id}/banana_sample.gif"
expect(helper.project_icon(project.full_path).to_s).
to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />"
@@ -84,12 +85,12 @@ describe ApplicationHelper do
it 'returns an url for the avatar' do
user = create(:user, avatar: File.open(uploaded_image_temp_path))
- avatar_url = "/uploads/user/avatar/#{user.id}/banana_sample.gif"
+ avatar_url = "/uploads/system/user/avatar/#{user.id}/banana_sample.gif"
expect(helper.avatar_icon(user.email).to_s).to match(avatar_url)
allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host)
- avatar_url = "#{gitlab_host}/uploads/user/avatar/#{user.id}/banana_sample.gif"
+ avatar_url = "#{gitlab_host}/uploads/system/user/avatar/#{user.id}/banana_sample.gif"
expect(helper.avatar_icon(user.email).to_s).to match(avatar_url)
end
@@ -102,7 +103,7 @@ describe ApplicationHelper do
user = create(:user, avatar: File.open(uploaded_image_temp_path))
expect(helper.avatar_icon(user.email).to_s).
- to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif")
+ to match("/gitlab/uploads/system/user/avatar/#{user.id}/banana_sample.gif")
end
it 'calls gravatar_icon when no User exists with the given email' do
@@ -116,7 +117,7 @@ describe ApplicationHelper do
user = create(:user, avatar: File.open(uploaded_image_temp_path))
expect(helper.avatar_icon(user).to_s).
- to match("/uploads/user/avatar/#{user.id}/banana_sample.gif")
+ to match("/uploads/system/user/avatar/#{user.id}/banana_sample.gif")
end
end
end
diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb
index cd112dbb2fb..c68e4f56b05 100644
--- a/spec/helpers/emails_helper_spec.rb
+++ b/spec/helpers/emails_helper_spec.rb
@@ -52,7 +52,7 @@ describe EmailsHelper do
)
expect(header_logo).to eq(
- %{<img style="height: 50px" src="/uploads/appearance/header_logo/#{appearance.id}/dk.png" alt="Dk" />}
+ %{<img style="height: 50px" src="/uploads/system/appearance/header_logo/#{appearance.id}/dk.png" alt="Dk" />}
)
end
end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index c8b0d86425f..0337afa4452 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -9,7 +9,7 @@ describe GroupsHelper do
group.avatar = fixture_file_upload(avatar_file_path)
group.save!
expect(group_icon(group.path).to_s).
- to match("/uploads/group/avatar/#{group.id}/banana_sample.gif")
+ to match("/uploads/system/group/avatar/#{group.id}/banana_sample.gif")
end
it 'gives default avatar_icon when no avatar is present' do
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index c1ecb46aece..8fcf7f5fa15 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -192,4 +192,22 @@ describe IssuablesHelper do
expect(helper.issuable_filter_present?).to be_falsey
end
end
+
+ describe '#updated_at_by' do
+ let(:user) { create(:user) }
+ let(:unedited_issuable) { create(:issue) }
+ let(:edited_issuable) { create(:issue, last_edited_by: user, created_at: 3.days.ago, updated_at: 2.days.ago, last_edited_at: 2.days.ago) }
+ let(:edited_updated_at_by) do
+ {
+ updatedAt: edited_issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: user.name,
+ path: user_path(user)
+ }
+ }
+ end
+
+ it { expect(helper.updated_at_by(unedited_issuable)).to eq({}) }
+ it { expect(helper.updated_at_by(edited_issuable)).to eq(edited_updated_at_by) }
+ end
end
diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb
index 355a4845afb..cc861af8533 100644
--- a/spec/helpers/notes_helper_spec.rb
+++ b/spec/helpers/notes_helper_spec.rb
@@ -256,4 +256,14 @@ describe NotesHelper do
expect(helper.form_resources).to eq([@project.namespace, @project, @note])
end
end
+
+ describe '#noteable_note_url' do
+ let(:project) { create(:empty_project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:note) { create(:note_on_issue, noteable: issue, project: project) }
+
+ it 'returns the noteable url with an anchor to the note' do
+ expect(noteable_note_url(note)).to match("/#{project.namespace.path}/#{project.path}/issues/#{issue.iid}##{dom_id(note)}")
+ end
+ end
end
diff --git a/spec/helpers/notifications_helper_spec.rb b/spec/helpers/notifications_helper_spec.rb
index 9d5f009ebe1..9ecaabc04ed 100644
--- a/spec/helpers/notifications_helper_spec.rb
+++ b/spec/helpers/notifications_helper_spec.rb
@@ -12,5 +12,11 @@ describe NotificationsHelper do
describe 'notification_title' do
it { expect(notification_title(:watch)).to match('Watch') }
it { expect(notification_title(:mention)).to match('On mention') }
+ it { expect(notification_title(:global)).to match('Global') }
+ end
+
+ describe '#notification_event_name' do
+ it { expect(notification_event_name(:success_pipeline)).to match('Successful pipeline') }
+ it { expect(notification_event_name(:failed_pipeline)).to match('Failed pipeline') }
end
end
diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb
index 2cc0b40b2d0..dff2784f21f 100644
--- a/spec/helpers/page_layout_helper_spec.rb
+++ b/spec/helpers/page_layout_helper_spec.rb
@@ -60,7 +60,7 @@ describe PageLayoutHelper do
%w(project user group).each do |type|
context "with @#{type} assigned" do
it "uses #{type.titlecase} avatar if available" do
- object = double(avatar_url: 'http://example.com/uploads/avatar.png')
+ object = double(avatar_url: 'http://example.com/uploads/system/avatar.png')
assign(type, object)
expect(helper.page_image).to eq object.avatar_url
diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb
new file mode 100644
index 00000000000..b33b3f3a228
--- /dev/null
+++ b/spec/helpers/profiles_helper_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+describe ProfilesHelper do
+ describe '#email_provider_label' do
+ it "returns nil for users without external email" do
+ user = create(:user)
+ allow(helper).to receive(:current_user).and_return(user)
+
+ expect(helper.email_provider_label).to be_nil
+ end
+
+ it "returns omniauth provider label for users with external email" do
+ stub_cas_omniauth_provider
+ cas_user = create(:omniauth_user, provider: 'cas3', external_email: true, email_provider: 'cas3')
+ allow(helper).to receive(:current_user).and_return(cas_user)
+
+ expect(helper.email_provider_label).to eq('CAS')
+ end
+
+ it "returns 'LDAP' for users with external email but no email provider" do
+ ldap_user = create(:omniauth_user, external_email: true)
+ allow(helper).to receive(:current_user).and_return(ldap_user)
+
+ expect(helper.email_provider_label).to eq('LDAP')
+ end
+ end
+
+ def stub_cas_omniauth_provider
+ provider = OpenStruct.new(
+ 'name' => 'cas3',
+ 'label' => 'CAS'
+ )
+
+ stub_omniauth_setting(providers: [provider])
+ end
+end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 54c5ba57bdf..a695621b87a 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -257,7 +257,7 @@ describe ProjectsHelper do
result = helper.project_feature_access_select(:issues_access_level)
expect(result).to include("Disabled")
expect(result).to include("Only team members")
- expect(result).not_to include("Everyone with access")
+ expect(result).to have_selector('option[disabled]', text: "Everyone with access")
end
end
@@ -272,7 +272,7 @@ describe ProjectsHelper do
expect(result).to include("Disabled")
expect(result).to include("Only team members")
- expect(result).not_to include("Everyone with access")
+ expect(result).to have_selector('option[disabled]', text: "Everyone with access")
expect(result).to have_selector('option[selected]', text: "Only team members")
end
end
diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb
index b05ae5c2232..cb727430117 100644
--- a/spec/helpers/submodule_helper_spec.rb
+++ b/spec/helpers/submodule_helper_spec.rb
@@ -52,6 +52,14 @@ describe SubmoduleHelper do
stub_url(['http://', config.host, '/gitlab/root/gitlab-org/gitlab-ce.git'].join(''))
expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')])
end
+
+ it 'works with subgroups' do
+ allow(Gitlab.config.gitlab).to receive(:port).and_return(80) # set this just to be sure
+ allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root')
+ allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
+ stub_url(['http://', config.host, '/gitlab/root/gitlab-org/sub/gitlab-ce.git'].join(''))
+ expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org/sub', 'gitlab-ce'), namespace_project_tree_path('gitlab-org/sub', 'gitlab-ce', 'hash')])
+ end
end
context 'submodule on github.com' do
diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb
index 8942b00b128..ad19cf9263d 100644
--- a/spec/helpers/visibility_level_helper_spec.rb
+++ b/spec/helpers/visibility_level_helper_spec.rb
@@ -37,7 +37,7 @@ describe VisibilityLevelHelper do
it "describes public projects" do
expect(project_visibility_level_description(Gitlab::VisibilityLevel::PUBLIC))
- .to eq "The project can be cloned without any authentication."
+ .to eq "The project can be accessed without any authentication."
end
end
diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js
index 45d12e252c4..832877de71c 100644
--- a/spec/javascripts/boards/board_new_issue_spec.js
+++ b/spec/javascripts/boards/board_new_issue_spec.js
@@ -19,6 +19,7 @@ describe('Issue boards new issue form', () => {
};
},
};
+
const submitIssue = () => {
vm.$el.querySelector('.btn-success').click();
};
@@ -107,7 +108,7 @@ describe('Issue boards new issue form', () => {
setTimeout(() => {
submitIssue();
- expect(vm.$el.querySelector('.btn-success').disbled).not.toBe(true);
+ expect(vm.$el.querySelector('.btn-success').disabled).toBe(false);
done();
}, 0);
});
@@ -115,36 +116,43 @@ describe('Issue boards new issue form', () => {
it('clears title after submit', (done) => {
vm.title = 'submit issue';
- setTimeout(() => {
+ Vue.nextTick(() => {
submitIssue();
- expect(vm.title).toBe('');
- done();
- }, 0);
+ setTimeout(() => {
+ expect(vm.title).toBe('');
+ done();
+ }, 0);
+ });
});
- it('adds new issue to list after submit', (done) => {
+ it('adds new issue to top of list after submit request', (done) => {
vm.title = 'submit issue';
setTimeout(() => {
submitIssue();
- expect(list.issues.length).toBe(2);
- expect(list.issues[1].title).toBe('submit issue');
- expect(list.issues[1].subscribed).toBe(true);
- done();
+ setTimeout(() => {
+ expect(list.issues.length).toBe(2);
+ expect(list.issues[0].title).toBe('submit issue');
+ expect(list.issues[0].subscribed).toBe(true);
+ done();
+ }, 0);
}, 0);
});
it('sets detail issue after submit', (done) => {
+ expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe(undefined);
vm.title = 'submit issue';
setTimeout(() => {
submitIssue();
- expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue');
- done();
- });
+ setTimeout(() => {
+ expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue');
+ done();
+ }, 0);
+ }, 0);
});
it('sets detail list after submit', (done) => {
@@ -153,8 +161,10 @@ describe('Issue boards new issue form', () => {
setTimeout(() => {
submitIssue();
- expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id);
- done();
+ setTimeout(() => {
+ expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id);
+ done();
+ }, 0);
}, 0);
});
});
@@ -169,13 +179,12 @@ describe('Issue boards new issue form', () => {
setTimeout(() => {
expect(list.issues.length).toBe(1);
done();
- }, 500);
+ }, 0);
}, 0);
});
it('shows error', (done) => {
vm.title = 'error';
- submitIssue();
setTimeout(() => {
submitIssue();
@@ -183,7 +192,7 @@ describe('Issue boards new issue form', () => {
setTimeout(() => {
expect(vm.error).toBe(true);
done();
- }, 500);
+ }, 0);
}, 0);
});
});
diff --git a/spec/javascripts/boards/components/board_spec.js b/spec/javascripts/boards/components/board_spec.js
new file mode 100644
index 00000000000..c4e8966ad6c
--- /dev/null
+++ b/spec/javascripts/boards/components/board_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import '~/boards/services/board_service';
+import '~/boards/components/board';
+import '~/boards/models/list';
+
+describe('Board component', () => {
+ let vm;
+ let el;
+
+ beforeEach((done) => {
+ loadFixtures('boards/show.html.raw');
+
+ el = document.createElement('div');
+ document.body.appendChild(el);
+
+ // eslint-disable-next-line no-undef
+ gl.boardService = new BoardService('/', '/', 1);
+
+ vm = new gl.issueBoards.Board({
+ propsData: {
+ boardId: '1',
+ disabled: false,
+ issueLinkBase: '/',
+ rootPath: '/',
+ // eslint-disable-next-line no-undef
+ list: new List({
+ id: 1,
+ position: 0,
+ title: 'test',
+ list_type: 'backlog',
+ }),
+ },
+ }).$mount(el);
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ // remove the component from the DOM
+ document.querySelector('.board').remove();
+
+ localStorage.removeItem(`boards.${vm.boardId}.${vm.list.type}.expanded`);
+ });
+
+ it('board is expandable when list type is backlog', () => {
+ expect(
+ vm.$el.classList.contains('is-expandable'),
+ ).toBe(true);
+ });
+
+ it('board is expandable when list type is closed', (done) => {
+ vm.list.type = 'closed';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.classList.contains('is-expandable'),
+ ).toBe(true);
+
+ done();
+ });
+ });
+
+ it('board is not expandable when list type is label', (done) => {
+ vm.list.type = 'label';
+ vm.list.isExpandable = false;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.classList.contains('is-expandable'),
+ ).toBe(false);
+
+ done();
+ });
+ });
+
+ it('collapses when clicking header', (done) => {
+ vm.$el.querySelector('.board-header').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.classList.contains('is-collapsed'),
+ ).toBe(true);
+
+ done();
+ });
+ });
+
+ it('created sets isExpanded to true from localStorage', (done) => {
+ vm.$el.querySelector('.board-header').click();
+
+ return Vue.nextTick()
+ .then(() => {
+ expect(
+ vm.$el.classList.contains('is-collapsed'),
+ ).toBe(true);
+
+ // call created manually
+ vm.$options.created[0].call(vm);
+
+ return Vue.nextTick();
+ })
+ .then(() => {
+ expect(
+ vm.$el.classList.contains('is-collapsed'),
+ ).toBe(true);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js
index 461908f3fde..4c8a48580d7 100644
--- a/spec/javascripts/build_spec.js
+++ b/spec/javascripts/build_spec.js
@@ -58,7 +58,7 @@ describe('Build', () => {
it('displays the remove date correctly', () => {
const removeDateElement = document.querySelector('.js-artifacts-remove');
- expect(removeDateElement.innerText.trim()).toBe('1 year');
+ expect(removeDateElement.innerText.trim()).toBe('1 year remaining');
});
});
diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js
index 398c593eec2..ebfd60198b2 100644
--- a/spec/javascripts/commit/pipelines/pipelines_spec.js
+++ b/spec/javascripts/commit/pipelines/pipelines_spec.js
@@ -71,7 +71,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
it('should render a table with the received pipelines', (done) => {
setTimeout(() => {
- expect(this.component.$el.querySelectorAll('table > tbody > tr').length).toEqual(1);
+ expect(this.component.$el.querySelectorAll('.ci-table .commit').length).toEqual(1);
expect(this.component.$el.querySelector('.realtime-loading')).toBe(null);
expect(this.component.$el.querySelector('.empty-state')).toBe(null);
expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBe(null);
@@ -108,7 +108,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBeDefined();
expect(this.component.$el.querySelector('.realtime-loading')).toBe(null);
expect(this.component.$el.querySelector('.js-empty-state')).toBe(null);
- expect(this.component.$el.querySelector('table')).toBe(null);
+ expect(this.component.$el.querySelector('.ci-table')).toBe(null);
done();
}, 0);
});
diff --git a/spec/javascripts/commits_spec.js b/spec/javascripts/commits_spec.js
index 187db7485a5..44a4386b250 100644
--- a/spec/javascripts/commits_spec.js
+++ b/spec/javascripts/commits_spec.js
@@ -28,6 +28,32 @@ import '~/commits';
expect(CommitsList).toBeDefined();
});
+ describe('processCommits', () => {
+ it('should join commit headers', () => {
+ CommitsList.$contentList = $(`
+ <div>
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ </div>
+ `);
+
+ const data = `
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ `;
+
+ // The last commit header should be removed
+ // since the previous one has the same data-day value.
+ expect(CommitsList.processCommits(data).find('li.commit-header').length).toBe(0);
+ });
+ });
+
describe('on entering input', () => {
let ajaxSpy;
diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js
index e347c980c78..c82ad0bea48 100644
--- a/spec/javascripts/datetime_utility_spec.js
+++ b/spec/javascripts/datetime_utility_spec.js
@@ -2,6 +2,26 @@ import '~/lib/utils/datetime_utility';
(() => {
describe('Date time utils', () => {
+ describe('timeFor', () => {
+ it('returns `past due` when in past', () => {
+ const date = new Date();
+ date.setFullYear(date.getFullYear() - 1);
+
+ expect(
+ gl.utils.timeFor(date),
+ ).toBe('Past due');
+ });
+
+ it('returns remaining time when in the future', () => {
+ const date = new Date();
+ date.setFullYear(date.getFullYear() + 1);
+
+ expect(
+ gl.utils.timeFor(date),
+ ).toBe('1 year remaining');
+ });
+ });
+
describe('get day name', () => {
it('should return Sunday', () => {
const day = gl.utils.getDayName(new Date('07/17/2016'));
diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js
index 793ab8c451d..a4b98f6140d 100644
--- a/spec/javascripts/deploy_keys/components/key_spec.js
+++ b/spec/javascripts/deploy_keys/components/key_spec.js
@@ -39,9 +39,15 @@ describe('Deploy keys key', () => {
).toBe(`created ${gl.utils.getTimeago().format(deployKey.created_at)}`);
});
+ it('shows edit button', () => {
+ expect(
+ vm.$el.querySelectorAll('.btn')[0].textContent.trim(),
+ ).toBe('Edit');
+ });
+
it('shows remove button', () => {
expect(
- vm.$el.querySelector('.btn').textContent.trim(),
+ vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
).toBe('Remove');
});
@@ -71,9 +77,15 @@ describe('Deploy keys key', () => {
setTimeout(done);
});
+ it('shows edit button', () => {
+ expect(
+ vm.$el.querySelectorAll('.btn')[0].textContent.trim(),
+ ).toBe('Edit');
+ });
+
it('shows enable button', () => {
expect(
- vm.$el.querySelector('.btn').textContent.trim(),
+ vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
).toBe('Enable');
});
@@ -82,7 +94,7 @@ describe('Deploy keys key', () => {
Vue.nextTick(() => {
expect(
- vm.$el.querySelector('.btn').textContent.trim(),
+ vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
).toBe('Disable');
done();
diff --git a/spec/javascripts/environments/environment_spec.js b/spec/javascripts/environments/environment_spec.js
index 1c54cc3054c..6639a6b5e7b 100644
--- a/spec/javascripts/environments/environment_spec.js
+++ b/spec/javascripts/environments/environment_spec.js
@@ -41,7 +41,7 @@ describe('Environment', () => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-new-environment-button').textContent,
- ).toContain('New Environment');
+ ).toContain('New environment');
expect(
component.$el.querySelector('.js-blank-state-title').textContent,
@@ -271,7 +271,7 @@ describe('Environment', () => {
// wait for next async request
setTimeout(() => {
expect(component.$el.querySelectorAll('.js-child-row').length).toEqual(1);
- expect(component.$el.querySelector('td.text-center > a.btn').textContent).toContain('Show all');
+ expect(component.$el.querySelector('.text-center > a.btn').textContent).toContain('Show all');
Vue.http.interceptors = _.without(Vue.http.interceptors, folderInterceptor);
done();
diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js
index effbc6c3ee1..2862971bec4 100644
--- a/spec/javascripts/environments/environment_table_spec.js
+++ b/spec/javascripts/environments/environment_table_spec.js
@@ -29,6 +29,6 @@ describe('Environment item', () => {
},
}).$mount();
- expect(component.$el.tagName).toEqual('TABLE');
+ expect(component.$el.getAttribute('class')).toContain('ci-table');
});
});
diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
index 6e59ee96c6b..6d00d71f145 100644
--- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
@@ -97,6 +97,49 @@ describe('Filtered Search Manager', () => {
});
});
+ describe('searchState', () => {
+ beforeEach(() => {
+ spyOn(gl.FilteredSearchManager.prototype, 'search').and.callFake(() => {});
+ });
+
+ it('should blur button', () => {
+ const e = {
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+ spyOn(e.currentTarget, 'blur').and.callThrough();
+ manager.searchState(e);
+
+ expect(e.currentTarget.blur).toHaveBeenCalled();
+ });
+
+ it('should not call search if there is no state', () => {
+ const e = {
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+
+ manager.searchState(e);
+ expect(gl.FilteredSearchManager.prototype.search).not.toHaveBeenCalled();
+ });
+
+ it('should call search when there is state', () => {
+ const e = {
+ currentTarget: {
+ blur: () => {},
+ dataset: {
+ state: 'opened',
+ },
+ },
+ };
+
+ manager.searchState(e);
+ expect(gl.FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened');
+ });
+ });
+
describe('search', () => {
const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
@@ -316,42 +359,6 @@ describe('Filtered Search Manager', () => {
});
});
- describe('unselects token', () => {
- beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug', true)}
- ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')}
- `);
- });
-
- it('unselects token when input is clicked', () => {
- const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
-
- expect(selectedToken.classList.contains('selected')).toEqual(true);
- expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
-
- // Click directly on input attached to document
- // so that the click event will propagate properly
- document.querySelector('.filtered-search').click();
-
- expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
- expect(selectedToken.classList.contains('selected')).toEqual(false);
- });
-
- it('unselects token when document.body is clicked', () => {
- const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
-
- expect(selectedToken.classList.contains('selected')).toEqual(true);
- expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
-
- document.body.click();
-
- expect(selectedToken.classList.contains('selected')).toEqual(false);
- expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
- });
- });
-
describe('toggleInputContainerFocus', () => {
it('toggles on focus', () => {
input.focus();
diff --git a/spec/javascripts/fixtures/boards.rb b/spec/javascripts/fixtures/boards.rb
new file mode 100644
index 00000000000..d7c3dc0a235
--- /dev/null
+++ b/spec/javascripts/fixtures/boards.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe Projects::BoardsController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'boards-project') }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('boards/')
+ end
+
+ before(:each) do
+ sign_in(admin)
+ end
+
+ it 'boards/show.html.raw' do |example|
+ get(:index,
+ namespace_id: project.namespace,
+ project_id: project)
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/issuable_filter.html.haml b/spec/javascripts/fixtures/issuable_filter.html.haml
index ae745b292e6..84fa5395cb8 100644
--- a/spec/javascripts/fixtures/issuable_filter.html.haml
+++ b/spec/javascripts/fixtures/issuable_filter.html.haml
@@ -1,6 +1,6 @@
%form.js-filter-form{action: '/user/project/issues?scope=all&state=closed'}
%input{id: 'utf8', name: 'utf8', value: '✓'}
- %input{id: 'check_all_issues', name: 'check_all_issues'}
+ %input{id: 'check-all-issues', name: 'check-all-issues'}
%input{id: 'search', name: 'search'}
%input{id: 'author_id', name: 'author_id'}
%input{id: 'assignee_id', name: 'assignee_id'}
diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js
index 3292590b9ed..10fcc590c89 100644
--- a/spec/javascripts/gl_dropdown_spec.js
+++ b/spec/javascripts/gl_dropdown_spec.js
@@ -185,7 +185,7 @@ import '~/lib/utils/url_utility';
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
- it('should focus on input when opening for the second time', () => {
+ it('should focus on input when opening for the second time after transition', () => {
remoteCallback();
this.dropdownContainerElement.trigger({
type: 'keyup',
@@ -193,6 +193,7 @@ import '~/lib/utils/url_utility';
keyCode: ARROW_KEYS.ESC
});
this.dropdownButtonElement.click();
+ this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
});
@@ -201,6 +202,7 @@ import '~/lib/utils/url_utility';
it('should focus input when passing array data to drop down', () => {
initDropDown.call(this, false, true);
this.dropdownButtonElement.click();
+ this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
});
diff --git a/spec/javascripts/gl_emoji_spec.js b/spec/javascripts/gl_emoji_spec.js
index b2b46640e5b..a09e0072fa8 100644
--- a/spec/javascripts/gl_emoji_spec.js
+++ b/spec/javascripts/gl_emoji_spec.js
@@ -192,6 +192,9 @@ describe('gl_emoji', () => {
});
describe('isFlagEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isFlagEmoji('')).toBeFalsy();
+ });
it('should detect flag_ac', () => {
expect(isFlagEmoji('🇦🇨')).toBeTruthy();
});
@@ -216,6 +219,9 @@ describe('gl_emoji', () => {
});
describe('isKeycapEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isKeycapEmoji('')).toBeFalsy();
+ });
it('should detect one(keycap)', () => {
expect(isKeycapEmoji('1️⃣')).toBeTruthy();
});
@@ -231,6 +237,9 @@ describe('gl_emoji', () => {
});
describe('isSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isSkinToneComboEmoji('')).toBeFalsy();
+ });
it('should detect hand_splayed_tone5', () => {
expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy();
});
@@ -255,6 +264,9 @@ describe('gl_emoji', () => {
});
describe('isHorceRacingSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isHorceRacingSkinToneComboEmoji('')).toBeFalsy();
+ });
it('should detect horse_racing_tone2', () => {
expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy();
});
@@ -264,6 +276,9 @@ describe('gl_emoji', () => {
});
describe('isPersonZwjEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isPersonZwjEmoji('')).toBeFalsy();
+ });
it('should detect couple_mm', () => {
expect(isPersonZwjEmoji('👨‍❤️‍👨')).toBeTruthy();
});
@@ -300,6 +315,22 @@ describe('gl_emoji', () => {
});
describe('isEmojiUnicodeSupported', () => {
+ it('should gracefully handle empty string with unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported(
+ { '1.0': true },
+ '',
+ '1.0',
+ );
+ expect(isSupported).toBeTruthy();
+ });
+ it('should gracefully handle empty string without unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported(
+ {},
+ '',
+ '1.0',
+ );
+ expect(isSupported).toBeFalsy();
+ });
it('bomb(6.0) with 6.0 support', () => {
const emojiKey = 'bomb';
const unicodeSupportMap = Object.assign({}, emptySupportMap, {
diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js
new file mode 100644
index 00000000000..25e10552d95
--- /dev/null
+++ b/spec/javascripts/groups/group_item_spec.js
@@ -0,0 +1,102 @@
+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
new file mode 100644
index 00000000000..2a77f7259da
--- /dev/null
+++ b/spec/javascripts/groups/groups_spec.js
@@ -0,0 +1,64 @@
+import Vue from 'vue';
+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 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[1119].subGroups[1120].name);
+ });
+
+ it('should remove prefix of parent group', () => {
+ expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
+ });
+ });
+});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js
new file mode 100644
index 00000000000..1c0ec7a97d0
--- /dev/null
+++ b/spec/javascripts/groups/mock_data.js
@@ -0,0 +1,110 @@
+const group1 = {
+ id: '12',
+ name: 'level1',
+ path: 'level1',
+ description: 'foo',
+ visibility: 'public',
+ avatar_url: null,
+ web_url: 'http://localhost:3000/groups/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',
+ },
+};
+
+// 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',
+ 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',
+ },
+};
+
+const group2 = {
+ id: 1119,
+ name: 'devops',
+ path: 'devops',
+ description: 'foo',
+ visibility: 'public',
+ avatar_url: null,
+ web_url: 'http://localhost:3000/groups/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',
+ },
+};
+
+const group21 = {
+ id: 1120,
+ name: 'chef',
+ path: 'chef',
+ description: 'foo',
+ visibility: 'public',
+ avatar_url: null,
+ web_url: 'http://localhost:3000/groups/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',
+ },
+};
+
+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 { groupsData, group1 };
diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js
index 49fa2cb8367..45f55395d3a 100644
--- a/spec/javascripts/issuable_spec.js
+++ b/spec/javascripts/issuable_spec.js
@@ -1,7 +1,7 @@
-/* global Issuable */
+/* global IssuableIndex */
import '~/lib/utils/url_utility';
-import '~/issuable';
+import '~/issuable_index';
(() => {
const BASE_URL = '/user/project/issues?scope=all&state=closed';
@@ -24,11 +24,11 @@ import '~/issuable';
beforeEach(() => {
loadFixtures('static/issuable_filter.html.raw');
- Issuable.init();
+ IssuableIndex.init();
});
it('should be defined', () => {
- expect(window.Issuable).toBeDefined();
+ expect(window.IssuableIndex).toBeDefined();
});
describe('filtering', () => {
@@ -43,7 +43,7 @@ import '~/issuable';
it('should contain only the default parameters', () => {
spyOn(gl.utils, 'visitUrl');
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + DEFAULT_PARAMS);
});
@@ -52,7 +52,7 @@ import '~/issuable';
spyOn(gl.utils, 'visitUrl');
updateForm({ search: 'broken' }, $filtersForm);
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
const params = `${DEFAULT_PARAMS}&search=broken`;
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params);
@@ -64,14 +64,14 @@ import '~/issuable';
// initial filter
updateForm({ milestone_title: 'v1.0' }, $filtersForm);
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
let params = `${DEFAULT_PARAMS}&milestone_title=v1.0`;
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params);
// update filter
updateForm({ label_name: 'Frontend' }, $filtersForm);
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
params = `${DEFAULT_PARAMS}&milestone_title=v1.0&label_name=Frontend`;
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params);
});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 0030a953119..59c006aa0af 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -14,6 +14,10 @@ const issueShowInterceptor = data => (request, next) => {
}));
};
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
describe('Issuable output', () => {
document.body.innerHTML = '<span id="task_status"></span>';
@@ -50,12 +54,17 @@ describe('Issuable output', () => {
Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor);
});
- it('should render a title/description and update title/description on update', (done) => {
+ it('should render a title/description/edited and update title/description/edited on update', (done) => {
setTimeout(() => {
+ const editedText = vm.$el.querySelector('.edited-text');
+
expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description');
+ expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
@@ -64,6 +73,10 @@ describe('Issuable output', () => {
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
+ expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
+ expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
done();
});
diff --git a/spec/javascripts/issue_show/components/edited_spec.js b/spec/javascripts/issue_show/components/edited_spec.js
new file mode 100644
index 00000000000..a0d0750ae34
--- /dev/null
+++ b/spec/javascripts/issue_show/components/edited_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import edited from '~/issue_show/components/edited.vue';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+describe('edited', () => {
+ const EditedComponent = Vue.extend(edited);
+
+ it('should render an edited at+by string', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+
+ it('if no updatedAt is provided, no time element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeFalsy();
+ });
+
+ it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link')).toBeFalsy();
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+});
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
index 6683d581bc5..eb3111412a7 100644
--- a/spec/javascripts/issue_show/mock_data.js
+++ b/spec/javascripts/issue_show/mock_data.js
@@ -5,7 +5,9 @@ export default {
description: '<p>this is a description!</p>',
description_text: 'this is a description',
task_status: '2 of 4 completed',
- updated_at: new Date().toString(),
+ updated_at: '2015-05-15T12:31:04.428Z',
+ updated_by_name: 'Some User',
+ updated_by_path: '/some_user',
},
secondRequest: {
title: '<p>2</p>',
@@ -13,7 +15,9 @@ export default {
description: '<p>42</p>',
description_text: '42',
task_status: '0 of 0 completed',
- updated_at: new Date().toString(),
+ updated_at: '2016-05-15T12:31:04.428Z',
+ updated_by_name: 'Other User',
+ updated_by_path: '/other_user',
},
issueSpecRequest: {
title: '<p>this is a title</p>',
@@ -21,6 +25,8 @@ export default {
description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>',
description_text: '- [ ] Task List Item',
task_status: '0 of 1 completed',
- updated_at: new Date().toString(),
+ updated_at: '2017-05-15T12:31:04.428Z',
+ updated_by_name: 'Last User',
+ updated_by_path: '/last_user',
},
};
diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js
index e1747a82329..2c946802dcd 100644
--- a/spec/javascripts/lib/utils/ajax_cache_spec.js
+++ b/spec/javascripts/lib/utils/ajax_cache_spec.js
@@ -154,5 +154,36 @@ describe('AjaxCache', () => {
.then(done)
.catch(fail);
});
+
+ it('makes Ajax call even if matching data exists when forceRequest parameter is provided', (done) => {
+ const oldDummyResponse = {
+ important: 'old dummy data',
+ };
+
+ AjaxCache.internalStorage[dummyEndpoint] = oldDummyResponse;
+
+ ajaxSpy = (url) => {
+ expect(url).toBe(dummyEndpoint);
+ const deferred = $.Deferred();
+ deferred.resolve(dummyResponse);
+ return deferred.promise();
+ };
+
+ // Call without forceRetrieve param
+ AjaxCache.retrieve(dummyEndpoint)
+ .then((data) => {
+ expect(data).toBe(oldDummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+
+ // Call with forceRetrieve param
+ AjaxCache.retrieve(dummyEndpoint, true)
+ .then((data) => {
+ expect(data).toBe(dummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+ });
});
});
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index e3938a77680..52cf217c25f 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -150,6 +150,14 @@ import '~/lib/utils/common_utils';
const value = gl.utils.getParameterByName('fakeParameter');
expect(value).toBe(null);
});
+
+ it('should return valid paramentes if URL is provided', () => {
+ let value = gl.utils.getParameterByName('foo', 'http://cocteau.twins/?foo=bar');
+ expect(value).toBe('bar');
+
+ value = gl.utils.getParameterByName('manan', 'http://cocteau.twins/?foo=bar&manan=canchu');
+ expect(value).toBe('canchu');
+ });
});
describe('gl.utils.normalizedHeaders', () => {
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 17aa70ff3f1..bfd8b8648a6 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -13,6 +13,23 @@ import '~/notes';
window.gl = window.gl || {};
gl.utils = gl.utils || {};
+ const htmlEscape = (comment) => {
+ const escapedString = comment.replace(/["&'<>]/g, (a) => {
+ const escapedToken = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&quot;',
+ "'": '&#x27;',
+ '`': '&#x60;'
+ }[a];
+
+ return escapedToken;
+ });
+
+ return escapedString;
+ };
+
describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert';
var commentsTemplate = 'issues/issue_with_comment.html.raw';
@@ -444,12 +461,57 @@ import '~/notes';
});
});
+ describe('update comment with script tags', () => {
+ const sampleComment = '<script></script>';
+ const updatedComment = '<script></script>';
+ const note = {
+ id: 1234,
+ html: `<li class="note note-row-1234 timeline-entry" id="note_1234">
+ <div class="note-text">${sampleComment}</div>
+ </li>`,
+ note: sampleComment,
+ valid: true
+ };
+ let $form;
+ let $notesContainer;
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ window.gon.current_username = 'root';
+ window.gon.current_user_fullname = 'Administrator';
+ $form = $('form.js-main-target-form');
+ $notesContainer = $('ul.main-notes-list');
+ $form.find('textarea.js-note-text').html(sampleComment);
+ });
+
+ it('should not render a script tag', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ $('.js-comment-button').click();
+
+ deferred.resolve(note);
+ const $noteEl = $notesContainer.find(`#note_${note.id}`);
+ $noteEl.find('.js-note-edit').click();
+ $noteEl.find('textarea.js-note-text').html(updatedComment);
+ $noteEl.find('.js-comment-save-button').click();
+
+ const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`).find('.js-task-list-container');
+ expect($updatedNoteEl.find('.note-text').text().trim()).toEqual('');
+ });
+ });
+
describe('getFormData', () => {
- it('should return form metadata object from form reference', () => {
+ let $form;
+ let sampleComment;
+
+ beforeEach(() => {
this.notes = new Notes('', []);
- const $form = $('form');
- const sampleComment = 'foobar';
+ $form = $('form');
+ sampleComment = 'foobar';
+ });
+
+ it('should return form metadata object from form reference', () => {
$form.find('textarea.js-note-text').val(sampleComment);
const { formData, formContent, formAction } = this.notes.getFormData($form);
@@ -457,6 +519,18 @@ import '~/notes';
expect(formContent).toEqual(sampleComment);
expect(formAction).toEqual($form.attr('action'));
});
+
+ it('should return form metadata with sanitized formContent from form reference', () => {
+ spyOn(_, 'escape').and.callFake(htmlEscape);
+
+ sampleComment = '<script>alert("Boom!");</script>';
+ $form.find('textarea.js-note-text').val(sampleComment);
+
+ const { formContent } = this.notes.getFormData($form);
+
+ expect(_.escape).toHaveBeenCalledWith(sampleComment);
+ expect(formContent).toEqual('&lt;script&gt;alert(&quot;Boom!&quot;);&lt;/script&gt;');
+ });
});
describe('hasSlashCommands', () => {
@@ -512,30 +586,42 @@ import '~/notes';
});
});
+ describe('getSlashCommandDescription', () => {
+ const availableSlashCommands = [
+ { name: 'close', description: 'Close this issue', params: [] },
+ { name: 'title', description: 'Change title', params: [{}] },
+ { name: 'estimate', description: 'Set time estimate', params: [{}] }
+ ];
+
+ beforeEach(() => {
+ this.notes = new Notes();
+ });
+
+ it('should return executing slash command description when note has single slash command', () => {
+ const sampleComment = '/close';
+ expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying command to close this issue');
+ });
+
+ it('should return generic multiple slash command description when note has multiple slash commands', () => {
+ const sampleComment = '/close\n/title [Duplicate] Issue foobar';
+ expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying multiple commands');
+ });
+
+ it('should return generic slash command description when available slash commands list is not populated', () => {
+ const sampleComment = '/close\n/title [Duplicate] Issue foobar';
+ expect(this.notes.getSlashCommandDescription(sampleComment)).toBe('Applying command');
+ });
+ });
+
describe('createPlaceholderNote', () => {
const sampleComment = 'foobar';
const uniqueId = 'b1234-a4567';
const currentUsername = 'root';
const currentUserFullname = 'Administrator';
+ const currentUserAvatar = 'avatar_url';
beforeEach(() => {
this.notes = new Notes('', []);
- spyOn(_, 'escape').and.callFake((comment) => {
- const escapedString = comment.replace(/["&'<>]/g, (a) => {
- const escapedToken = {
- '&': '&amp;',
- '<': '&lt;',
- '>': '&gt;',
- '"': '&quot;',
- "'": '&#x27;',
- '`': '&#x60;'
- }[a];
-
- return escapedToken;
- });
-
- return escapedString;
- });
});
it('should return constructed placeholder element for regular note based on form contents', () => {
@@ -544,46 +630,59 @@ import '~/notes';
uniqueId,
isDiscussionNote: false,
currentUsername,
- currentUserFullname
+ currentUserFullname,
+ currentUserAvatar,
});
const $tempNoteHeader = $tempNote.find('.note-header');
expect($tempNote.prop('nodeName')).toEqual('LI');
expect($tempNote.attr('id')).toEqual(uniqueId);
+ expect($tempNote.hasClass('being-posted')).toBeTruthy();
+ expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
$tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() {
expect($(this).attr('href')).toEqual(`/${currentUsername}`);
});
+ expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar);
expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy();
expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname);
expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`);
expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment);
});
- it('should escape HTML characters from note based on form contents', () => {
- const commentWithHtml = '<script>alert("Boom!");</script>';
+ it('should return constructed placeholder element for discussion note based on form contents', () => {
const $tempNote = this.notes.createPlaceholderNote({
- formContent: commentWithHtml,
+ formContent: sampleComment,
uniqueId,
- isDiscussionNote: false,
+ isDiscussionNote: true,
currentUsername,
currentUserFullname
});
- expect(_.escape).toHaveBeenCalledWith(commentWithHtml);
- expect($tempNote.find('.note-body .note-text p').html()).toEqual('&lt;script&gt;alert("Boom!");&lt;/script&gt;');
+ expect($tempNote.prop('nodeName')).toEqual('LI');
+ expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy();
});
+ });
- it('should return constructed placeholder element for discussion note based on form contents', () => {
- const $tempNote = this.notes.createPlaceholderNote({
- formContent: sampleComment,
+ describe('createPlaceholderSystemNote', () => {
+ const sampleCommandDescription = 'Applying command to close this issue';
+ const uniqueId = 'b1234-a4567';
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ spyOn(_, 'escape').and.callFake(htmlEscape);
+ });
+
+ it('should return constructed placeholder element for system note based on form contents', () => {
+ const $tempNote = this.notes.createPlaceholderSystemNote({
+ formContent: sampleCommandDescription,
uniqueId,
- isDiscussionNote: true,
- currentUsername,
- currentUserFullname
});
expect($tempNote.prop('nodeName')).toEqual('LI');
- expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy();
+ expect($tempNote.attr('id')).toEqual(uniqueId);
+ expect($tempNote.hasClass('being-posted')).toBeTruthy();
+ expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
+ expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription);
});
});
@@ -595,7 +694,7 @@ import '~/notes';
it('shows a flash message', () => {
this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline);
- expect(document.querySelectorAll('.flash-alert').length).toBe(1);
+ expect($('.flash-alert').is(':visible')).toBeTruthy();
});
});
@@ -605,13 +704,12 @@ import '~/notes';
this.notes = new Notes();
});
- it('removes all the associated flash messages', () => {
+ it('hides visible flash message', () => {
this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline);
- this.notes.addFlash('Error message 2', FLASH_TYPE_ALERT, this.notes.parentTimeline);
this.notes.clearFlash();
- expect(document.querySelectorAll('.flash-alert').length).toBe(0);
+ expect($('.flash-alert').is(':visible')).toBeFalsy();
});
});
});
diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js
index b8531350e43..cecc7ceb53d 100644
--- a/spec/javascripts/pipelines/header_component_spec.js
+++ b/spec/javascripts/pipelines/header_component_spec.js
@@ -10,6 +10,9 @@ describe('Pipeline details header', () => {
beforeEach(() => {
HeaderComponent = Vue.extend(headerComponent);
+ const threeWeeksAgo = new Date();
+ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
props = {
pipeline: {
details: {
@@ -22,7 +25,7 @@ describe('Pipeline details header', () => {
},
},
id: 123,
- created_at: '2017-05-08T14:57:39.781Z',
+ created_at: threeWeeksAgo.toISOString(),
user: {
web_url: 'path',
name: 'Foo',
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index 13827a26571..2c34402576b 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -51,7 +51,6 @@ if (process.env.BABEL_ENV === 'coverage') {
'./environments/environments_bundle.js',
'./filtered_search/filtered_search_bundle.js',
'./graphs/graphs_bundle.js',
- './issuable/issuable_bundle.js',
'./issuable/time_tracking/time_tracking_bundle.js',
'./main.js',
'./merge_conflicts/merge_conflicts_bundle.js',
diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js
index 050170a54e9..540245fe71e 100644
--- a/spec/javascripts/vue_shared/components/commit_spec.js
+++ b/spec/javascripts/vue_shared/components/commit_spec.js
@@ -22,7 +22,7 @@ describe('Commit component', () => {
shortSha: 'b7836edd',
title: 'Commit message',
author: {
- avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
+ avatar_url: 'https://gitlab.com/uploads/system/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
path: '/jschatz1',
username: 'jschatz1',
@@ -45,7 +45,7 @@ describe('Commit component', () => {
shortSha: 'b7836edd',
title: 'Commit message',
author: {
- avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
+ avatar_url: 'https://gitlab.com/uploads/system/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
path: '/jschatz1',
username: 'jschatz1',
diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb
index d5746107ee1..f4f42bfc3ed 100644
--- a/spec/lib/banzai/reference_parser/base_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb
@@ -30,7 +30,7 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
it 'checks if user can read the resource' do
link['data-project'] = project.id.to_s
- expect(subject).to receive(:can_read_reference?).with(user, project)
+ expect(subject).to receive(:can_read_reference?).with(user, project, link)
subject.nodes_visible_to_user(user, [link])
end
diff --git a/spec/lib/banzai/reference_parser/snippet_parser_spec.rb b/spec/lib/banzai/reference_parser/snippet_parser_spec.rb
index d217a775802..620875ece20 100644
--- a/spec/lib/banzai/reference_parser/snippet_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/snippet_parser_spec.rb
@@ -4,20 +4,199 @@ describe Banzai::ReferenceParser::SnippetParser, lib: true do
include ReferenceParserHelpers
let(:project) { create(:empty_project, :public) }
+
let(:user) { create(:user) }
- let(:snippet) { create(:snippet, project: project) }
+ let(:external_user) { create(:user, :external) }
+ let(:project_member) { create(:user) }
+
subject { described_class.new(project, user) }
let(:link) { empty_html_link }
+ def visible_references(snippet_visibility, user = nil)
+ snippet = create(:project_snippet, snippet_visibility, project: project)
+ link['data-project'] = project.id.to_s
+ link['data-snippet'] = snippet.id.to_s
+
+ subject.nodes_visible_to_user(user, [link])
+ end
+
+ before do
+ project.add_user(project_member, :developer)
+ end
+
describe '#nodes_visible_to_user' do
- context 'when the link has a data-issue attribute' do
- before { link['data-snippet'] = snippet.id.to_s }
+ context 'when a project is public and the snippets feature is enabled for everyone' do
+ before do
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::ENABLED)
+ end
+
+ it 'creates a reference for guest for a public snippet' do
+ expect(visible_references(:public)).to eq([link])
+ end
+
+ it 'creates a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to eq([link])
+ end
+
+ it 'creates a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to eq([link])
+ end
+
+ it 'does not create a reference for an external user for an internal snippet' do
+ expect(visible_references(:internal, external_user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is public and the snippets feature is enabled for project team members' do
+ before do
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::PRIVATE)
+ end
+
+ it 'creates a reference for a project member for a public snippet' do
+ expect(visible_references(:public, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public, nil)).to be_empty
+ end
+
+ it 'does not create a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for an internal snippet' do
+ expect(visible_references(:internal, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is internal and the snippets feature is enabled for everyone' do
+ before do
+ project.update_attribute(:visibility, Gitlab::VisibilityLevel::INTERNAL)
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::ENABLED)
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public)).to be_empty
+ end
+
+ it 'does not create a reference for an external user for a public snippet' do
+ expect(visible_references(:public, external_user)).to be_empty
+ end
- it_behaves_like "referenced feature visibility", "snippets"
+ it 'creates a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to eq([link])
+ end
+
+ it 'creates a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to eq([link])
+ end
+
+ it 'does not create a reference for an external user for an internal snippet' do
+ expect(visible_references(:internal, external_user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is internal and the snippets feature is enabled for project team members' do
+ before do
+ project.update_attribute(:visibility, Gitlab::VisibilityLevel::INTERNAL)
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::PRIVATE)
+ end
+
+ it 'creates a reference for a project member for a public snippet' do
+ expect(visible_references(:public, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public, nil)).to be_empty
+ end
+
+ it 'does not create reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for an internal snippet' do
+ expect(visible_references(:internal, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is private and the snippets feature is enabled for project team members' do
+ before do
+ project.update_attribute(:visibility, Gitlab::VisibilityLevel::PRIVATE)
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::PRIVATE)
+ end
+
+ it 'creates a reference for a project member for a public snippet' do
+ expect(visible_references(:public, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public, nil)).to be_empty
+ end
+
+ it 'does not create a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for an internal snippet' do
+ expect(visible_references(:internal, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
end
end
describe '#referenced_by' do
+ let(:snippet) { create(:snippet, project: project) }
describe 'when the link has a data-snippet attribute' do
context 'using an existing snippet ID' do
it 'returns an Array of snippets' do
@@ -31,7 +210,7 @@ describe Banzai::ReferenceParser::SnippetParser, lib: true do
it 'returns an empty Array' do
link['data-snippet'] = ''
- expect(subject.referenced_by([link])).to eq([])
+ expect(subject.referenced_by([link])).to be_empty
end
end
end
diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
index fe2c00bb2ca..2ca0773ad1d 100644
--- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
+++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
@@ -1,7 +1,8 @@
require 'spec_helper'
module Ci
- describe GitlabCiYamlProcessor, lib: true do
+ describe GitlabCiYamlProcessor, :lib do
+ subject { described_class.new(config, path) }
let(:path) { 'path' }
describe 'our current .gitlab-ci.yml' do
@@ -82,6 +83,67 @@ module Ci
end
end
+ describe '#stage_seeds' do
+ context 'when no refs policy is specified' do
+ let(:config) do
+ YAML.dump(production: { stage: 'deploy', script: 'cap prod' },
+ rspec: { stage: 'test', script: 'rspec' },
+ spinach: { stage: 'test', script: 'spinach' })
+ end
+
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'correctly fabricates a stage seeds object' do
+ seeds = subject.stage_seeds(pipeline)
+
+ expect(seeds.size).to eq 2
+ expect(seeds.first.stage[:name]).to eq 'test'
+ expect(seeds.second.stage[:name]).to eq 'deploy'
+ expect(seeds.first.builds.dig(0, :name)).to eq 'rspec'
+ expect(seeds.first.builds.dig(1, :name)).to eq 'spinach'
+ expect(seeds.second.builds.dig(0, :name)).to eq 'production'
+ end
+ end
+
+ context 'when refs policy is specified' do
+ let(:config) do
+ YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['master'] },
+ spinach: { stage: 'test', script: 'spinach', only: ['tags'] })
+ end
+
+ let(:pipeline) do
+ create(:ci_empty_pipeline, ref: 'feature', tag: true)
+ end
+
+ it 'returns stage seeds only assigned to master to master' do
+ seeds = subject.stage_seeds(pipeline)
+
+ expect(seeds.size).to eq 1
+ expect(seeds.first.stage[:name]).to eq 'test'
+ expect(seeds.first.builds.dig(0, :name)).to eq 'spinach'
+ end
+ end
+
+ context 'when source policy is specified' do
+ let(:config) do
+ YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] },
+ spinach: { stage: 'test', script: 'spinach', only: ['schedules'] })
+ end
+
+ let(:pipeline) do
+ create(:ci_empty_pipeline, source: :schedule)
+ end
+
+ it 'returns stage seeds only assigned to schedules' do
+ seeds = subject.stage_seeds(pipeline)
+
+ expect(seeds.size).to eq 1
+ expect(seeds.first.stage[:name]).to eq 'test'
+ expect(seeds.first.builds.dig(0, :name)).to eq 'spinach'
+ end
+ end
+ end
+
describe "#builds_for_ref" do
let(:type) { 'test' }
@@ -176,26 +238,44 @@ module Ci
expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0)
end
- it "returns builds if only has a triggers keyword specified and a trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: ["triggers"] }
- })
+ it "returns builds if only has special keywords specified and source matches" do
+ possibilities = [{ keyword: 'pushes', source: 'push' },
+ { keyword: 'web', source: 'web' },
+ { keyword: 'triggers', source: 'trigger' },
+ { keyword: 'schedules', source: 'schedule' },
+ { keyword: 'api', source: 'api' },
+ { keyword: 'external', source: 'external' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, only: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(1)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(1)
+ end
end
- it "does not return builds if only has a triggers keyword specified and no trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: ["triggers"] }
- })
+ it "does not return builds if only has special keywords specified and source doesn't match" do
+ possibilities = [{ keyword: 'pushes', source: 'web' },
+ { keyword: 'web', source: 'push' },
+ { keyword: 'triggers', source: 'schedule' },
+ { keyword: 'schedules', source: 'external' },
+ { keyword: 'api', source: 'trigger' },
+ { keyword: 'external', source: 'api' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, only: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(0)
+ end
end
it "returns builds if only has current repository path" do
@@ -332,26 +412,44 @@ module Ci
expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1)
end
- it "does not return builds if except has a triggers keyword specified and a trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: ["triggers"] }
- })
+ it "does not return builds if except has special keywords specified and source matches" do
+ possibilities = [{ keyword: 'pushes', source: 'push' },
+ { keyword: 'web', source: 'web' },
+ { keyword: 'triggers', source: 'trigger' },
+ { keyword: 'schedules', source: 'schedule' },
+ { keyword: 'api', source: 'api' },
+ { keyword: 'external', source: 'external' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, except: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(0)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(0)
+ end
end
- it "returns builds if except has a triggers keyword specified and no trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: ["triggers"] }
- })
+ it "returns builds if except has special keywords specified and source doesn't match" do
+ possibilities = [{ keyword: 'pushes', source: 'web' },
+ { keyword: 'web', source: 'push' },
+ { keyword: 'triggers', source: 'schedule' },
+ { keyword: 'schedules', source: 'external' },
+ { keyword: 'api', source: 'trigger' },
+ { keyword: 'external', source: 'api' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, except: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(1)
+ end
end
it "does not return builds if except has current repository path" do
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index 50bc3ef1b7c..d09da951869 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -17,7 +17,11 @@ describe Gitlab::Auth, lib: true do
end
it 'OPTIONAL_SCOPES contains all non-default scopes' do
- expect(subject::OPTIONAL_SCOPES).to eq [:read_user, :openid]
+ expect(subject::OPTIONAL_SCOPES).to eq %i[read_user read_registry openid]
+ end
+
+ it 'REGISTRY_SCOPES contains all registry related scopes' do
+ expect(subject::REGISTRY_SCOPES).to eq %i[read_registry]
end
end
@@ -143,6 +147,13 @@ describe Gitlab::Auth, lib: true do
expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, full_authentication_abilities))
end
+ it 'succeeds for personal access tokens with the `read_registry` scope' do
+ personal_access_token = create(:personal_access_token, scopes: ['read_registry'])
+
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '')
+ expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, [:read_container_image]))
+ end
+
it 'succeeds if it is an impersonation token' do
impersonation_token = create(:personal_access_token, :impersonation, scopes: ['api'])
@@ -150,18 +161,11 @@ describe Gitlab::Auth, lib: true do
expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(impersonation_token.user, nil, :personal_token, full_authentication_abilities))
end
- it 'fails for personal access tokens with other scopes' do
+ it 'limits abilities based on scope' do
personal_access_token = create(:personal_access_token, scopes: ['read_user'])
- expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '')
- expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil))
- end
-
- it 'fails for impersonation token with other scopes' do
- impersonation_token = create(:personal_access_token, scopes: ['read_user'])
-
- expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '')
- expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil))
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '')
+ expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, []))
end
it 'fails if password is nil' do
@@ -200,6 +204,12 @@ describe Gitlab::Auth, lib: true do
expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
expect(gl_auth.find_for_git_client(login, 'bar', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new)
end
+
+ it 'throws an error suggesting user create a PAT when internal auth is disabled' do
+ allow_any_instance_of(ApplicationSetting).to receive(:signin_enabled?) { false }
+
+ expect { gl_auth.find_for_git_client('foo', 'bar', project: nil, ip: 'ip') }.to raise_error(Gitlab::Auth::MissingPersonalTokenError)
+ end
end
describe 'find_with_user_password' do
diff --git a/spec/lib/gitlab/backup/repository_spec.rb b/spec/lib/gitlab/backup/repository_spec.rb
new file mode 100644
index 00000000000..51c1e9d657b
--- /dev/null
+++ b/spec/lib/gitlab/backup/repository_spec.rb
@@ -0,0 +1,63 @@
+require 'spec_helper'
+
+describe Backup::Repository, lib: true do
+ let(:progress) { StringIO.new }
+ let!(:project) { create(:empty_project) }
+
+ before do
+ allow(progress).to receive(:puts)
+ allow(progress).to receive(:print)
+
+ allow_any_instance_of(String).to receive(:color) do |string, _color|
+ string
+ end
+
+ allow_any_instance_of(described_class).to receive(:progress).and_return(progress)
+ end
+
+ describe '#dump' do
+ describe 'repo failure' do
+ before do
+ allow_any_instance_of(Repository).to receive(:empty_repo?).and_raise(Rugged::OdbError)
+ allow(Gitlab::Popen).to receive(:popen).and_return(['normal output', 0])
+ end
+
+ it 'does not raise error' do
+ expect { described_class.new.dump }.not_to raise_error
+ end
+
+ it 'shows the appropriate error' do
+ described_class.new.dump
+
+ expect(progress).to have_received(:puts).with("Ignoring repository error and continuing backing up project: #{project.full_path} - Rugged::OdbError")
+ end
+ end
+
+ describe 'command failure' do
+ before do
+ allow_any_instance_of(Repository).to receive(:empty_repo?).and_return(false)
+ allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1])
+ end
+
+ it 'shows the appropriate error' do
+ described_class.new.dump
+
+ expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error")
+ end
+ end
+ end
+
+ describe '#restore' do
+ describe 'command failure' do
+ before do
+ allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1])
+ end
+
+ it 'shows the appropriate error' do
+ described_class.new.restore
+
+ expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error")
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb
index 8d81ed5856e..c0c309d8179 100644
--- a/spec/lib/gitlab/checks/change_access_spec.rb
+++ b/spec/lib/gitlab/checks/change_access_spec.rb
@@ -23,29 +23,27 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
before { project.add_developer(user) }
context 'without failed checks' do
- it "doesn't return any error" do
- expect(subject.status).to be(true)
+ it "doesn't raise an error" do
+ expect { subject }.not_to raise_error
end
end
context 'when the user is not allowed to push code' do
- it 'returns an error' do
+ it 'raises an error' do
expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to push code to this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.')
end
end
context 'tags check' do
let(:ref) { 'refs/tags/v1.0.0' }
- it 'returns an error if the user is not allowed to update tags' do
+ it 'raises an error if the user is not allowed to update tags' do
allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true)
expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to change existing tags on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.')
end
context 'with protected tag' do
@@ -59,8 +57,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:newrev) { '0000000000000000000000000000000000000000' }
it 'is prevented' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('cannot be deleted')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/)
end
end
@@ -69,8 +66,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
it 'is prevented' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('cannot be updated')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/)
end
end
end
@@ -81,15 +77,14 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:ref) { 'refs/tags/v9.1.0' }
it 'prevents creation below access level' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('allowed to create this tag as it is protected')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/)
end
context 'when user has access' do
let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') }
it 'allows tag creation' do
- expect(subject.status).to be(true)
+ expect { subject }.not_to raise_error
end
end
end
@@ -101,9 +96,8 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:newrev) { '0000000000000000000000000000000000000000' }
let(:ref) { 'refs/heads/master' }
- it 'returns an error' do
- expect(subject.status).to be(false)
- expect(subject.message).to eq('The default branch of a project cannot be deleted.')
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.')
end
end
@@ -113,27 +107,24 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
allow(ProtectedBranch).to receive(:protected?).with(project, 'feature').and_return(true)
end
- it 'returns an error if the user is not allowed to do forced pushes to protected branches' do
+ it 'raises an error if the user is not allowed to do forced pushes to protected branches' do
expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to force push code to a protected branch on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.')
end
- it 'returns an error if the user is not allowed to merge to protected branches' do
+ it 'raises an error if the user is not allowed to merge to protected branches' do
expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true)
expect(user_access).to receive(:can_merge_to_branch?).and_return(false)
expect(user_access).to receive(:can_push_to_branch?).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to merge code into protected branches on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.')
end
- it 'returns an error if the user is not allowed to push to protected branches' do
+ it 'raises an error if the user is not allowed to push to protected branches' do
expect(user_access).to receive(:can_push_to_branch?).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to push code to protected branches on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.')
end
context 'branch deletion' do
@@ -141,9 +132,8 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:ref) { 'refs/heads/feature' }
context 'if the user is not allowed to delete protected branches' do
- it 'returns an error' do
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.')
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.')
end
end
@@ -156,14 +146,13 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:protocol) { 'web' }
it 'allows branch deletion' do
- expect(subject.status).to be(true)
+ expect { subject }.not_to raise_error
end
end
context 'over SSH or HTTP' do
- it 'returns an error' do
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You can only delete protected branches using the web interface.')
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.')
end
end
end
diff --git a/spec/lib/gitlab/ci/stage/seed_spec.rb b/spec/lib/gitlab/ci/stage/seed_spec.rb
new file mode 100644
index 00000000000..d7e91a5a62c
--- /dev/null
+++ b/spec/lib/gitlab/ci/stage/seed_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Stage::Seed do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ let(:builds) do
+ [{ name: 'rspec' }, { name: 'spinach' }]
+ end
+
+ subject do
+ described_class.new(pipeline, 'test', builds)
+ end
+
+ describe '#stage' do
+ it 'returns hash attributes of a stage' do
+ expect(subject.stage).to be_a Hash
+ expect(subject.stage).to include(:name, :project)
+ end
+ end
+
+ describe '#builds' do
+ it 'returns hash attributes of all builds' do
+ expect(subject.builds.size).to eq 2
+ expect(subject.builds).to all(include(ref: 'master'))
+ expect(subject.builds).to all(include(tag: false))
+ expect(subject.builds).to all(include(project: pipeline.project))
+ expect(subject.builds)
+ .to all(include(trigger_request: pipeline.trigger_requests.first))
+ end
+ end
+
+ describe '#user=' do
+ let(:user) { build(:user) }
+
+ it 'assignes relevant pipeline attributes' do
+ subject.user = user
+
+ expect(subject.builds).to all(include(user: user))
+ end
+ end
+
+ describe '#create!' do
+ it 'creates all stages and builds' do
+ subject.create!
+
+ expect(pipeline.reload.stages.count).to eq 1
+ expect(pipeline.reload.builds.count).to eq 2
+ expect(pipeline.builds).to all(satisfy { |job| job.stage_id.present? })
+ expect(pipeline.builds).to all(satisfy { |job| job.pipeline.present? })
+ expect(pipeline.builds).to all(satisfy { |job| job.project.present? })
+ expect(pipeline.stages)
+ .to all(satisfy { |stage| stage.pipeline.present? })
+ expect(pipeline.stages)
+ .to all(satisfy { |stage| stage.project.present? })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci_access_spec.rb b/spec/lib/gitlab/ci_access_spec.rb
new file mode 100644
index 00000000000..eaf8f1d0f1c
--- /dev/null
+++ b/spec/lib/gitlab/ci_access_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe Gitlab::CiAccess, lib: true do
+ let(:access) { Gitlab::CiAccess.new }
+
+ describe '#can_do_action?' do
+ context 'when action is :build_download_code' do
+ it { expect(access.can_do_action?(:build_download_code)).to be_truthy }
+ end
+
+ context 'when action is not :build_download_code' do
+ it { expect(access.can_do_action?(:download_code)).to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/current_settings_spec.rb b/spec/lib/gitlab/current_settings_spec.rb
index c796c98ec9f..fda39d78610 100644
--- a/spec/lib/gitlab/current_settings_spec.rb
+++ b/spec/lib/gitlab/current_settings_spec.rb
@@ -14,20 +14,20 @@ describe Gitlab::CurrentSettings do
end
it 'attempts to use cached values first' do
- expect(ApplicationSetting).to receive(:current)
- expect(ApplicationSetting).not_to receive(:last)
+ expect(ApplicationSetting).to receive(:cached)
expect(current_application_settings).to be_a(ApplicationSetting)
end
it 'falls back to DB if Redis returns an empty value' do
+ expect(ApplicationSetting).to receive(:cached).and_return(nil)
expect(ApplicationSetting).to receive(:last).and_call_original
expect(current_application_settings).to be_a(ApplicationSetting)
end
it 'falls back to DB if Redis fails' do
- expect(ApplicationSetting).to receive(:current).and_raise(::Redis::BaseError)
+ expect(ApplicationSetting).to receive(:cached).and_raise(::Redis::BaseError)
expect(ApplicationSetting).to receive(:last).and_call_original
expect(current_application_settings).to be_a(ApplicationSetting)
@@ -37,6 +37,7 @@ describe Gitlab::CurrentSettings do
context 'with DB unavailable' do
before do
allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(false)
+ allow_any_instance_of(described_class).to receive(:retrieve_settings_from_database_cache?).and_return(nil)
end
it 'returns an in-memory ApplicationSetting object' do
diff --git a/spec/lib/gitlab/diff/diff_refs_spec.rb b/spec/lib/gitlab/diff/diff_refs_spec.rb
new file mode 100644
index 00000000000..a8173558c00
--- /dev/null
+++ b/spec/lib/gitlab/diff/diff_refs_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+describe Gitlab::Diff::DiffRefs, lib: true do
+ let(:project) { create(:project, :repository) }
+
+ describe '#compare_in' do
+ context 'with diff refs for the initial commit' do
+ let(:commit) { project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') }
+ subject { commit.diff_refs }
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+
+ context 'with diff refs for a commit' do
+ let(:commit) { project.commit('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ subject { commit.diff_refs }
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+
+ context 'with diff refs for a comparison through the base' do
+ subject do
+ described_class.new(
+ start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature
+ base_sha: 'ae73cb07c9eeaf35924a10f713b364d32b2dd34f',
+ head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master
+ )
+ end
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+
+ context 'with diff refs for a straight comparison' do
+ subject do
+ described_class.new(
+ start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature
+ base_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9',
+ head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master
+ )
+ end
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb
index f2bc15d39d7..d81774c8b8f 100644
--- a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb
+++ b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb
@@ -5,15 +5,7 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do
let(:diff_files) { described_class.new(merge_request.merge_request_diff, diff_options: nil).diff_files }
it 'does not highlight binary files' do
- allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(double("text?" => false))
-
- expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines)
-
- diff_files
- end
-
- it 'does not highlight file if blob is not accessable' do
- allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(nil)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(false)
expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines)
@@ -21,7 +13,7 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do
end
it 'does not files marked as undiffable in .gitattributes' do
- allow_any_instance_of(Repository).to receive(:diffable?).and_return(false)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(false)
expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines)
diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb
index 050689b7c9a..a9953bb0d01 100644
--- a/spec/lib/gitlab/diff/file_spec.rb
+++ b/spec/lib/gitlab/diff/file_spec.rb
@@ -6,7 +6,7 @@ describe Gitlab::Diff::File, lib: true do
let(:project) { create(:project, :repository) }
let(:commit) { project.commit(sample_commit.id) }
let(:diff) { commit.raw_diffs.first }
- let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: commit.diff_refs, repository: project.repository) }
+ let(:diff_file) { described_class.new(diff, diff_refs: commit.diff_refs, repository: project.repository) }
describe '#diff_lines' do
let(:diff_lines) { diff_file.diff_lines }
@@ -63,11 +63,33 @@ describe Gitlab::Diff::File, lib: true do
end
end
- describe '#blob' do
+ describe '#new_blob' do
it 'returns blob of new commit' do
- data = diff_file.blob.data
+ data = diff_file.new_blob.data
expect(data).to include('raise RuntimeError, "System commands must be given as an array of strings"')
end
end
+
+ describe '#diffable?' do
+ let(:commit) { project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') }
+ let(:diffs) { commit.diffs }
+
+ before do
+ info_dir_path = File.join(project.repository.path_to_repo, 'info')
+
+ FileUtils.mkdir(info_dir_path) unless File.exist?(info_dir_path)
+ File.write(File.join(info_dir_path, 'attributes'), "*.md -diff\n")
+ end
+
+ it "returns true for files that do not have attributes" do
+ diff_file = diffs.diff_file_with_new_path('LICENSE')
+ expect(diff_file.diffable?).to be_truthy
+ end
+
+ it "returns false for files that have been marked as not being diffable in attributes" do
+ diff_file = diffs.diff_file_with_new_path('README.md')
+ expect(diff_file.diffable?).to be_falsey
+ end
+ end
end
diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb
index 7095104d75c..b3d46e69ccb 100644
--- a/spec/lib/gitlab/diff/position_spec.rb
+++ b/spec/lib/gitlab/diff/position_spec.rb
@@ -381,6 +381,54 @@ describe Gitlab::Diff::Position, lib: true do
end
end
+ describe "position for a file in a straight comparison" do
+ let(:diff_refs) do
+ Gitlab::Diff::DiffRefs.new(
+ start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature
+ base_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9',
+ head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master
+ )
+ end
+
+ subject do
+ described_class.new(
+ old_path: "files/ruby/feature.rb",
+ new_path: "files/ruby/feature.rb",
+ old_line: 3,
+ new_line: nil,
+ diff_refs: diff_refs
+ )
+ end
+
+ describe "#diff_file" do
+ it "returns the correct diff file" do
+ diff_file = subject.diff_file(project.repository)
+
+ expect(diff_file.deleted_file?).to be true
+ expect(diff_file.old_path).to eq(subject.old_path)
+ expect(diff_file.diff_refs).to eq(subject.diff_refs)
+ end
+ end
+
+ describe "#diff_line" do
+ it "returns the correct diff line" do
+ diff_line = subject.diff_line(project.repository)
+
+ expect(diff_line.removed?).to be true
+ expect(diff_line.old_line).to eq(subject.old_line)
+ expect(diff_line.text).to eq("- puts 'bar'")
+ end
+ end
+
+ describe "#line_code" do
+ it "returns the correct line code" do
+ line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 0, subject.old_line)
+
+ expect(subject.line_code(project.repository)).to eq(line_code)
+ end
+ end
+ end
+
describe "#to_json" do
let(:hash) do
{
diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb
index 24df04e985a..3c6ef7c7ccb 100644
--- a/spec/lib/gitlab/etag_caching/middleware_spec.rb
+++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb
@@ -164,6 +164,25 @@ describe Gitlab::EtagCaching::Middleware do
end
end
+ context 'when GitLab instance is using a relative URL' do
+ before do
+ mock_app_response
+ end
+
+ it 'uses full path as cache key' do
+ env = {
+ 'PATH_INFO' => enabled_path,
+ 'SCRIPT_NAME' => '/relative-gitlab'
+ }
+
+ expect_any_instance_of(Gitlab::EtagCaching::Store)
+ .to receive(:get).with("/relative-gitlab#{enabled_path}")
+ .and_return(nil)
+
+ middleware.call(env)
+ end
+ end
+
def mock_app_response
allow(app).to receive(:call).and_return([app_status_code, {}, ['body']])
end
diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb
index 0418fc0a1e2..2bb40827fcf 100644
--- a/spec/lib/gitlab/etag_caching/router_spec.rb
+++ b/spec/lib/gitlab/etag_caching/router_spec.rb
@@ -2,104 +2,115 @@ require 'spec_helper'
describe Gitlab::EtagCaching::Router do
it 'matches issue notes endpoint' do
- env = build_env(
+ request = build_request(
'/my-group/and-subgroup/here-comes-the-project/noteable/issue/1/notes'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'issue_notes'
end
it 'matches issue title endpoint' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/issues/123/realtime_changes'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'issue_title'
end
it 'matches project pipelines endpoint' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/pipelines.json'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'project_pipelines'
end
it 'matches commit pipelines endpoint' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/commit/aa8260d253a53f73f6c26c734c72fdd600f6e6d4/pipelines.json'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'commit_pipelines'
end
it 'matches new merge request pipelines endpoint' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/merge_requests/new.json'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'new_merge_request_pipelines'
end
it 'matches merge request pipelines endpoint' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/merge_requests/234/pipelines.json'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'merge_request_pipelines'
end
+ it 'matches build endpoint' do
+ request = build_request(
+ '/my-group/my-project/builds/234.json'
+ )
+
+ result = described_class.match(request)
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_build'
+ end
+
it 'does not match blob with confusing name' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/blob/master/pipelines.json'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_blank
end
it 'matches the environments path' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/environments.json'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'environments'
end
it 'matches pipeline#show endpoint' do
- env = build_env(
+ request = build_request(
'/my-group/my-project/pipelines/2.json'
)
- result = described_class.match(env)
+ result = described_class.match(request)
expect(result).to be_present
expect(result.name).to eq 'project_pipeline'
end
- def build_env(path)
- { 'PATH_INFO' => path }
+ def build_request(path)
+ double(path_info: path)
end
end
diff --git a/spec/lib/gitlab/git/compare_spec.rb b/spec/lib/gitlab/git/compare_spec.rb
index 7c45071ec45..4c9f4a28f32 100644
--- a/spec/lib/gitlab/git/compare_spec.rb
+++ b/spec/lib/gitlab/git/compare_spec.rb
@@ -2,8 +2,8 @@ require "spec_helper"
describe Gitlab::Git::Compare, seed_helper: true do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
- let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, false) }
- let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, true) }
+ let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: false) }
+ let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: true) }
describe '#commits' do
subject do
diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb
index 3565e719ad3..a9a7bba2c05 100644
--- a/spec/lib/gitlab/git/diff_collection_spec.rb
+++ b/spec/lib/gitlab/git/diff_collection_spec.rb
@@ -341,7 +341,8 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
end
context 'when diff is quite large will collapse by default' do
- let(:iterator) { [{ diff: 'a' * 20480 }] }
+ let(:iterator) { [{ diff: 'a' * (Gitlab::Git::Diff.collapse_limit + 1) }] }
+ let(:max_files) { 100 }
context 'when no collapse is set' do
let(:expanded) { true }
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 8e24168ad71..da213f617cc 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -31,6 +31,36 @@ EOT
[".gitmodules"]).patches.first
end
+ describe 'size limit feature toggles' do
+ context 'when the feature gitlab_git_diff_size_limit_increase is enabled' do
+ before do
+ Feature.enable('gitlab_git_diff_size_limit_increase')
+ end
+
+ it 'returns 200 KB for size_limit' do
+ expect(described_class.size_limit).to eq(200.kilobytes)
+ end
+
+ it 'returns 100 KB for collapse_limit' do
+ expect(described_class.collapse_limit).to eq(100.kilobytes)
+ end
+ end
+
+ context 'when the feature gitlab_git_diff_size_limit_increase is disabled' do
+ before do
+ Feature.disable('gitlab_git_diff_size_limit_increase')
+ end
+
+ it 'returns 100 KB for size_limit' do
+ expect(described_class.size_limit).to eq(100.kilobytes)
+ end
+
+ it 'returns 10 KB for collapse_limit' do
+ expect(described_class.collapse_limit).to eq(10.kilobytes)
+ end
+ end
+ end
+
describe '.new' do
context 'using a Hash' do
context 'with a small diff' do
@@ -47,7 +77,7 @@ EOT
context 'using a diff that is too large' do
it 'prunes the diff' do
- diff = described_class.new(diff: 'a' * 204800)
+ diff = described_class.new(diff: 'a' * (described_class.size_limit + 1))
expect(diff.diff).to be_empty
expect(diff).to be_too_large
@@ -85,8 +115,8 @@ EOT
# The patch total size is 200, with lines between 21 and 54.
# This is a quick-and-dirty way to test this. Ideally, a new patch is
# added to the test repo with a size that falls between the real limits.
- stub_const("#{described_class}::SIZE_LIMIT", 150)
- stub_const("#{described_class}::COLLAPSE_LIMIT", 100)
+ allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(150)
+ allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(100)
end
it 'prunes the diff as a large diff instead of as a collapsed diff' do
@@ -110,23 +140,23 @@ EOT
end
end
- context 'using a Gitaly::CommitDiffResponse' do
+ context 'using a GitalyClient::Diff' do
let(:diff) do
described_class.new(
- Gitaly::CommitDiffResponse.new(
+ Gitlab::GitalyClient::Diff.new(
to_path: ".gitmodules",
from_path: ".gitmodules",
old_mode: 0100644,
new_mode: 0100644,
from_id: '357406f3075a57708d0163752905cc1576fceacc',
to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
- raw_chunks: raw_chunks
+ patch: raw_patch
)
)
end
context 'with a small diff' do
- let(:raw_chunks) { [@raw_diff_hash[:diff]] }
+ let(:raw_patch) { @raw_diff_hash[:diff] }
it 'initializes the diff' do
expect(diff.to_hash).to eq(@raw_diff_hash)
@@ -138,7 +168,7 @@ EOT
end
context 'using a diff that is too large' do
- let(:raw_chunks) { ['a' * 204800] }
+ let(:raw_patch) { 'a' * 204800 }
it 'prunes the diff' do
expect(diff.diff).to be_empty
@@ -299,7 +329,7 @@ EOT
describe '#collapsed?' do
it 'returns true for a diff that is quite large' do
- diff = described_class.new({ diff: 'a' * 20480 }, expanded: false)
+ diff = described_class.new({ diff: 'a' * (described_class.collapse_limit + 1) }, expanded: false)
expect(diff).to be_collapsed
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 26215381cc4..e1e4aa9fde9 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -1235,47 +1235,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
- describe '#diffable' do
- info_dir_path = attributes_path = File.join(SEED_STORAGE_PATH, TEST_REPO_PATH, 'info')
- attributes_path = File.join(info_dir_path, 'attributes')
-
- before(:all) do
- FileUtils.mkdir(info_dir_path) unless File.exist?(info_dir_path)
- File.write(attributes_path, "*.md -diff\n")
- end
-
- it "should return true for files which are text and do not have attributes" do
- blob = Gitlab::Git::Blob.find(
- repository,
- '33bcff41c232a11727ac6d660bd4b0c2ba86d63d',
- 'LICENSE'
- )
- expect(repository.diffable?(blob)).to be_truthy
- end
-
- it "should return false for binary files which do not have attributes" do
- blob = Gitlab::Git::Blob.find(
- repository,
- '33bcff41c232a11727ac6d660bd4b0c2ba86d63d',
- 'files/images/logo-white.png'
- )
- expect(repository.diffable?(blob)).to be_falsey
- end
-
- it "should return false for text files which have been marked as not being diffable in attributes" do
- blob = Gitlab::Git::Blob.find(
- repository,
- '33bcff41c232a11727ac6d660bd4b0c2ba86d63d',
- 'README.md'
- )
- expect(repository.diffable?(blob)).to be_falsey
- end
-
- after(:all) do
- FileUtils.rm_rf(info_dir_path)
- end
- end
-
describe '#tag_exists?' do
it 'returns true for an existing tag' do
tag = repository.tag_names.first
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index 25769977f24..36d1d777583 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -1,10 +1,13 @@
require 'spec_helper'
describe Gitlab::GitAccess, lib: true do
- let(:access) { Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities) }
+ let(:pull_access_check) { access.check('git-upload-pack', '_any') }
+ let(:push_access_check) { access.check('git-receive-pack', '_any') }
+ let(:access) { Gitlab::GitAccess.new(actor, project, protocol, authentication_abilities: authentication_abilities) }
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:actor) { user }
+ let(:protocol) { 'ssh' }
let(:authentication_abilities) do
[
:read_project,
@@ -15,49 +18,188 @@ describe Gitlab::GitAccess, lib: true do
describe '#check with single protocols allowed' do
def disable_protocol(protocol)
- settings = ::ApplicationSetting.create_from_defaults
- settings.update_attribute(:enabled_git_access_protocol, protocol)
+ allow(Gitlab::ProtocolAccess).to receive(:allowed?).with(protocol).and_return(false)
end
context 'ssh disabled' do
before do
disable_protocol('ssh')
- @acc = Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities)
end
it 'blocks ssh git push' do
- expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey
+ expect { push_access_check }.to raise_unauthorized('Git access over SSH is not allowed')
end
it 'blocks ssh git pull' do
- expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey
+ expect { pull_access_check }.to raise_unauthorized('Git access over SSH is not allowed')
end
end
context 'http disabled' do
+ let(:protocol) { 'http' }
+
before do
disable_protocol('http')
- @acc = Gitlab::GitAccess.new(actor, project, 'http', authentication_abilities: authentication_abilities)
end
it 'blocks http push' do
- expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey
+ expect { push_access_check }.to raise_unauthorized('Git access over HTTP is not allowed')
end
it 'blocks http git pull' do
- expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey
+ expect { pull_access_check }.to raise_unauthorized('Git access over HTTP is not allowed')
end
end
end
- describe '#check_download_access!' do
- subject { access.check('git-upload-pack', '_any') }
+ describe '#check_project_accessibility!' do
+ context 'when the project exists' do
+ context 'when actor exists' do
+ context 'when actor is a DeployKey' do
+ let(:deploy_key) { create(:deploy_key, user: user, can_push: true) }
+ let(:actor) { deploy_key }
+
+ context 'when the DeployKey has access to the project' do
+ before { deploy_key.projects << project }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'allows push access' do
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
+ context 'when the Deploykey does not have access to the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+ context 'when actor is a User' do
+ context 'when the User can read the project' do
+ before { project.team << [user, :master] }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'allows push access' do
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
+ context 'when the User cannot read the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+
+ # For backwards compatibility
+ context 'when actor is :ci' do
+ let(:actor) { :ci }
+ let(:authentication_abilities) { build_authentication_abilities }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'does not block pushes with "not found"' do
+ expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.')
+ end
+ end
+ end
+
+ context 'when actor is nil' do
+ let(:actor) { nil }
+
+ context 'when guests can read the project' do
+ let(:project) { create(:project, :repository, :public) }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'does not block pushes with "not found"' do
+ expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.')
+ end
+ end
+
+ context 'when guests cannot read the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+ end
+
+ context 'when the project is nil' do
+ let(:project) { nil }
+
+ it 'blocks any command with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+
+ describe '#check_command_disabled!' do
+ before { project.team << [user, :master] }
+
+ context 'over http' do
+ let(:protocol) { 'http' }
+
+ context 'when the git-upload-pack command is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+ end
+
+ context 'when calling git-upload-pack' do
+ it { expect { pull_access_check }.to raise_unauthorized('Pulling over HTTP is not allowed.') }
+ end
+
+ context 'when calling git-receive-pack' do
+ it { expect { push_access_check }.not_to raise_error }
+ end
+ end
+
+ context 'when the git-receive-pack command is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ end
+
+ context 'when calling git-receive-pack' do
+ it { expect { push_access_check }.to raise_unauthorized('Pushing over HTTP is not allowed.') }
+ end
+
+ context 'when calling git-upload-pack' do
+ it { expect { pull_access_check }.not_to raise_error }
+ end
+ end
+ end
+ end
+
+ describe '#check_download_access!' do
describe 'master permissions' do
before { project.team << [user, :master] }
context 'pull code' do
- it { expect(subject.allowed?).to be_truthy }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
@@ -65,8 +207,7 @@ describe Gitlab::GitAccess, lib: true do
before { project.team << [user, :guest] }
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
- it { expect(subject.message).to match(/You are not allowed to download code/) }
+ it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') }
end
end
@@ -77,24 +218,22 @@ describe Gitlab::GitAccess, lib: true do
end
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
- it { expect(subject.message).to match(/Your account has been blocked/) }
+ it { expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.') }
end
end
describe 'without access to project' do
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
context 'when project is public' do
let(:public_project) { create(:project, :public, :repository) }
- let(:guest_access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) }
- subject { guest_access.check('git-upload-pack', '_any') }
+ let(:access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) }
context 'when repository is enabled' do
it 'give access to download code' do
- expect(subject.allowed?).to be_truthy
+ expect { pull_access_check }.not_to raise_error
end
end
@@ -102,8 +241,7 @@ describe Gitlab::GitAccess, lib: true do
it 'does not give access to download code' do
public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
- expect(subject.allowed?).to be_falsey
- expect(subject.message).to match(/You are not allowed to download code/)
+ expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.')
end
end
end
@@ -117,26 +255,26 @@ describe Gitlab::GitAccess, lib: true do
context 'when project is authorized' do
before { key.projects << project }
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
context 'when unauthorized' do
context 'from public project' do
let(:project) { create(:project, :public, :repository) }
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
context 'from internal project' do
let(:project) { create(:project, :internal, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
context 'from private project' do
let(:project) { create(:project, :private, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -149,7 +287,7 @@ describe Gitlab::GitAccess, lib: true do
let(:project) { create(:project, :repository, namespace: user.namespace) }
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
@@ -157,7 +295,7 @@ describe Gitlab::GitAccess, lib: true do
before { project.team << [user, :reporter] }
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
@@ -168,16 +306,24 @@ describe Gitlab::GitAccess, lib: true do
before { project.team << [user, :reporter] }
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
context 'when is not member of the project' do
context 'pull code' do
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') }
end
end
end
+
+ describe 'generic CI (build without a user)' do
+ let(:actor) { :ci }
+
+ context 'pull code' do
+ it { expect { pull_access_check }.not_to raise_error }
+ end
+ end
end
end
@@ -365,42 +511,32 @@ describe Gitlab::GitAccess, lib: true do
end
end
- shared_examples 'pushing code' do |can|
- subject { access.check('git-receive-pack', '_any') }
+ describe 'build authentication abilities' do
+ let(:authentication_abilities) { build_authentication_abilities }
context 'when project is authorized' do
- before { authorize }
+ before { project.team << [user, :reporter] }
- it { expect(subject).public_send(can, be_allowed) }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'when unauthorized' do
context 'to public project' do
let(:project) { create(:project, :public, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'to internal project' do
let(:project) { create(:project, :internal, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'to private project' do
let(:project) { create(:project, :private, :repository) }
- it { expect(subject).not_to be_allowed }
- end
- end
- end
-
- describe 'build authentication abilities' do
- let(:authentication_abilities) { build_authentication_abilities }
-
- it_behaves_like 'pushing code', :not_to do
- def authorize
- project.team << [user, :reporter]
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -412,9 +548,29 @@ describe Gitlab::GitAccess, lib: true do
context 'when deploy_key can push' do
let(:can_push) { true }
- it_behaves_like 'pushing code', :to do
- def authorize
- key.projects << project
+ context 'when project is authorized' do
+ before { key.projects << project }
+
+ it { expect { push_access_check }.not_to raise_error }
+ end
+
+ context 'when unauthorized' do
+ context 'to public project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'to internal project' do
+ let(:project) { create(:project, :internal, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
+
+ context 'to private project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -422,9 +578,29 @@ describe Gitlab::GitAccess, lib: true do
context 'when deploy_key cannot push' do
let(:can_push) { false }
- it_behaves_like 'pushing code', :not_to do
- def authorize
- key.projects << project
+ context 'when project is authorized' do
+ before { key.projects << project }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'when unauthorized' do
+ context 'to public project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'to internal project' do
+ let(:project) { create(:project, :internal, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
+
+ context 'to private project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -432,6 +608,14 @@ describe Gitlab::GitAccess, lib: true do
private
+ def raise_unauthorized(message)
+ raise_error(Gitlab::GitAccess::UnauthorizedError, message)
+ end
+
+ def raise_not_found(message)
+ raise_error(Gitlab::GitAccess::NotFoundError, message)
+ end
+
def build_authentication_abilities
[
:read_project,
diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb
index 1ae293416e4..a1eb95750ba 100644
--- a/spec/lib/gitlab/git_access_wiki_spec.rb
+++ b/spec/lib/gitlab/git_access_wiki_spec.rb
@@ -20,7 +20,7 @@ describe Gitlab::GitAccessWiki, lib: true do
subject { access.check('git-receive-pack', changes) }
- it { expect(subject.allowed?).to be_truthy }
+ it { expect { subject }.not_to raise_error }
end
def changes
@@ -36,7 +36,7 @@ describe Gitlab::GitAccessWiki, lib: true do
context 'when wiki feature is enabled' do
it 'give access to download wiki code' do
- expect(subject.allowed?).to be_truthy
+ expect { subject }.not_to raise_error
end
end
@@ -44,8 +44,7 @@ describe Gitlab::GitAccessWiki, lib: true do
it 'does not give access to download wiki code' do
project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
- expect(subject.allowed?).to be_falsey
- expect(subject.message).to match(/You are not allowed to download code/)
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to download code from this project.')
end
end
end
diff --git a/spec/lib/gitlab/gitaly_client/diff_spec.rb b/spec/lib/gitlab/gitaly_client/diff_spec.rb
new file mode 100644
index 00000000000..2960c9a79ad
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/diff_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::Diff, lib: true do
+ let(:diff_fields) do
+ {
+ to_path: ".gitmodules",
+ from_path: ".gitmodules",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 100
+ }
+ end
+
+ subject { described_class.new(diff_fields) }
+
+ it { is_expected.to respond_to(:from_path) }
+ it { is_expected.to respond_to(:to_path) }
+ it { is_expected.to respond_to(:old_mode) }
+ it { is_expected.to respond_to(:new_mode) }
+ it { is_expected.to respond_to(:from_id) }
+ it { is_expected.to respond_to(:to_id) }
+ it { is_expected.to respond_to(:patch) }
+
+ describe '#==' do
+ it { expect(subject).to eq(described_class.new(diff_fields)) }
+ it { expect(subject).not_to eq(described_class.new(diff_fields.merge(patch: 'a'))) }
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb
new file mode 100644
index 00000000000..07650013052
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::DiffStitcher, lib: true do
+ describe 'enumeration' do
+ it 'combines segregated diff messages together' do
+ diff_1 = OpenStruct.new(
+ to_path: ".gitmodules",
+ from_path: ".gitmodules",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 100
+ )
+ diff_2 = OpenStruct.new(
+ to_path: ".gitignore",
+ from_path: ".gitignore",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 200
+ )
+ diff_3 = OpenStruct.new(
+ to_path: "README",
+ from_path: "README",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 100
+ )
+
+ msg_1 = OpenStruct.new(diff_1.to_h.except(:patch))
+ msg_1.raw_patch_data = diff_1.patch
+ msg_1.end_of_patch = true
+
+ msg_2 = OpenStruct.new(diff_2.to_h.except(:patch))
+ msg_2.raw_patch_data = diff_2.patch[0..100]
+ msg_2.end_of_patch = false
+
+ msg_3 = OpenStruct.new(raw_patch_data: diff_2.patch[101..-1], end_of_patch: true)
+
+ msg_4 = OpenStruct.new(diff_3.to_h.except(:patch))
+ msg_4.raw_patch_data = diff_3.patch
+ msg_4.end_of_patch = true
+
+ diff_msgs = [msg_1, msg_2, msg_3, msg_4]
+
+ expected_diffs = [
+ Gitlab::GitalyClient::Diff.new(diff_1.to_h),
+ Gitlab::GitalyClient::Diff.new(diff_2.to_h),
+ Gitlab::GitalyClient::Diff.new(diff_3.to_h)
+ ]
+
+ expect(described_class.new(diff_msgs).to_a).to eq(expected_diffs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb b/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb
new file mode 100644
index 00000000000..ed757ed60d8
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb
@@ -0,0 +1,41 @@
+describe Gitlab::HealthChecks::PrometheusTextFormat do
+ let(:metric_class) { Gitlab::HealthChecks::Metric }
+ subject { described_class.new }
+
+ describe '#marshal' do
+ let(:sample_metrics) do
+ [metric_class.new('metric1', 1),
+ metric_class.new('metric2', 2)]
+ end
+
+ it 'marshal to text with non repeating type definition' do
+ expected = <<-EXPECTED.strip_heredoc
+ # TYPE metric1 gauge
+ metric1 1
+ # TYPE metric2 gauge
+ metric2 2
+ EXPECTED
+
+ expect(subject.marshal(sample_metrics)).to eq(expected)
+ end
+
+ context 'metrics where name repeats' do
+ let(:sample_metrics) do
+ [metric_class.new('metric1', 1),
+ metric_class.new('metric1', 2),
+ metric_class.new('metric2', 3)]
+ end
+
+ it 'marshal to text with non repeating type definition' do
+ expected = <<-EXPECTED.strip_heredoc
+ # TYPE metric1 gauge
+ metric1 1
+ metric1 2
+ # TYPE metric2 gauge
+ metric2 3
+ EXPECTED
+ expect(subject.marshal(sample_metrics)).to eq(expected)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb
index a20cef3b000..c2bb9f9a166 100644
--- a/spec/lib/gitlab/highlight_spec.rb
+++ b/spec/lib/gitlab/highlight_spec.rb
@@ -7,30 +7,6 @@ describe Gitlab::Highlight, lib: true do
let(:repository) { project.repository }
let(:commit) { project.commit(sample_commit.id) }
- describe '.highlight_lines' do
- let(:lines) do
- Gitlab::Highlight.highlight_lines(project.repository, commit.id, 'files/ruby/popen.rb')
- end
-
- it 'highlights all the lines properly' do
- expect(lines[4]).to eq(%Q{<span id="LC5" class="line" lang="ruby"> <span class="kp">extend</span> <span class="nb">self</span></span>\n})
- expect(lines[21]).to eq(%Q{<span id="LC22" class="line" lang="ruby"> <span class="k">unless</span> <span class="no">File</span><span class="p">.</span><span class="nf">directory?</span><span class="p">(</span><span class="n">path</span><span class="p">)</span></span>\n})
- expect(lines[26]).to eq(%Q{<span id="LC27" class="line" lang="ruby"> <span class="vi">@cmd_status</span> <span class="o">=</span> <span class="mi">0</span></span>\n})
- end
-
- describe 'with CRLF' do
- let(:branch) { 'crlf-diff' }
- let(:blob) { repository.blob_at_branch(branch, path) }
- let(:lines) do
- Gitlab::Highlight.highlight_lines(project.repository, 'crlf-diff', 'files/whitespace')
- end
-
- it 'strips extra LFs' do
- expect(lines[0]).to eq("<span id=\"LC1\" class=\"line\" lang=\"plaintext\">test </span>")
- end
- end
- end
-
describe 'custom highlighting from .gitattributes' do
let(:branch) { 'gitattributes' }
let(:blob) { repository.blob_at_branch(branch, path) }
@@ -59,6 +35,19 @@ describe Gitlab::Highlight, lib: true do
end
describe '#highlight' do
+ describe 'with CRLF' do
+ let(:branch) { 'crlf-diff' }
+ let(:path) { 'files/whitespace' }
+ let(:blob) { repository.blob_at_branch(branch, path) }
+ let(:lines) do
+ Gitlab::Highlight.highlight(blob.path, blob.data, repository: repository).lines
+ end
+
+ it 'strips extra LFs' do
+ expect(lines[0]).to eq("<span id=\"LC1\" class=\"line\" lang=\"plaintext\">test </span>")
+ end
+ end
+
it 'links dependencies via DependencyLinker' do
expect(Gitlab::DependencyLinker).to receive(:link).
with('file.name', 'Contents', anything).and_call_original
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 2e9646286df..412eb33b35b 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -91,6 +91,7 @@ merge_request_diff:
pipelines:
- project
- user
+- stages
- statuses
- builds
- trigger_requests
@@ -104,9 +105,15 @@ pipelines:
- artifacts
- pipeline_schedule
- merge_requests
+stages:
+- project
+- pipeline
+- statuses
+- builds
statuses:
- project
- pipeline
+- stage
- user
- auto_canceled_by
variables:
@@ -144,7 +151,9 @@ merge_access_levels:
push_access_levels:
- protected_branch
create_access_levels:
+- user
- protected_tag
+- group
container_repositories:
- project
- name
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index 54ce8051f30..50ff6ecc1e0 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -92,6 +92,7 @@ Milestone:
ProjectSnippet:
- id
- title
+- description
- content
- author_id
- project_id
@@ -174,6 +175,7 @@ MergeRequestDiff:
Ci::Pipeline:
- id
- project_id
+- source
- ref
- sha
- before_sha
@@ -191,7 +193,13 @@ Ci::Pipeline:
- lock_version
- auto_canceled_by_id
- pipeline_schedule_id
-- source
+Ci::Stage:
+- id
+- name
+- project_id
+- pipeline_id
+- created_at
+- updated_at
CommitStatus:
- id
- project_id
@@ -213,6 +221,7 @@ CommitStatus:
- stage
- trigger_request_id
- stage_idx
+- stage_id
- tag
- ref
- user_id
diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb
index f4aab429931..a0eda685ca3 100644
--- a/spec/lib/gitlab/ldap/user_spec.rb
+++ b/spec/lib/gitlab/ldap/user_spec.rb
@@ -37,7 +37,7 @@ describe Gitlab::LDAP::User, lib: true do
end
it "does not mark existing ldap user as changed" do
- create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain', ldap_email: true)
+ create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain', external_email: true, email_provider: 'ldapmain')
expect(ldap_user.changed?).to be_falsey
end
end
@@ -141,8 +141,12 @@ describe Gitlab::LDAP::User, lib: true do
expect(ldap_user.gl_user.email).to eq(info[:email])
end
- it "has ldap_email set to true" do
- expect(ldap_user.gl_user.ldap_email?).to be(true)
+ it "has external_email set to true" do
+ expect(ldap_user.gl_user.external_email?).to be(true)
+ end
+
+ it "has email_provider set to provider" do
+ expect(ldap_user.gl_user.email_provider).to eql 'ldapmain'
end
end
@@ -155,8 +159,8 @@ describe Gitlab::LDAP::User, lib: true do
expect(ldap_user.gl_user.temp_oauth_email?).to be(true)
end
- it "has ldap_email set to false" do
- expect(ldap_user.gl_user.ldap_email?).to be(false)
+ it "has external_email set to false" do
+ expect(ldap_user.gl_user.external_email?).to be(false)
end
end
end
diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb
index 208a8d028cd..5a87b906609 100644
--- a/spec/lib/gitlab/metrics_spec.rb
+++ b/spec/lib/gitlab/metrics_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe Gitlab::Metrics do
+ include StubENV
+
describe '.settings' do
it 'returns a Hash' do
expect(described_class.settings).to be_an_instance_of(Hash)
@@ -9,7 +11,19 @@ describe Gitlab::Metrics do
describe '.enabled?' do
it 'returns a boolean' do
- expect([true, false].include?(described_class.enabled?)).to eq(true)
+ expect(described_class.enabled?).to be_in([true, false])
+ end
+ end
+
+ describe '.prometheus_metrics_enabled?' do
+ it 'returns a boolean' do
+ expect(described_class.prometheus_metrics_enabled?).to be_in([true, false])
+ end
+ end
+
+ describe '.influx_metrics_enabled?' do
+ it 'returns a boolean' do
+ expect(described_class.influx_metrics_enabled?).to be_in([true, false])
end
end
@@ -177,4 +191,133 @@ describe Gitlab::Metrics do
end
end
end
+
+ shared_examples 'prometheus metrics API' do
+ describe '#counter' do
+ subject { described_class.counter(:couter, 'doc') }
+
+ describe '#increment' do
+ it 'successfully calls #increment without arguments' do
+ expect { subject.increment }.not_to raise_exception
+ end
+
+ it 'successfully calls #increment with 1 argument' do
+ expect { subject.increment({}) }.not_to raise_exception
+ end
+
+ it 'successfully calls #increment with 2 arguments' do
+ expect { subject.increment({}, 1) }.not_to raise_exception
+ end
+ end
+ end
+
+ describe '#summary' do
+ subject { described_class.summary(:summary, 'doc') }
+
+ describe '#observe' do
+ it 'successfully calls #observe with 2 arguments' do
+ expect { subject.observe({}, 2) }.not_to raise_exception
+ end
+ end
+ end
+
+ describe '#gauge' do
+ subject { described_class.gauge(:gauge, 'doc') }
+
+ describe '#set' do
+ it 'successfully calls #set with 2 arguments' do
+ expect { subject.set({}, 1) }.not_to raise_exception
+ end
+ end
+ end
+
+ describe '#histogram' do
+ subject { described_class.histogram(:histogram, 'doc') }
+
+ describe '#observe' do
+ it 'successfully calls #observe with 2 arguments' do
+ expect { subject.observe({}, 2) }.not_to raise_exception
+ end
+ end
+ end
+ end
+
+ context 'prometheus metrics disabled' do
+ before do
+ allow(described_class).to receive(:prometheus_metrics_enabled?).and_return(false)
+ end
+
+ it_behaves_like 'prometheus metrics API'
+
+ describe '#null_metric' do
+ subject { described_class.provide_metric(:test) }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#counter' do
+ subject { described_class.counter(:counter, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#summary' do
+ subject { described_class.summary(:summary, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#gauge' do
+ subject { described_class.gauge(:gauge, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#histogram' do
+ subject { described_class.histogram(:histogram, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+ end
+
+ context 'prometheus metrics enabled' do
+ let(:metrics_multiproc_dir) { Dir.mktmpdir }
+
+ before do
+ stub_const('Prometheus::Client::Multiprocdir', metrics_multiproc_dir)
+ allow(described_class).to receive(:prometheus_metrics_enabled?).and_return(true)
+ end
+
+ it_behaves_like 'prometheus metrics API'
+
+ describe '#null_metric' do
+ subject { described_class.provide_metric(:test) }
+
+ it { is_expected.to be_nil }
+ end
+
+ describe '#counter' do
+ subject { described_class.counter(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#summary' do
+ subject { described_class.summary(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#gauge' do
+ subject { described_class.gauge(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#histogram' do
+ subject { described_class.histogram(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+ end
end
diff --git a/spec/lib/gitlab/o_auth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb
index 828c953197d..8943d1aa488 100644
--- a/spec/lib/gitlab/o_auth/user_spec.rb
+++ b/spec/lib/gitlab/o_auth/user_spec.rb
@@ -28,11 +28,11 @@ describe Gitlab::OAuth::User, lib: true do
end
end
- describe '#save' do
- def stub_omniauth_config(messages)
- allow(Gitlab.config.omniauth).to receive_messages(messages)
- end
+ def stub_omniauth_config(messages)
+ allow(Gitlab.config.omniauth).to receive_messages(messages)
+ end
+ describe '#save' do
def stub_ldap_config(messages)
allow(Gitlab::LDAP::Config).to receive_messages(messages)
end
@@ -377,4 +377,40 @@ describe Gitlab::OAuth::User, lib: true do
end
end
end
+
+ describe 'updating email' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ before do
+ stub_omniauth_config(sync_email_from_provider: 'my-provider')
+ end
+
+ context "when provider sets an email" do
+ it "updates the user email" do
+ expect(gl_user.email).to eq(info_hash[:email])
+ end
+
+ it "has external_email set to true" do
+ expect(gl_user.external_email?).to be(true)
+ end
+
+ it "has email_provider set to provider" do
+ expect(gl_user.email_provider).to eql 'my-provider'
+ end
+ end
+
+ context "when provider doesn't set an email" do
+ before do
+ info_hash.delete(:email)
+ end
+
+ it "does not update the user email" do
+ expect(gl_user.email).not_to eq(info_hash[:email])
+ end
+
+ it "has external_email set to false" do
+ expect(gl_user.external_email?).to be(false)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/otp_key_rotator_spec.rb b/spec/lib/gitlab/otp_key_rotator_spec.rb
new file mode 100644
index 00000000000..6e6e9ce29ac
--- /dev/null
+++ b/spec/lib/gitlab/otp_key_rotator_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe Gitlab::OtpKeyRotator do
+ let(:file) { Tempfile.new("otp-key-rotator-test") }
+ let(:filename) { file.path }
+ let(:old_key) { Gitlab::Application.secrets.otp_key_base }
+ let(:new_key) { "00" * 32 }
+ let!(:users) { create_list(:user, 5, :two_factor) }
+
+ after do
+ file.close
+ file.unlink
+ end
+
+ def data
+ CSV.read(filename)
+ end
+
+ def build_row(user, applied = false)
+ [user.id.to_s, encrypt_otp(user, old_key), encrypt_otp(user, new_key)]
+ end
+
+ def encrypt_otp(user, key)
+ opts = {
+ value: user.otp_secret,
+ iv: user.encrypted_otp_secret_iv.unpack("m").join,
+ salt: user.encrypted_otp_secret_salt.unpack("m").join,
+ algorithm: 'aes-256-cbc',
+ insecure_mode: true,
+ key: key
+ }
+ [Encryptor.encrypt(opts)].pack("m")
+ end
+
+ subject(:rotator) { described_class.new(filename) }
+
+ describe '#rotate!' do
+ subject(:rotation) { rotator.rotate!(old_key: old_key, new_key: new_key) }
+
+ it 'stores the calculated values in a spreadsheet' do
+ rotation
+
+ expect(data).to match_array(users.map {|u| build_row(u) })
+ end
+
+ context 'new key is too short' do
+ let(:new_key) { "00" * 31 }
+
+ it { expect { rotation }.to raise_error(ArgumentError) }
+ end
+
+ context 'new key is the same as the old key' do
+ let(:new_key) { old_key }
+
+ it { expect { rotation }.to raise_error(ArgumentError) }
+ end
+ end
+
+ describe '#rollback!' do
+ it 'updates rows to the old value' do
+ file.puts("#{users[0].id},old,new")
+ file.close
+
+ rotator.rollback!
+
+ expect(users[0].reload.encrypted_otp_secret).to eq('old')
+ expect(users[1].reload.encrypted_otp_secret).not_to eq('old')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/uploads_transfer_spec.rb b/spec/lib/gitlab/uploads_transfer_spec.rb
new file mode 100644
index 00000000000..109559bb01c
--- /dev/null
+++ b/spec/lib/gitlab/uploads_transfer_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe Gitlab::UploadsTransfer do
+ it 'leaves avatar uploads where they are' do
+ project_with_avatar = create(:empty_project, :with_avatar)
+
+ described_class.new.rename_namespace('project', 'project-renamed')
+
+ expect(File.exist?(project_with_avatar.avatar.path)).to be_truthy
+ end
+end
diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb
index 3fe8cf43934..e8a37e8d77b 100644
--- a/spec/lib/gitlab/url_builder_spec.rb
+++ b/spec/lib/gitlab/url_builder_spec.rb
@@ -97,6 +97,17 @@ describe Gitlab::UrlBuilder, lib: true do
end
end
+ context 'on a PersonalSnippet' do
+ it 'returns a proper URL' do
+ personal_snippet = create(:personal_snippet)
+ note = build_stubbed(:note_on_personal_snippet, noteable: personal_snippet)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/snippets/#{note.noteable_id}#note_#{note.id}"
+ end
+ end
+
context 'on another object' do
it 'returns a proper URL' do
project = build_stubbed(:empty_project)
diff --git a/spec/migrations/clean_upload_symlinks_spec.rb b/spec/migrations/clean_upload_symlinks_spec.rb
new file mode 100644
index 00000000000..cecb3ddac53
--- /dev/null
+++ b/spec/migrations/clean_upload_symlinks_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170406111121_clean_upload_symlinks.rb')
+
+describe CleanUploadSymlinks do
+ let(:migration) { described_class.new }
+ let(:test_dir) { File.join(Rails.root, "tmp", "tests", "move_uploads_test") }
+ let(:uploads_dir) { File.join(test_dir, "public", "uploads") }
+ let(:new_uploads_dir) { File.join(uploads_dir, "system") }
+ let(:original_path) { File.join(new_uploads_dir, 'user') }
+ let(:symlink_path) { File.join(uploads_dir, 'user') }
+
+ before do
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ FileUtils.mkdir_p(uploads_dir)
+ allow(migration).to receive(:base_directory).and_return(test_dir)
+ allow(migration).to receive(:say)
+ end
+
+ describe "#up" do
+ before do
+ FileUtils.mkdir_p(original_path)
+ FileUtils.ln_s(original_path, symlink_path)
+ end
+
+ it 'removes the symlink' do
+ migration.up
+
+ expect(File.symlink?(symlink_path)).to be(false)
+ end
+ end
+
+ describe '#down' do
+ before do
+ FileUtils.mkdir_p(File.join(original_path))
+ FileUtils.touch(File.join(original_path, 'dummy.file'))
+ end
+
+ it 'creates a symlink' do
+ expected_path = File.join(symlink_path, "dummy.file")
+ migration.down
+
+ expect(File.exist?(expected_path)).to be(true)
+ expect(File.symlink?(symlink_path)).to be(true)
+ end
+ end
+end
diff --git a/spec/migrations/migrate_build_stage_reference_spec.rb b/spec/migrations/migrate_build_stage_reference_spec.rb
new file mode 100644
index 00000000000..80b321860c2
--- /dev/null
+++ b/spec/migrations/migrate_build_stage_reference_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170526185921_migrate_build_stage_reference.rb')
+
+describe MigrateBuildStageReference, :migration do
+ ##
+ # Create test data - pipeline and CI/CD jobs.
+ #
+
+ let(:jobs) { table(:ci_builds) }
+ let(:stages) { table(:ci_stages) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:projects) { table(:projects) }
+
+ before do
+ # Create projects
+ #
+ projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1')
+ projects.create!(id: 456, name: 'gitlab2', path: 'gitlab2')
+
+ # Create CI/CD pipelines
+ #
+ pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a')
+ pipelines.create!(id: 2, project_id: 456, ref: 'feature', sha: '21a3deb')
+
+ # Create CI/CD jobs
+ #
+ jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 3, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ jobs.create!(id: 4, commit_id: 1, project_id: 123, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 5, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2')
+ jobs.create!(id: 6, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 7, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 8, commit_id: 3, project_id: 789, stage_idx: 3, stage: 'deploy')
+
+ # Create CI/CD stages
+ #
+ stages.create(id: 101, pipeline_id: 1, project_id: 123, name: 'test')
+ stages.create(id: 102, pipeline_id: 1, project_id: 123, name: 'build')
+ stages.create(id: 103, pipeline_id: 1, project_id: 123, name: 'deploy')
+ stages.create(id: 104, pipeline_id: 2, project_id: 456, name: 'test:1')
+ stages.create(id: 105, pipeline_id: 2, project_id: 456, name: 'test:2')
+ stages.create(id: 106, pipeline_id: 2, project_id: 456, name: 'deploy')
+ end
+
+ it 'correctly migrate build stage references' do
+ expect(jobs.where(stage_id: nil).count).to eq 8
+
+ migrate!
+
+ expect(jobs.where(stage_id: nil).count).to eq 1
+
+ expect(jobs.find(1).stage_id).to eq 102
+ expect(jobs.find(2).stage_id).to eq 102
+ expect(jobs.find(3).stage_id).to eq 101
+ expect(jobs.find(4).stage_id).to eq 103
+ expect(jobs.find(5).stage_id).to eq 105
+ expect(jobs.find(6).stage_id).to eq 104
+ expect(jobs.find(7).stage_id).to eq 104
+ expect(jobs.find(8).stage_id).to eq nil
+ end
+end
diff --git a/spec/migrations/migrate_pipeline_stages_spec.rb b/spec/migrations/migrate_pipeline_stages_spec.rb
new file mode 100644
index 00000000000..c47f2bb8ff9
--- /dev/null
+++ b/spec/migrations/migrate_pipeline_stages_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170526185842_migrate_pipeline_stages.rb')
+
+describe MigratePipelineStages, :migration do
+ ##
+ # Create test data - pipeline and CI/CD jobs.
+ #
+
+ let(:jobs) { table(:ci_builds) }
+ let(:stages) { table(:ci_stages) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:projects) { table(:projects) }
+
+ before do
+ # Create projects
+ #
+ projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1')
+ projects.create!(id: 456, name: 'gitlab2', path: 'gitlab2')
+
+ # Create CI/CD pipelines
+ #
+ pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a')
+ pipelines.create!(id: 2, project_id: 456, ref: 'feature', sha: '21a3deb')
+
+ # Create CI/CD jobs
+ #
+ jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 3, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ jobs.create!(id: 4, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ jobs.create!(id: 5, commit_id: 1, project_id: 123, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 6, commit_id: 2, project_id: 456, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 7, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2')
+ jobs.create!(id: 8, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 9, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 10, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2')
+ jobs.create!(id: 11, commit_id: 3, project_id: 456, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 12, commit_id: 2, project_id: 789, stage_idx: 3, stage: 'deploy')
+ end
+
+ it 'correctly migrates pipeline stages' do
+ expect(stages.count).to be_zero
+
+ migrate!
+
+ expect(stages.count).to eq 6
+ expect(stages.all.pluck(:name))
+ .to match_array %w[test build deploy test:1 test:2 deploy]
+ expect(stages.where(pipeline_id: 1).order(:id).pluck(:name))
+ .to eq %w[test build deploy]
+ expect(stages.where(pipeline_id: 2).order(:id).pluck(:name))
+ .to eq %w[test:1 test:2 deploy]
+ expect(stages.where(pipeline_id: 3).count).to be_zero
+ expect(stages.where(project_id: 789).count).to be_zero
+ end
+end
diff --git a/spec/migrations/move_uploads_to_system_dir_spec.rb b/spec/migrations/move_uploads_to_system_dir_spec.rb
new file mode 100644
index 00000000000..37d66452447
--- /dev/null
+++ b/spec/migrations/move_uploads_to_system_dir_spec.rb
@@ -0,0 +1,68 @@
+require "spec_helper"
+require Rails.root.join("db", "migrate", "20170316163845_move_uploads_to_system_dir.rb")
+
+describe MoveUploadsToSystemDir do
+ let(:migration) { described_class.new }
+ let(:test_dir) { File.join(Rails.root, "tmp", "move_uploads_test") }
+ let(:uploads_dir) { File.join(test_dir, "public", "uploads") }
+ let(:new_uploads_dir) { File.join(uploads_dir, "system") }
+
+ before do
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ FileUtils.mkdir_p(uploads_dir)
+ allow(migration).to receive(:base_directory).and_return(test_dir)
+ allow(migration).to receive(:say)
+ end
+
+ describe "#up" do
+ before do
+ FileUtils.mkdir_p(File.join(uploads_dir, 'user'))
+ FileUtils.touch(File.join(uploads_dir, 'user', 'dummy.file'))
+ end
+
+ it 'moves the directory to the new path' do
+ expected_path = File.join(new_uploads_dir, 'user', 'dummy.file')
+
+ migration.up
+
+ expect(File.exist?(expected_path)).to be(true)
+ end
+
+ it 'creates a symlink in the old location' do
+ symlink_path = File.join(uploads_dir, 'user')
+ expected_path = File.join(symlink_path, 'dummy.file')
+
+ migration.up
+
+ expect(File.exist?(expected_path)).to be(true)
+ expect(File.symlink?(symlink_path)).to be(true)
+ end
+ end
+
+ describe "#down" do
+ before do
+ FileUtils.mkdir_p(File.join(new_uploads_dir, 'user'))
+ FileUtils.touch(File.join(new_uploads_dir, 'user', 'dummy.file'))
+ end
+
+ it 'moves the directory to the old path' do
+ expected_path = File.join(uploads_dir, 'user', 'dummy.file')
+
+ migration.down
+
+ expect(File.exist?(expected_path)).to be(true)
+ end
+
+ it 'removes the symlink if it existed' do
+ FileUtils.ln_s(File.join(new_uploads_dir, 'user'), File.join(uploads_dir, 'user'))
+
+ directory = File.join(uploads_dir, 'user')
+ expected_path = File.join(directory, 'dummy.file')
+
+ migration.down
+
+ expect(File.exist?(expected_path)).to be(true)
+ expect(File.symlink?(directory)).to be(false)
+ end
+ end
+end
diff --git a/spec/migrations/rename_system_namespaces_spec.rb b/spec/migrations/rename_system_namespaces_spec.rb
new file mode 100644
index 00000000000..626a6005838
--- /dev/null
+++ b/spec/migrations/rename_system_namespaces_spec.rb
@@ -0,0 +1,254 @@
+require "spec_helper"
+require Rails.root.join("db", "migrate", "20170316163800_rename_system_namespaces.rb")
+
+describe RenameSystemNamespaces, truncate: true do
+ let(:migration) { described_class.new }
+ let(:test_dir) { File.join(Rails.root, "tmp", "tests", "rename_namespaces_test") }
+ let(:uploads_dir) { File.join(test_dir, "public", "uploads") }
+ let(:system_namespace) do
+ namespace = build(:namespace, path: "system")
+ namespace.save(validate: false)
+ namespace
+ end
+
+ def save_invalid_routable(routable)
+ routable.__send__(:prepare_route)
+ routable.save(validate: false)
+ end
+
+ before do
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ FileUtils.mkdir_p(uploads_dir)
+ FileUtils.remove_dir(TestEnv.repos_path) if File.directory?(TestEnv.repos_path)
+ allow(migration).to receive(:say)
+ allow(migration).to receive(:uploads_dir).and_return(uploads_dir)
+ end
+
+ describe "#system_namespace" do
+ it "only root namespaces called with path `system`" do
+ system_namespace
+ system_namespace_with_parent = build(:namespace, path: 'system', parent: create(:namespace))
+ system_namespace_with_parent.save(validate: false)
+
+ expect(migration.system_namespace.id).to eq(system_namespace.id)
+ end
+ end
+
+ describe "#up" do
+ before do
+ system_namespace
+ end
+
+ it "doesn't break if there are no namespaces called system" do
+ Namespace.delete_all
+
+ migration.up
+ end
+
+ it "renames namespaces called system" do
+ migration.up
+
+ expect(system_namespace.reload.path).to eq("system0")
+ end
+
+ it "renames the route to the namespace" do
+ migration.up
+
+ expect(system_namespace.reload.full_path).to eq("system0")
+ end
+
+ it "renames the route for projects of the namespace" do
+ project = build(:project, path: "project-path", namespace: system_namespace)
+ save_invalid_routable(project)
+
+ migration.up
+
+ expect(project.route.reload.path).to eq("system0/project-path")
+ end
+
+ it "doesn't touch routes of namespaces that look like system" do
+ namespace = create(:group, path: 'systemlookalike')
+ project = create(:project, namespace: namespace, path: 'the-project')
+
+ migration.up
+
+ expect(project.route.reload.path).to eq('systemlookalike/the-project')
+ expect(namespace.route.reload.path).to eq('systemlookalike')
+ end
+
+ it "moves the the repository for a project in the namespace" do
+ project = build(:project, namespace: system_namespace, path: "system-project")
+ save_invalid_routable(project)
+ TestEnv.copy_repo(project,
+ bare_repo: TestEnv.factory_repo_path_bare,
+ refs: TestEnv::BRANCH_SHA)
+ expected_repo = File.join(TestEnv.repos_path, "system0", "system-project.git")
+
+ migration.up
+
+ expect(File.directory?(expected_repo)).to be(true)
+ end
+
+ it "moves the uploads for the namespace" do
+ allow(migration).to receive(:move_namespace_folders).with(Settings.pages.path, "system", "system0")
+ expect(migration).to receive(:move_namespace_folders).with(uploads_dir, "system", "system0")
+
+ migration.up
+ end
+
+ it "moves the pages for the namespace" do
+ allow(migration).to receive(:move_namespace_folders).with(uploads_dir, "system", "system0")
+ expect(migration).to receive(:move_namespace_folders).with(Settings.pages.path, "system", "system0")
+
+ migration.up
+ end
+
+ describe "clears the markdown cache for projects in the system namespace" do
+ let!(:project) do
+ project = build(:project, namespace: system_namespace)
+ save_invalid_routable(project)
+ project
+ end
+
+ it 'removes description_html from projects' do
+ migration.up
+
+ expect(project.reload.description_html).to be_nil
+ end
+
+ it 'removes issue descriptions' do
+ issue = create(:issue, project: project, description_html: 'Issue description')
+
+ migration.up
+
+ expect(issue.reload.description_html).to be_nil
+ end
+
+ it 'removes merge request descriptions' do
+ merge_request = create(:merge_request,
+ source_project: project,
+ target_project: project,
+ description_html: 'MergeRequest description')
+
+ migration.up
+
+ expect(merge_request.reload.description_html).to be_nil
+ end
+
+ it 'removes note html' do
+ note = create(:note,
+ project: project,
+ noteable: create(:issue, project: project),
+ note_html: 'note description')
+
+ migration.up
+
+ expect(note.reload.note_html).to be_nil
+ end
+
+ it 'removes milestone description' do
+ milestone = create(:milestone,
+ project: project,
+ description_html: 'milestone description')
+
+ migration.up
+
+ expect(milestone.reload.description_html).to be_nil
+ end
+ end
+
+ context "system namespace -> subgroup -> system0 project" do
+ it "updates the route of the project correctly" do
+ subgroup = build(:group, path: "subgroup", parent: system_namespace)
+ save_invalid_routable(subgroup)
+ project = build(:project, path: "system0", namespace: subgroup)
+ save_invalid_routable(project)
+
+ migration.up
+
+ expect(project.route.reload.path).to eq("system0/subgroup/system0")
+ end
+ end
+ end
+
+ describe "#move_repositories" do
+ let(:namespace) { create(:group, name: "hello-group") }
+ it "moves a project for a namespace" do
+ create(:project, namespace: namespace, path: "hello-project")
+ expected_path = File.join(TestEnv.repos_path, "bye-group", "hello-project.git")
+
+ migration.move_repositories(namespace, "hello-group", "bye-group")
+
+ expect(File.directory?(expected_path)).to be(true)
+ end
+
+ it "moves a namespace in a subdirectory correctly" do
+ child_namespace = create(:group, name: "sub-group", parent: namespace)
+ create(:project, namespace: child_namespace, path: "hello-project")
+
+ expected_path = File.join(TestEnv.repos_path, "hello-group", "renamed-sub-group", "hello-project.git")
+
+ migration.move_repositories(child_namespace, "hello-group/sub-group", "hello-group/renamed-sub-group")
+
+ expect(File.directory?(expected_path)).to be(true)
+ end
+
+ it "moves a parent namespace with subdirectories" do
+ child_namespace = create(:group, name: "sub-group", parent: namespace)
+ create(:project, namespace: child_namespace, path: "hello-project")
+ expected_path = File.join(TestEnv.repos_path, "renamed-group", "sub-group", "hello-project.git")
+
+ migration.move_repositories(child_namespace, "hello-group", "renamed-group")
+
+ expect(File.directory?(expected_path)).to be(true)
+ end
+ end
+
+ describe "#move_namespace_folders" do
+ it "moves a namespace with files" do
+ source = File.join(uploads_dir, "parent-group", "sub-group")
+ FileUtils.mkdir_p(source)
+ destination = File.join(uploads_dir, "parent-group", "moved-group")
+ FileUtils.touch(File.join(source, "test.txt"))
+ expected_file = File.join(destination, "test.txt")
+
+ migration.move_namespace_folders(uploads_dir, File.join("parent-group", "sub-group"), File.join("parent-group", "moved-group"))
+
+ expect(File.exist?(expected_file)).to be(true)
+ end
+
+ it "moves a parent namespace uploads" do
+ source = File.join(uploads_dir, "parent-group", "sub-group")
+ FileUtils.mkdir_p(source)
+ destination = File.join(uploads_dir, "moved-parent", "sub-group")
+ FileUtils.touch(File.join(source, "test.txt"))
+ expected_file = File.join(destination, "test.txt")
+
+ migration.move_namespace_folders(uploads_dir, "parent-group", "moved-parent")
+
+ expect(File.exist?(expected_file)).to be(true)
+ end
+ end
+
+ describe "#child_ids_for_parent" do
+ it "collects child ids for all levels" do
+ parent = create(:group)
+ first_child = create(:group, parent: parent)
+ second_child = create(:group, parent: parent)
+ third_child = create(:group, parent: second_child)
+ all_ids = [parent.id, first_child.id, second_child.id, third_child.id]
+
+ collected_ids = migration.child_ids_for_parent(parent, ids: [parent.id])
+
+ expect(collected_ids).to contain_exactly(*all_ids)
+ end
+ end
+
+ describe "#remove_last_ocurrence" do
+ it "removes only the last occurance of a string" do
+ input = "this/is/system/namespace/with/system"
+
+ expect(migration.remove_last_occurrence(input, "system")).to eq("this/is/system/namespace/with/")
+ end
+ end
+end
diff --git a/spec/migrations/update_upload_paths_to_system_spec.rb b/spec/migrations/update_upload_paths_to_system_spec.rb
new file mode 100644
index 00000000000..7df44515424
--- /dev/null
+++ b/spec/migrations/update_upload_paths_to_system_spec.rb
@@ -0,0 +1,53 @@
+require "spec_helper"
+require Rails.root.join("db", "post_migrate", "20170317162059_update_upload_paths_to_system.rb")
+
+describe UpdateUploadPathsToSystem do
+ let(:migration) { described_class.new }
+
+ before do
+ allow(migration).to receive(:say)
+ end
+
+ describe "#uploads_to_switch_to_new_path" do
+ it "contains only uploads with the old path for the correct models" do
+ _upload_for_other_type = create(:upload, model: create(:ci_pipeline), path: "uploads/ci_pipeline/avatar.jpg")
+ _upload_with_system_path = create(:upload, model: create(:empty_project), path: "uploads/system/project/avatar.jpg")
+ _upload_with_other_path = create(:upload, model: create(:empty_project), path: "thelongsecretforafileupload/avatar.jpg")
+ old_upload = create(:upload, model: create(:empty_project), path: "uploads/project/avatar.jpg")
+ group_upload = create(:upload, model: create(:group), path: "uploads/group/avatar.jpg")
+
+ expect(Upload.where(migration.uploads_to_switch_to_new_path)).to contain_exactly(old_upload, group_upload)
+ end
+ end
+
+ describe "#uploads_to_switch_to_old_path" do
+ it "contains only uploads with the new path for the correct models" do
+ _upload_for_other_type = create(:upload, model: create(:ci_pipeline), path: "uploads/ci_pipeline/avatar.jpg")
+ upload_with_system_path = create(:upload, model: create(:empty_project), path: "uploads/system/project/avatar.jpg")
+ _upload_with_other_path = create(:upload, model: create(:empty_project), path: "thelongsecretforafileupload/avatar.jpg")
+ _old_upload = create(:upload, model: create(:empty_project), path: "uploads/project/avatar.jpg")
+
+ expect(Upload.where(migration.uploads_to_switch_to_old_path)).to contain_exactly(upload_with_system_path)
+ end
+ end
+
+ describe "#up", truncate: true do
+ it "updates old upload records to the new path" do
+ old_upload = create(:upload, model: create(:empty_project), path: "uploads/project/avatar.jpg")
+
+ migration.up
+
+ expect(old_upload.reload.path).to eq("uploads/system/project/avatar.jpg")
+ end
+ end
+
+ describe "#down", truncate: true do
+ it "updates the new system patsh to the old paths" do
+ new_upload = create(:upload, model: create(:empty_project), path: "uploads/system/project/avatar.jpg")
+
+ migration.down
+
+ expect(new_upload.reload.path).to eq("uploads/project/avatar.jpg")
+ end
+ end
+end
diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb
index ced93c8f762..90aec2b45e6 100644
--- a/spec/models/abuse_report_spec.rb
+++ b/spec/models/abuse_report_spec.rb
@@ -28,9 +28,7 @@ RSpec.describe AbuseReport, type: :model do
end
it 'lets a worker delete the user' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id,
- delete_solo_owned_groups: true,
- hard_delete: true)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, hard_delete: true)
subject.remove_user(deleted_by: user)
end
diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb
index f19e1af65a6..e1193e0d19a 100644
--- a/spec/models/blob_spec.rb
+++ b/spec/models/blob_spec.rb
@@ -199,6 +199,14 @@ describe Blob do
end
end
+ describe '#file_type' do
+ it 'returns the file type' do
+ blob = fake_blob(path: 'README.md')
+
+ expect(blob.file_type).to eq(:readme)
+ end
+ end
+
describe '#simple_viewer' do
context 'when the blob is empty' do
it 'returns an empty viewer' do
diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb
index d56379eb59d..574438838d8 100644
--- a/spec/models/blob_viewer/base_spec.rb
+++ b/spec/models/blob_viewer/base_spec.rb
@@ -106,9 +106,9 @@ describe BlobViewer::Base, model: true do
end
describe '#render_error' do
- context 'when expanded' do
+ context 'when the blob is expanded' do
before do
- viewer.expanded = true
+ blob.expand!
end
context 'when the blob size is larger than the size limit' do
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index e2406290c6c..b0716e04d3d 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -427,6 +427,42 @@ describe Ci::Build, :models do
end
end
+ describe '#environment_url' do
+ subject { job.environment_url }
+
+ context 'when yaml environment uses $CI_COMMIT_REF_NAME' do
+ let(:job) do
+ create(:ci_build,
+ ref: 'master',
+ options: { environment: { url: 'http://review/$CI_COMMIT_REF_NAME' } })
+ end
+
+ it { is_expected.to eq('http://review/master') }
+ end
+
+ context 'when yaml environment uses yaml_variables containing symbol keys' do
+ let(:job) do
+ create(:ci_build,
+ yaml_variables: [{ key: :APP_HOST, value: 'host' }],
+ options: { environment: { url: 'http://review/$APP_HOST' } })
+ end
+
+ it { is_expected.to eq('http://review/host') }
+ end
+
+ context 'when yaml environment does not have url' do
+ let(:job) { create(:ci_build, environment: 'staging') }
+
+ let!(:environment) do
+ create(:environment, project: job.project, name: job.environment)
+ end
+
+ it 'returns the external_url from persisted environment' do
+ is_expected.to eq(environment.external_url)
+ end
+ end
+ end
+
describe '#starts_environment?' do
subject { build.starts_environment? }
@@ -918,6 +954,10 @@ describe Ci::Build, :models do
it { is_expected.to eq(environment) }
end
+
+ context 'when there is no environment' do
+ it { is_expected.to be_nil }
+ end
end
describe '#play' do
@@ -1139,6 +1179,7 @@ describe Ci::Build, :models do
{ key: 'CI_PROJECT_ID', value: project.id.to_s, public: true },
{ key: 'CI_PROJECT_NAME', value: project.path, public: true },
{ key: 'CI_PROJECT_PATH', value: project.full_path, public: true },
+ { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path.parameterize, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true },
{ key: 'CI_PROJECT_URL', value: project.web_url, public: true },
{ key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true },
@@ -1176,11 +1217,6 @@ describe Ci::Build, :models do
end
context 'when build has an environment' do
- before do
- build.update(environment: 'production')
- create(:environment, project: build.project, name: 'production', slug: 'prod-slug')
- end
-
let(:environment_variables) do
[
{ key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true },
@@ -1188,7 +1224,56 @@ describe Ci::Build, :models do
]
end
- it { environment_variables.each { |v| is_expected.to include(v) } }
+ let!(:environment) do
+ create(:environment,
+ project: build.project,
+ name: 'production',
+ slug: 'prod-slug',
+ external_url: '')
+ end
+
+ before do
+ build.update(environment: 'production')
+ end
+
+ shared_examples 'containing environment variables' do
+ it { environment_variables.each { |v| is_expected.to include(v) } }
+ end
+
+ context 'when no URL was set' do
+ it_behaves_like 'containing environment variables'
+
+ it 'does not have CI_ENVIRONMENT_URL' do
+ keys = subject.map { |var| var[:key] }
+
+ expect(keys).not_to include('CI_ENVIRONMENT_URL')
+ end
+ end
+
+ context 'when an URL was set' do
+ let(:url) { 'http://host/test' }
+
+ before do
+ environment_variables <<
+ { key: 'CI_ENVIRONMENT_URL', value: url, public: true }
+ end
+
+ context 'when the URL was set from the job' do
+ before do
+ build.update(options: { environment: { url: 'http://host/$CI_JOB_NAME' } })
+ end
+
+ it_behaves_like 'containing environment variables'
+ end
+
+ context 'when the URL was not set from the job, but environment' do
+ before do
+ environment.update(external_url: url)
+ end
+
+ it_behaves_like 'containing environment variables'
+ end
+ end
end
context 'when build started manually' do
diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/legacy_stage_spec.rb
index 8f6ab908987..48116c7e701 100644
--- a/spec/models/ci/stage_spec.rb
+++ b/spec/models/ci/legacy_stage_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::Stage, models: true do
+describe Ci::LegacyStage, :models do
let(:stage) { build(:ci_stage) }
let(:pipeline) { stage.pipeline }
let(:stage_name) { stage.name }
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index ae1b01b76ab..b50c7700bd3 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -224,8 +224,19 @@ describe Ci::Pipeline, models: true do
status: 'success')
end
- describe '#stages' do
- subject { pipeline.stages }
+ describe '#stage_seeds' do
+ let(:pipeline) do
+ create(:ci_pipeline, config: { rspec: { script: 'rake' } })
+ end
+
+ it 'returns preseeded stage seeds object' do
+ expect(pipeline.stage_seeds).to all(be_a Gitlab::Ci::Stage::Seed)
+ expect(pipeline.stage_seeds.count).to eq 1
+ end
+ end
+
+ describe '#legacy_stages' do
+ subject { pipeline.legacy_stages }
context 'stages list' do
it 'returns ordered list of stages' do
@@ -274,7 +285,7 @@ describe Ci::Pipeline, models: true do
end
it 'populates stage with correct number of warnings' do
- deploy_stage = pipeline.stages.third
+ deploy_stage = pipeline.legacy_stages.third
expect(deploy_stage).not_to receive(:statuses)
expect(deploy_stage).to have_warnings
@@ -288,22 +299,22 @@ describe Ci::Pipeline, models: true do
end
end
- describe '#stages_name' do
+ describe '#stages_names' do
it 'returns a valid names of stages' do
- expect(pipeline.stages_name).to eq(%w(build test deploy))
+ expect(pipeline.stages_names).to eq(%w(build test deploy))
end
end
end
- describe '#stage' do
- subject { pipeline.stage('test') }
+ describe '#legacy_stage' do
+ subject { pipeline.legacy_stage('test') }
context 'with status in stage' do
before do
create(:commit_status, pipeline: pipeline, stage: 'test')
end
- it { expect(subject).to be_a Ci::Stage }
+ it { expect(subject).to be_a Ci::LegacyStage }
it { expect(subject.name).to eq 'test' }
it { expect(subject.statuses).not_to be_empty }
end
@@ -524,6 +535,20 @@ describe Ci::Pipeline, models: true do
end
end
+ describe '#has_stage_seeds?' do
+ context 'when pipeline has stage seeds' do
+ subject { build(:ci_pipeline_with_one_job) }
+
+ it { is_expected.to have_stage_seeds }
+ end
+
+ context 'when pipeline does not have stage seeds' do
+ subject { create(:ci_pipeline_without_jobs) }
+
+ it { is_expected.not_to have_stage_seeds }
+ end
+ end
+
describe '#has_warnings?' do
subject { pipeline.has_warnings? }
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index 72f83d63224..140fd2720bf 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -67,11 +67,11 @@ describe Commit, models: true do
expect(commit.title).to eq("--no commit message")
end
- it "truncates a message without a newline at 80 characters" do
+ it 'truncates a message without a newline at natural break to 80 characters' do
message = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.'
allow(commit).to receive(:safe_message).and_return(message)
- expect(commit.title).to eq("#{message[0..79]}…")
+ expect(commit.title).to eq('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis…')
end
it "truncates a message with a newline before 80 characters at the newline" do
@@ -113,6 +113,28 @@ eos
end
end
+ describe 'description' do
+ it 'returns description of commit message if title less than 100 characters' do
+ message = <<eos
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit.
+Vivamus egestas lacinia lacus, sed rutrum mauris.
+eos
+
+ allow(commit).to receive(:safe_message).and_return(message)
+ expect(commit.description).to eq('Vivamus egestas lacinia lacus, sed rutrum mauris.')
+ end
+
+ it 'returns full commit message if commit title more than 100 characters' do
+ message = <<eos
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.
+Vivamus egestas lacinia lacus, sed rutrum mauris.
+eos
+
+ allow(commit).to receive(:safe_message).and_return(message)
+ expect(commit.description).to eq(message)
+ end
+ end
+
describe "delegation" do
subject { commit }
diff --git a/spec/models/concerns/editable_spec.rb b/spec/models/concerns/editable_spec.rb
new file mode 100644
index 00000000000..cd73af3b480
--- /dev/null
+++ b/spec/models/concerns/editable_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe Editable do
+ describe '#is_edited?' do
+ let(:issue) { create(:issue, last_edited_at: nil) }
+ let(:edited_issue) { create(:issue, created_at: 3.days.ago, last_edited_at: 2.days.ago) }
+
+ it { expect(issue.is_edited?).to eq(false) }
+ it { expect(edited_issue.is_edited?).to eq(true) }
+ end
+end
diff --git a/spec/models/forked_project_link_spec.rb b/spec/models/forked_project_link_spec.rb
index 454550c9710..6e8d43f988c 100644
--- a/spec/models/forked_project_link_spec.rb
+++ b/spec/models/forked_project_link_spec.rb
@@ -2,8 +2,8 @@ require 'spec_helper'
describe ForkedProjectLink, "add link on fork" do
let(:project_from) { create(:project, :repository) }
- let(:namespace) { create(:namespace) }
- let(:user) { create(:user, namespace: namespace) }
+ let(:user) { create(:user) }
+ let(:namespace) { user.namespace }
before do
create(:project_member, :reporter, user: user, project: project_from)
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 316bf153660..3d437ca0fcc 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -179,7 +179,7 @@ describe Group, models: true do
let!(:group) { create(:group, :access_requestable, :with_avatar) }
let(:user) { create(:user) }
let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
- let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" }
+ let(:avatar_path) { "/uploads/system/group/avatar/#{group.id}/dk.png" }
context 'when avatar file is uploaded' do
before { group.add_master(user) }
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index 7c40cfd8253..f1e2a2cc518 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -66,14 +66,16 @@ describe Key, models: true do
end
it "does not accept the exact same key twice" do
- create(:key, user: user)
- expect(build(:key, user: user)).not_to be_valid
+ first_key = create(:key, user: user)
+
+ expect(build(:key, user: user, key: first_key.key)).not_to be_valid
end
it "does not accept a duplicate key with a different comment" do
- create(:key, user: user)
- duplicate = build(:key, user: user)
+ first_key = create(:key, user: user)
+ duplicate = build(:key, user: user, key: first_key.key)
duplicate.key << ' extra comment'
+
expect(duplicate).not_to be_valid
end
end
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 0a10ee01506..ed9fde57bf7 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -139,4 +139,15 @@ describe MergeRequestDiff, models: true do
expect(subject.commits_count).to eq 2
end
end
+
+ describe '#utf8_st_diffs' do
+ it 'does not raise error when a hash value is in binary' do
+ subject.st_diffs = [
+ { diff: "\0" },
+ { diff: "\x05\x00\x68\x65\x6c\x6c\x6f" }
+ ]
+
+ expect { subject.utf8_st_diffs }.not_to raise_error
+ end
+ end
end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 0e74f1ab1bd..145c7ad5770 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -43,6 +43,12 @@ describe Namespace, models: true do
end
end
+ context "is case insensitive" do
+ let(:group) { build(:group, path: "System") }
+
+ it { expect(group).not_to be_valid }
+ end
+
context 'top-level group' do
let(:group) { build(:group, path: 'tree') }
@@ -178,8 +184,8 @@ describe Namespace, models: true do
let(:parent) { create(:group, name: 'parent', path: 'parent') }
let(:child) { create(:group, name: 'child', path: 'child', parent: parent) }
let!(:project) { create(:project_empty_repo, path: 'the-project', namespace: child) }
- let(:uploads_dir) { File.join(CarrierWave.root, 'uploads') }
- let(:pages_dir) { TestEnv.pages_path }
+ let(:uploads_dir) { File.join(CarrierWave.root, FileUploader.base_dir) }
+ let(:pages_dir) { File.join(TestEnv.pages_path) }
before do
FileUtils.mkdir_p(File.join(uploads_dir, 'parent', 'child', 'the-project'))
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index c6c45d78990..f9d060d4e0e 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -6,7 +6,7 @@ describe PagesDomain, models: true do
end
describe 'validate domain' do
- subject { build(:pages_domain, domain: domain) }
+ subject(:pages_domain) { build(:pages_domain, domain: domain) }
context 'is unique' do
let(:domain) { 'my.domain.com' }
@@ -14,36 +14,25 @@ describe PagesDomain, models: true do
it { is_expected.to validate_uniqueness_of(:domain) }
end
- context 'valid domain' do
- let(:domain) { 'my.domain.com' }
-
- it { is_expected.to be_valid }
- end
-
- context 'valid hexadecimal-looking domain' do
- let(:domain) { '0x12345.com'}
-
- it { is_expected.to be_valid }
- end
-
- context 'no domain' do
- let(:domain) { nil }
-
- it { is_expected.not_to be_valid }
- end
-
- context 'invalid domain' do
- let(:domain) { '0123123' }
-
- it { is_expected.not_to be_valid }
- end
-
- context 'domain from .example.com' do
- let(:domain) { 'my.domain.com' }
-
- before { allow(Settings.pages).to receive(:host).and_return('domain.com') }
-
- it { is_expected.not_to be_valid }
+ {
+ 'my.domain.com' => true,
+ '123.456.789' => true,
+ '0x12345.com' => true,
+ '0123123' => true,
+ '_foo.com' => false,
+ 'reserved.com' => false,
+ 'a.reserved.com' => false,
+ nil => false
+ }.each do |value, validity|
+ context "domain #{value.inspect} validity" do
+ before do
+ allow(Settings.pages).to receive(:host).and_return('reserved.com')
+ end
+
+ let(:domain) { value }
+
+ it { expect(pages_domain.valid?).to eq(validity) }
+ end
end
end
diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb
index 823623d96fa..fa781195608 100644
--- a/spec/models/personal_access_token_spec.rb
+++ b/spec/models/personal_access_token_spec.rb
@@ -35,6 +35,16 @@ describe PersonalAccessToken, models: true do
end
end
+ describe 'revoke!' do
+ let(:active_personal_access_token) { create(:personal_access_token) }
+
+ it 'revokes the token' do
+ active_personal_access_token.revoke!
+
+ expect(active_personal_access_token.revoked?).to be true
+ end
+ end
+
context "validations" do
let(:personal_access_token) { build(:personal_access_token) }
@@ -51,11 +61,17 @@ describe PersonalAccessToken, models: true do
expect(personal_access_token).to be_valid
end
- it "rejects creating a token with non-API scopes" do
+ it "allows creating a token with read_registry scope" do
+ personal_access_token.scopes = [:read_registry]
+
+ expect(personal_access_token).to be_valid
+ end
+
+ it "rejects creating a token with unavailable scopes" do
personal_access_token.scopes = [:openid, :api]
expect(personal_access_token).not_to be_valid
- expect(personal_access_token.errors[:scopes].first).to eq "can only contain API scopes"
+ expect(personal_access_token.errors[:scopes].first).to eq "can only contain available scopes"
end
end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 86ab2550bfb..454eeb58ecd 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -50,7 +50,7 @@ describe Project, models: true do
it { is_expected.to have_one(:external_wiki_service).dependent(:destroy) }
it { is_expected.to have_one(:project_feature).dependent(:destroy) }
it { is_expected.to have_one(:statistics).class_name('ProjectStatistics').dependent(:delete) }
- it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:destroy) }
+ it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:delete) }
it { is_expected.to have_one(:last_event).class_name('Event') }
it { is_expected.to have_one(:forked_from_project).through(:forked_project_link) }
it { is_expected.to have_many(:commit_statuses) }
@@ -812,7 +812,7 @@ describe Project, models: true do
context 'when avatar file is uploaded' do
let(:project) { create(:empty_project, :with_avatar) }
- let(:avatar_path) { "/uploads/project/avatar/#{project.id}/dk.png" }
+ let(:avatar_path) { "/uploads/system/project/avatar/#{project.id}/dk.png" }
let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
it 'shows correct url' do
@@ -1446,16 +1446,13 @@ describe Project, models: true do
end
describe 'Project import job' do
- let(:project) { create(:empty_project) }
- let(:mirror) { false }
+ let(:project) { create(:empty_project, import_url: generate(:url)) }
before do
allow_any_instance_of(Gitlab::Shell).to receive(:import_repository)
.with(project.repository_storage_path, project.path_with_namespace, project.import_url)
.and_return(true)
- allow(project).to receive(:repository_exists?).and_return(true)
-
expect_any_instance_of(Repository).to receive(:after_import)
.and_call_original
end
@@ -1463,8 +1460,7 @@ describe Project, models: true do
it 'imports a project' do
expect_any_instance_of(RepositoryImportWorker).to receive(:perform).and_call_original
- project.import_start
- project.add_import_job
+ project.import_schedule
expect(project.reload.import_status).to eq('finished')
end
@@ -1551,7 +1547,7 @@ describe Project, models: true do
describe '#add_import_job' do
context 'forked' do
- let(:forked_project_link) { create(:forked_project_link) }
+ let(:forked_project_link) { create(:forked_project_link, :forked_to_empty_project) }
let(:forked_from_project) { forked_project_link.forked_from_project }
let(:project) { forked_project_link.forked_to_project }
@@ -1565,9 +1561,9 @@ describe Project, models: true do
end
context 'not forked' do
- let(:project) { create(:empty_project) }
-
it 'schedules a RepositoryImportWorker job' do
+ project = create(:empty_project, import_url: generate(:url))
+
expect(RepositoryImportWorker).to receive(:perform_async).with(project.id)
project.add_import_job
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 718b7d5e86b..a6d4d92c450 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -1905,19 +1905,43 @@ describe Repository, models: true do
end
describe '#is_ancestor?' do
- context 'Gitaly is_ancestor feature enabled' do
- let(:commit) { repository.commit }
- let(:ancestor) { commit.parents.first }
+ let(:commit) { repository.commit }
+ let(:ancestor) { commit.parents.first }
+ context 'with Gitaly enabled' do
+ it 'it is an ancestor' do
+ expect(repository.is_ancestor?(ancestor.id, commit.id)).to eq(true)
+ end
+
+ it 'it is not an ancestor' do
+ expect(repository.is_ancestor?(commit.id, ancestor.id)).to eq(false)
+ end
+
+ it 'returns false on nil-values' do
+ expect(repository.is_ancestor?(nil, commit.id)).to eq(false)
+ expect(repository.is_ancestor?(ancestor.id, nil)).to eq(false)
+ expect(repository.is_ancestor?(nil, nil)).to eq(false)
+ end
+ end
+
+ context 'with Gitaly disabled' do
before do
- allow(Gitlab::GitalyClient).to receive(:enabled?).and_return(true)
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(true)
+ allow(Gitlab::GitalyClient).to receive(:enabled?).and_return(false)
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(false)
end
- it "asks Gitaly server if it's an ancestor" do
- expect_any_instance_of(Gitlab::GitalyClient::Commit).to receive(:is_ancestor).with(ancestor.id, commit.id)
+ it 'it is an ancestor' do
+ expect(repository.is_ancestor?(ancestor.id, commit.id)).to eq(true)
+ end
+
+ it 'it is not an ancestor' do
+ expect(repository.is_ancestor?(commit.id, ancestor.id)).to eq(false)
+ end
- repository.is_ancestor?(ancestor.id, commit.id)
+ it 'returns false on nil-values' do
+ expect(repository.is_ancestor?(nil, commit.id)).to eq(false)
+ expect(repository.is_ancestor?(ancestor.id, nil)).to eq(false)
+ expect(repository.is_ancestor?(nil, nil)).to eq(false)
end
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 1c3541da44f..d5bd9946ab6 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -13,6 +13,10 @@ describe User, models: true do
it { is_expected.to include_module(TokenAuthenticatable) }
end
+ describe 'delegations' do
+ it { is_expected.to delegate_method(:path).to(:namespace).with_prefix }
+ end
+
describe 'associations' do
it { is_expected.to have_one(:namespace) }
it { is_expected.to have_many(:snippets).dependent(:destroy) }
@@ -983,7 +987,7 @@ describe User, models: true do
context 'when avatar file is uploaded' do
let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
- let(:avatar_path) { "/uploads/user/avatar/#{user.id}/dk.png" }
+ let(:avatar_path) { "/uploads/system/user/avatar/#{user.id}/dk.png" }
it 'shows correct avatar url' do
expect(user.avatar_url).to eq(avatar_path)
diff --git a/spec/policies/deploy_key_policy_spec.rb b/spec/policies/deploy_key_policy_spec.rb
new file mode 100644
index 00000000000..28e10f0bfe2
--- /dev/null
+++ b/spec/policies/deploy_key_policy_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe DeployKeyPolicy, models: true do
+ subject { described_class.abilities(current_user, deploy_key).to_set }
+
+ describe 'updating a deploy_key' do
+ context 'when a regular user' do
+ let(:current_user) { create(:user) }
+
+ context 'tries to update private deploy key attached to project' do
+ let(:deploy_key) { create(:deploy_key, public: false) }
+ let(:project) { create(:project_empty_repo) }
+
+ before do
+ project.add_master(current_user)
+ project.deploy_keys << deploy_key
+ end
+
+ it { is_expected.to include(:update_deploy_key) }
+ end
+
+ context 'tries to update private deploy key attached to other project' do
+ let(:deploy_key) { create(:deploy_key, public: false) }
+ let(:other_project) { create(:project_empty_repo) }
+
+ before do
+ other_project.deploy_keys << deploy_key
+ end
+
+ it { is_expected.not_to include(:update_deploy_key) }
+ end
+
+ context 'tries to update public deploy key' do
+ let(:deploy_key) { create(:another_deploy_key, public: true) }
+
+ it { is_expected.not_to include(:update_deploy_key) }
+ end
+ end
+
+ context 'when an admin user' do
+ let(:current_user) { create(:user, :admin) }
+
+ context ' tries to update private deploy key' do
+ let(:deploy_key) { create(:deploy_key, public: false) }
+
+ it { is_expected.to include(:update_deploy_key) }
+ end
+
+ context 'when an admin user tries to update public deploy key' do
+ let(:deploy_key) { create(:another_deploy_key, public: true) }
+
+ it { is_expected.to include(:update_deploy_key) }
+ end
+ end
+ end
+end
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 4c37a553227..a8331ceb5ff 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -9,11 +9,12 @@ describe GroupPolicy, models: true do
let(:admin) { create(:admin) }
let(:group) { create(:group) }
+ let(:reporter_permissions) { [:admin_label] }
+
let(:master_permissions) do
[
:create_projects,
- :admin_milestones,
- :admin_label
+ :admin_milestones
]
end
@@ -42,6 +43,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -52,6 +54,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -62,6 +65,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -72,6 +76,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -82,6 +87,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -92,6 +98,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.to include(*owner_permissions)
end
@@ -102,14 +109,27 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.to include(*owner_permissions)
end
end
- describe 'private nested group inherit permissions', :nested_groups do
+ describe 'private nested group use the highest access level from the group and inherited permissions', :nested_groups do
let(:nested_group) { create(:group, :private, parent: group) }
+ before do
+ nested_group.add_guest(guest)
+ nested_group.add_guest(reporter)
+ nested_group.add_guest(developer)
+ nested_group.add_guest(master)
+
+ group.owners.destroy_all
+
+ group.add_guest(owner)
+ nested_group.add_owner(owner)
+ end
+
subject { described_class.abilities(current_user, nested_group).to_set }
context 'with no user' do
@@ -117,6 +137,7 @@ describe GroupPolicy, models: true do
it do
is_expected.not_to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -127,6 +148,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -137,6 +159,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -147,6 +170,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -157,6 +181,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -167,6 +192,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.to include(*owner_permissions)
end
diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb
index e1771b636b8..ddbed5f781e 100644
--- a/spec/policies/project_snippet_policy_spec.rb
+++ b/spec/policies/project_snippet_policy_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe ProjectSnippetPolicy, models: true do
let(:regular_user) { create(:user) }
let(:external_user) { create(:user, :external) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:empty_project, :public) }
let(:author_permissions) do
[
@@ -107,7 +107,7 @@ describe ProjectSnippetPolicy, models: true do
end
context 'snippet author' do
- let(:snippet) { create(:project_snippet, :private, author: regular_user) }
+ let(:snippet) { create(:project_snippet, :private, author: regular_user, project: project) }
subject { described_class.abilities(regular_user, snippet).to_set }
diff --git a/spec/presenters/conversational_development_index/metric_presenter_spec.rb b/spec/presenters/conversational_development_index/metric_presenter_spec.rb
new file mode 100644
index 00000000000..1e015c71f5b
--- /dev/null
+++ b/spec/presenters/conversational_development_index/metric_presenter_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe ConversationalDevelopmentIndex::MetricPresenter do
+ subject { described_class.new(metric) }
+ let(:metric) { build(:conversational_development_index_metric) }
+
+ describe '#cards' do
+ it 'includes instance score, leader score and percentage score' do
+ issues_card = subject.cards.first
+
+ expect(issues_card.instance_score).to eq 1.234
+ expect(issues_card.leader_score).to eq 9.256
+ expect(issues_card.percentage_score).to be_within(0.1).of(13.3)
+ end
+ end
+
+ describe '#idea_to_production_steps' do
+ it 'returns percentage score when it depends on a single feature' do
+ code_step = subject.idea_to_production_steps.fourth
+
+ expect(code_step.percentage_score).to be_within(0.1).of(50.0)
+ end
+
+ it 'returns percentage score when it depends on two features' do
+ issue_step = subject.idea_to_production_steps.second
+
+ expect(issue_step.percentage_score).to be_within(0.1).of(53.0)
+ end
+ end
+
+ describe '#average_percentage_score' do
+ it 'calculates an average value across all the features' do
+ expect(subject.average_percentage_score).to be_within(0.1).of(55.8)
+ end
+ end
+end
diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb
index 44720fc4448..f5a14b1d04d 100644
--- a/spec/presenters/merge_request_presenter_spec.rb
+++ b/spec/presenters/merge_request_presenter_spec.rb
@@ -132,6 +132,11 @@ describe MergeRequestPresenter do
it 'does not present related issues links' do
is_expected.not_to match("#{project.full_path}/issues/#{issue_b.iid}")
end
+
+ it 'appends status when closing issue is already closed' do
+ issue_a.close
+ is_expected.to match('(closed)')
+ end
end
describe '#mentioned_issues_links' do
@@ -147,6 +152,11 @@ describe MergeRequestPresenter do
it 'does not present closing issues links' do
is_expected.not_to match("#{project.full_path}/issues/#{issue_a.iid}")
end
+
+ it 'appends status when mentioned issue is already closed' do
+ issue_b.close
+ is_expected.to match('(closed)')
+ end
end
describe '#assign_to_closing_issues_link' do
diff --git a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb
index 6443f86b6a1..5c39e1b5f96 100644
--- a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb
+++ b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb
@@ -51,10 +51,6 @@ describe Projects::Settings::DeployKeysPresenter do
expect(presenter.available_project_keys).not_to be_empty
end
- it 'returns false if any available_project_keys are enabled' do
- expect(presenter.any_available_project_keys_enabled?).to eq(true)
- end
-
it 'returns the available_project_keys size' do
expect(presenter.available_project_keys_size).to eq(1)
end
diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb
index 843e9862b0c..4d9cd5f3a27 100644
--- a/spec/requests/api/deploy_keys_spec.rb
+++ b/spec/requests/api/deploy_keys_spec.rb
@@ -13,7 +13,7 @@ describe API::DeployKeys do
describe 'GET /deploy_keys' do
context 'when unauthenticated' do
- it 'should return authentication error' do
+ it 'returns authentication error' do
get api('/deploy_keys')
expect(response.status).to eq(401)
@@ -21,7 +21,7 @@ describe API::DeployKeys do
end
context 'when authenticated as non-admin user' do
- it 'should return a 403 error' do
+ it 'returns a 403 error' do
get api('/deploy_keys', user)
expect(response.status).to eq(403)
@@ -29,7 +29,7 @@ describe API::DeployKeys do
end
context 'when authenticated as admin' do
- it 'should return all deploy keys' do
+ it 'returns all deploy keys' do
get api('/deploy_keys', admin)
expect(response.status).to eq(200)
@@ -43,7 +43,7 @@ describe API::DeployKeys do
describe 'GET /projects/:id/deploy_keys' do
before { deploy_key }
- it 'should return array of ssh keys' do
+ it 'returns array of ssh keys' do
get api("/projects/#{project.id}/deploy_keys", admin)
expect(response).to have_http_status(200)
@@ -54,14 +54,14 @@ describe API::DeployKeys do
end
describe 'GET /projects/:id/deploy_keys/:key_id' do
- it 'should return a single key' do
+ it 'returns a single key' do
get api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
expect(response).to have_http_status(200)
expect(json_response['title']).to eq(deploy_key.title)
end
- it 'should return 404 Not Found with invalid ID' do
+ it 'returns 404 Not Found with invalid ID' do
get api("/projects/#{project.id}/deploy_keys/404", admin)
expect(response).to have_http_status(404)
@@ -69,26 +69,26 @@ describe API::DeployKeys do
end
describe 'POST /projects/:id/deploy_keys' do
- it 'should not create an invalid ssh key' do
+ it 'does not create an invalid ssh key' do
post api("/projects/#{project.id}/deploy_keys", admin), { title: 'invalid key' }
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('key is missing')
end
- it 'should not create a key without title' do
+ it 'does not create a key without title' do
post api("/projects/#{project.id}/deploy_keys", admin), key: 'some key'
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('title is missing')
end
- it 'should create new ssh key' do
+ it 'creates new ssh key' do
key_attrs = attributes_for :another_key
expect do
post api("/projects/#{project.id}/deploy_keys", admin), key_attrs
- end.to change{ project.deploy_keys.count }.by(1)
+ end.to change { project.deploy_keys.count }.by(1)
end
it 'returns an existing ssh key when attempting to add a duplicate' do
@@ -117,10 +117,53 @@ describe API::DeployKeys do
end
end
+ describe 'PUT /projects/:id/deploy_keys/:key_id' do
+ let(:private_deploy_key) { create(:another_deploy_key, public: false) }
+ let(:project_private_deploy_key) do
+ create(:deploy_keys_project, project: project, deploy_key: private_deploy_key)
+ end
+
+ it 'updates a public deploy key as admin' do
+ expect do
+ put api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin), { title: 'new title' }
+ end.not_to change(deploy_key, :title)
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'does not update a public deploy key as non admin' do
+ expect do
+ put api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", user), { title: 'new title' }
+ end.not_to change(deploy_key, :title)
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'does not update a private key with invalid title' do
+ project_private_deploy_key
+
+ expect do
+ put api("/projects/#{project.id}/deploy_keys/#{private_deploy_key.id}", admin), { title: '' }
+ end.not_to change(deploy_key, :title)
+
+ expect(response).to have_http_status(400)
+ end
+
+ it 'updates a private ssh key with correct attributes' do
+ project_private_deploy_key
+
+ put api("/projects/#{project.id}/deploy_keys/#{private_deploy_key.id}", admin), { title: 'new title', can_push: true }
+
+ expect(json_response['id']).to eq(private_deploy_key.id)
+ expect(json_response['title']).to eq('new title')
+ expect(json_response['can_push']).to eq(true)
+ end
+ end
+
describe 'DELETE /projects/:id/deploy_keys/:key_id' do
before { deploy_key }
- it 'should delete existing key' do
+ it 'deletes existing key' do
expect do
delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
@@ -128,7 +171,7 @@ describe API::DeployKeys do
end.to change{ project.deploy_keys.count }.by(-1)
end
- it 'should return 404 Not Found with invalid ID' do
+ it 'returns 404 Not Found with invalid ID' do
delete api("/projects/#{project.id}/deploy_keys/404", admin)
expect(response).to have_http_status(404)
@@ -150,7 +193,7 @@ describe API::DeployKeys do
end
context 'when authenticated as non-admin user' do
- it 'should return a 404 error' do
+ it 'returns a 404 error' do
post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", user)
expect(response).to have_http_status(404)
diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb
new file mode 100644
index 00000000000..a19870a95e8
--- /dev/null
+++ b/spec/requests/api/events_spec.rb
@@ -0,0 +1,142 @@
+require 'spec_helper'
+
+describe API::Events, api: true do
+ include ApiHelpers
+ let(:user) { create(:user) }
+ let(:non_member) { create(:user) }
+ let(:other_user) { create(:user, username: 'otheruser') }
+ let(:private_project) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) }
+ let(:closed_issue) { create(:closed_issue, project: private_project, author: user) }
+ let!(:closed_issue_event) { create(:event, project: private_project, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) }
+
+ describe 'GET /events' do
+ context 'when unauthenticated' do
+ it 'returns authentication error' do
+ get api('/events')
+
+ expect(response).to have_http_status(401)
+ end
+ end
+
+ context 'when authenticated' do
+ it 'returns users events' do
+ get api('/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31', user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+ end
+ end
+
+ describe 'GET /users/:id/events' do
+ context "as a user that cannot see the event's project" do
+ it 'returns no events' do
+ get api("/users/#{user.id}/events", other_user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_empty
+ end
+ end
+
+ context "as a user that can see the event's project" do
+ it 'accepts a username' do
+ get api("/users/#{user.username}/events", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+
+ it 'returns the events' do
+ get api("/users/#{user.id}/events", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+
+ context 'when there are multiple events from different projects' do
+ let(:second_note) { create(:note_on_issue, project: create(:empty_project)) }
+
+ before do
+ second_note.project.add_user(user, :developer)
+
+ [second_note].each do |note|
+ EventCreateService.new.leave_note(note, user)
+ end
+ end
+
+ it 'returns events in the correct order (from newest to oldest)' do
+ get api("/users/#{user.id}/events", user)
+
+ comment_events = json_response.select { |e| e['action_name'] == 'commented on' }
+ close_events = json_response.select { |e| e['action_name'] == 'closed' }
+
+ expect(comment_events[0]['target_id']).to eq(second_note.id)
+ expect(close_events[0]['target_id']).to eq(closed_issue.id)
+ end
+
+ it 'accepts filter parameters' do
+ get api("/users/#{user.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user)
+
+ expect(json_response.size).to eq(1)
+ expect(json_response[0]['target_id']).to eq(closed_issue.id)
+ end
+ end
+ end
+
+ it 'returns a 404 error if not found' do
+ get api('/users/42/events', user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ describe 'GET /projects/:id/events' do
+ context 'when unauthenticated ' do
+ it 'returns 404 for private project' do
+ get api("/projects/#{private_project.id}/events")
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'returns 200 status for a public project' do
+ public_project = create(:empty_project, :public)
+
+ get api("/projects/#{public_project.id}/events")
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when not permitted to read' do
+ it 'returns 404' do
+ get api("/projects/#{private_project.id}/events", non_member)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when authenticated' do
+ it 'returns project events' do
+ get api("/projects/#{private_project.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+
+ it 'returns 404 if project does not exist' do
+ get api("/projects/1234/events", user)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index deb2cac6869..d325c6eff9d 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -258,6 +258,25 @@ describe API::Files do
expect(last_commit.author_name).to eq(user.name)
end
+ it "returns a 400 bad request if update existing file with stale last commit id" do
+ params_with_stale_id = valid_params.merge(last_commit_id: 'stale')
+
+ put api(route(file_path), user), params_with_stale_id
+
+ expect(response).to have_http_status(400)
+ expect(json_response['message']).to eq('You are attempting to update a file that has changed since you started editing it.')
+ end
+
+ it "updates existing file in project repo with accepts correct last commit id" do
+ last_commit = Gitlab::Git::Commit
+ .last_for_path(project.repository, 'master', URI.unescape(file_path))
+ params_with_correct_id = valid_params.merge(last_commit_id: last_commit.id)
+
+ put api(route(file_path), user), params_with_correct_id
+
+ expect(response).to have_http_status(200)
+ end
+
it "returns a 400 bad request if no params given" do
put api(route(file_path), user)
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index 3ab1764f5c3..4d4631322b1 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -36,11 +36,34 @@ describe API::ProjectSnippets do
end
end
+ describe 'GET /projects/:project_id/snippets/:id' do
+ let(:user) { create(:user) }
+ let(:snippet) { create(:project_snippet, :public, project: project) }
+
+ it 'returns snippet json' do
+ get api("/projects/#{project.id}/snippets/#{snippet.id}", user)
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['title']).to eq(snippet.title)
+ expect(json_response['description']).to eq(snippet.description)
+ expect(json_response['file_name']).to eq(snippet.file_name)
+ end
+
+ it 'returns 404 for invalid snippet id' do
+ get api("/projects/#{project.id}/snippets/1234", user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Not found')
+ end
+ end
+
describe 'POST /projects/:project_id/snippets/' do
let(:params) do
{
title: 'Test Title',
file_name: 'test.rb',
+ description: 'test description',
code: 'puts "hello world"',
visibility: 'public'
}
@@ -52,6 +75,7 @@ describe API::ProjectSnippets do
expect(response).to have_http_status(201)
snippet = ProjectSnippet.find(json_response['id'])
expect(snippet.content).to eq(params[:code])
+ expect(snippet.description).to eq(params[:description])
expect(snippet.title).to eq(params[:title])
expect(snippet.file_name).to eq(params[:file_name])
expect(snippet.visibility_level).to eq(Snippet::PUBLIC)
@@ -106,12 +130,14 @@ describe API::ProjectSnippets do
it 'updates snippet' do
new_content = 'New content'
+ new_description = 'New description'
- put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content
+ put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content, description: new_description
expect(response).to have_http_status(200)
snippet.reload
expect(snippet.content).to eq(new_content)
+ expect(snippet.description).to eq(new_description)
end
it 'returns 404 for invalid snippet id' do
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 40bfc0c636b..86c57204971 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -635,6 +635,8 @@ describe API::Projects do
expect(json_response['shared_runners_enabled']).to be_present
expect(json_response['creator_id']).to be_present
expect(json_response['namespace']).to be_present
+ expect(json_response['import_status']).to be_present
+ expect(json_response).to include("import_error")
expect(json_response['avatar_url']).to be_nil
expect(json_response['star_count']).to be_present
expect(json_response['forks_count']).to be_present
@@ -702,6 +704,20 @@ describe API::Projects do
expect(json_response).to include 'statistics'
end
+ it "includes import_error if user can admin project" do
+ get api("/projects/#{project.id}", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to include("import_error")
+ end
+
+ it "does not include import_error if user cannot admin project" do
+ get api("/projects/#{project.id}", user3)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).not_to include("import_error")
+ end
+
describe 'permissions' do
context 'all projects' do
before { project.team << [user, :master] }
@@ -746,64 +762,6 @@ describe API::Projects do
end
end
- describe 'GET /projects/:id/events' do
- shared_examples_for 'project events response' do
- it 'returns the project events' do
- member = create(:user)
- create(:project_member, :developer, user: member, project: project)
- note = create(:note_on_issue, note: 'What an awesome day!', project: project)
- EventCreateService.new.leave_note(note, note.author)
-
- get api("/projects/#{project.id}/events", current_user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
-
- first_event = json_response.first
- expect(first_event['action_name']).to eq('commented on')
- expect(first_event['note']['body']).to eq('What an awesome day!')
-
- last_event = json_response.last
-
- expect(last_event['action_name']).to eq('joined')
- expect(last_event['project_id'].to_i).to eq(project.id)
- expect(last_event['author_username']).to eq(member.username)
- expect(last_event['author']['name']).to eq(member.name)
- end
- end
-
- context 'when unauthenticated' do
- it_behaves_like 'project events response' do
- let(:project) { create(:empty_project, :public) }
- let(:current_user) { nil }
- end
- end
-
- context 'when authenticated' do
- context 'valid request' do
- it_behaves_like 'project events response' do
- let(:current_user) { user }
- end
- end
-
- it 'returns a 404 error if not found' do
- get api('/projects/42/events', user)
-
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Project Not Found')
- end
-
- it 'returns a 404 error if user is not a member' do
- other_user = create(:user)
-
- get api("/projects/#{project.id}/events", other_user)
-
- expect(response).to have_http_status(404)
- end
- end
- end
-
describe 'GET /projects/:id/users' do
shared_examples_for 'project users response' do
it 'returns the project users' do
@@ -1464,6 +1422,8 @@ describe API::Projects do
expect(json_response['owner']['id']).to eq(user2.id)
expect(json_response['namespace']['id']).to eq(user2.namespace.id)
expect(json_response['forked_from_project']['id']).to eq(project.id)
+ expect(json_response['import_status']).to eq('scheduled')
+ expect(json_response).to include("import_error")
end
it 'forks if user is admin' do
@@ -1475,6 +1435,8 @@ describe API::Projects do
expect(json_response['owner']['id']).to eq(admin.id)
expect(json_response['namespace']['id']).to eq(admin.namespace.id)
expect(json_response['forked_from_project']['id']).to eq(project.id)
+ expect(json_response['import_status']).to eq('scheduled')
+ expect(json_response).to include("import_error")
end
it 'fails on missing project access for the project to fork' do
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index e429cddcf6a..8741cbd4e80 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -80,11 +80,33 @@ describe API::Snippets do
end
end
+ describe 'GET /snippets/:id' do
+ let(:snippet) { create(:personal_snippet, author: user) }
+
+ it 'returns snippet json' do
+ get api("/snippets/#{snippet.id}", user)
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['title']).to eq(snippet.title)
+ expect(json_response['description']).to eq(snippet.description)
+ expect(json_response['file_name']).to eq(snippet.file_name)
+ end
+
+ it 'returns 404 for invalid snippet id' do
+ get api("/snippets/1234", user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Not found')
+ end
+ end
+
describe 'POST /snippets/' do
let(:params) do
{
title: 'Test Title',
file_name: 'test.rb',
+ description: 'test description',
content: 'puts "hello world"',
visibility: 'public'
}
@@ -97,6 +119,7 @@ describe API::Snippets do
expect(response).to have_http_status(201)
expect(json_response['title']).to eq(params[:title])
+ expect(json_response['description']).to eq(params[:description])
expect(json_response['file_name']).to eq(params[:file_name])
end
@@ -150,12 +173,14 @@ describe API::Snippets do
it 'updates snippet' do
new_content = 'New content'
+ new_description = 'New description'
- put api("/snippets/#{snippet.id}", user), content: new_content
+ put api("/snippets/#{snippet.id}", user), content: new_content, description: new_description
expect(response).to have_http_status(200)
snippet.reload
expect(snippet.content).to eq(new_content)
+ expect(snippet.description).to eq(new_description)
end
it 'returns 404 for invalid snippet id' do
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index a2503dbeb69..ec51b96c86b 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -426,9 +426,14 @@ describe API::Users do
expect(user.reload.email).not_to eq('invalid email')
end
- it "is not available for non admin users" do
- put api("/users/#{user.id}", user), attributes_for(:user)
- expect(response).to have_http_status(403)
+ context 'when the current user is not an admin' do
+ it "is not available" do
+ expect do
+ put api("/users/#{user.id}", user), attributes_for(:user)
+ end.not_to change { user.reload.attributes }
+
+ expect(response).to have_http_status(403)
+ end
end
it "returns 404 for non-existing user" do
@@ -649,7 +654,7 @@ describe API::Users do
end
it "returns a 404 for invalid ID" do
- put api("/users/ASDF/emails", admin)
+ get api("/users/ASDF/emails", admin)
expect(response).to have_http_status(404)
end
@@ -702,6 +707,7 @@ describe API::Users do
describe "DELETE /users/:id" do
let!(:namespace) { user.namespace }
+ let!(:issue) { create(:issue, author: user) }
before { admin }
it "deletes user" do
@@ -733,6 +739,25 @@ describe API::Users do
expect(response).to have_http_status(404)
end
+
+ context "hard delete disabled" do
+ it "moves contributions to the ghost user" do
+ Sidekiq::Testing.inline! { delete api("/users/#{user.id}", admin) }
+
+ expect(response).to have_http_status(204)
+ expect(issue.reload).to be_persisted
+ expect(issue.author.ghost?).to be_truthy
+ end
+ end
+
+ context "hard delete enabled" do
+ it "removes contributions" do
+ Sidekiq::Testing.inline! { delete api("/users/#{user.id}?hard_delete=true", admin) }
+
+ expect(response).to have_http_status(204)
+ expect(Issue.exists?(issue.id)).to be_falsy
+ end
+ end
end
describe "GET /user" do
@@ -1110,83 +1135,6 @@ describe API::Users do
end
end
- describe 'GET /users/:id/events' do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
- let(:note) { create(:note_on_issue, note: 'What an awesome day!', project: project) }
-
- before do
- project.add_user(user, :developer)
- EventCreateService.new.leave_note(note, user)
- end
-
- context "as a user than cannot see the event's project" do
- it 'returns no events' do
- other_user = create(:user)
-
- get api("/users/#{user.id}/events", other_user)
-
- expect(response).to have_http_status(200)
- expect(json_response).to be_empty
- end
- end
-
- context "as a user than can see the event's project" do
- context 'joined event' do
- it 'returns the "joined" event' do
- get api("/users/#{user.id}/events", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
-
- comment_event = json_response.find { |e| e['action_name'] == 'commented on' }
-
- expect(comment_event['project_id'].to_i).to eq(project.id)
- expect(comment_event['author_username']).to eq(user.username)
- expect(comment_event['note']['id']).to eq(note.id)
- expect(comment_event['note']['body']).to eq('What an awesome day!')
-
- joined_event = json_response.find { |e| e['action_name'] == 'joined' }
-
- expect(joined_event['project_id'].to_i).to eq(project.id)
- expect(joined_event['author_username']).to eq(user.username)
- expect(joined_event['author']['name']).to eq(user.name)
- end
- end
-
- context 'when there are multiple events from different projects' do
- let(:second_note) { create(:note_on_issue, project: create(:empty_project)) }
- let(:third_note) { create(:note_on_issue, project: project) }
-
- before do
- second_note.project.add_user(user, :developer)
-
- [second_note, third_note].each do |note|
- EventCreateService.new.leave_note(note, user)
- end
- end
-
- it 'returns events in the correct order (from newest to oldest)' do
- get api("/users/#{user.id}/events", user)
-
- comment_events = json_response.select { |e| e['action_name'] == 'commented on' }
-
- expect(comment_events[0]['target_id']).to eq(third_note.id)
- expect(comment_events[1]['target_id']).to eq(second_note.id)
- expect(comment_events[2]['target_id']).to eq(note.id)
- end
- end
- end
-
- it 'returns a 404 error if not found' do
- get api('/users/42/events', user)
-
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 User Not Found')
- end
- end
-
context "user activities", :redis do
let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) }
let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) }
diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb
index bc591b2eb37..47cca4275af 100644
--- a/spec/requests/api/v3/projects_spec.rb
+++ b/spec/requests/api/v3/projects_spec.rb
@@ -165,7 +165,7 @@ describe API::V3::Projects do
expect(json_response).to satisfy do |response|
response.one? do |entry|
- entry.has_key?('permissions') &&
+ entry.key?('permissions') &&
entry['name'] == project.name &&
entry['owner']['username'] == user.username
end
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index 6ca3ef18fe6..6a83024d0d5 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -5,76 +5,217 @@ describe 'Git HTTP requests', lib: true do
include WorkhorseHelpers
include UserActivitiesHelpers
- it "gives WWW-Authenticate hints" do
- clone_get('doesnt/exist.git')
+ shared_examples 'pulls require Basic HTTP Authentication' do
+ context "when no credentials are provided" do
+ it "responds to downloads with status 401 Unauthorized (no project existence information leak)" do
+ download(path) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- expect(response.header['WWW-Authenticate']).to start_with('Basic ')
- end
+ context "when only username is provided" do
+ it "responds to downloads with status 401 Unauthorized" do
+ download(path, user: user.username) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- describe "User with no identities" do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository, path: 'project.git-project') }
+ context "when username and password are provided" do
+ context "when authentication fails" do
+ it "responds to downloads with status 401 Unauthorized" do
+ download(path, user: user.username, password: "wrong-password") do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- context "when the project doesn't exist" do
- context "when no authentication is provided" do
- it "responds with status 401 (no project existence information leak)" do
- download('doesnt/exist.git') do |response|
- expect(response).to have_http_status(401)
+ context "when authentication succeeds" do
+ it "does not respond to downloads with status 401 Unauthorized" do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).not_to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to be_nil
end
end
end
+ end
+ end
- context "when username and password are provided" do
- context "when authentication fails" do
- it "responds with status 401" do
- download('doesnt/exist.git', user: user.username, password: "nope") do |response|
- expect(response).to have_http_status(401)
- end
+ shared_examples 'pushes require Basic HTTP Authentication' do
+ context "when no credentials are provided" do
+ it "responds to uploads with status 401 Unauthorized (no project existence information leak)" do
+ upload(path) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
+
+ context "when only username is provided" do
+ it "responds to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
+
+ context "when username and password are provided" do
+ context "when authentication fails" do
+ it "responds to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username, password: "wrong-password") do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
end
end
+ end
- context "when authentication succeeds" do
- it "responds with status 404" do
- download('/doesnt/exist.git', user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
- end
+ context "when authentication succeeds" do
+ it "does not respond to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).not_to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to be_nil
end
end
end
end
+ end
+
+ shared_examples_for 'pulls are allowed' do
+ it do
+ download(path, env) do |response|
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+ end
+ end
+
+ shared_examples_for 'pushes are allowed' do
+ it do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+ end
+ end
+
+ describe "User with no identities" do
+ let(:user) { create(:user) }
- context "when the Wiki for a project exists" do
- it "responds with the right project" do
- wiki = ProjectWiki.new(project)
- project.update_attribute(:visibility_level, Project::PUBLIC)
+ context "when the project doesn't exist" do
+ let(:path) { 'doesnt/exist.git' }
- download("/#{wiki.repository.path_with_namespace}.git") do |response|
- json_body = ActiveSupport::JSON.decode(response.body)
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
- expect(response).to have_http_status(200)
- expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ context 'when authenticated' do
+ it 'rejects downloads and uploads with 404 Not Found' do
+ download_or_upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ end
end
end
+ end
+
+ context "when requesting the Wiki" do
+ let(:wiki) { ProjectWiki.new(project) }
+ let(:path) { "/#{wiki.repository.path_with_namespace}.git" }
+
+ context "when the project is public" do
+ let(:project) { create(:project, :repository, :public, :wiki_enabled) }
+
+ it_behaves_like 'pushes require Basic HTTP Authentication'
+
+ context 'when unauthenticated' do
+ let(:env) { {} }
- context 'but the repo is disabled' do
- let(:project) { create(:project, :repository_disabled, :wiki_enabled) }
- let(:wiki) { ProjectWiki.new(project) }
- let(:path) { "/#{wiki.repository.path_with_namespace}.git" }
+ it_behaves_like 'pulls are allowed'
- before do
- project.team << [user, :developer]
+ it "responds to pulls with the wiki's repo" do
+ download(path) do |response|
+ json_body = ActiveSupport::JSON.decode(response.body)
+
+ expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace)
+ end
+ end
end
- it 'allows clones' do
- download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ context 'when authenticated' do
+ let(:env) { { user: user.username, password: user.password } }
+
+ context 'and as a developer on the team' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'but the repo is disabled' do
+ let(:project) { create(:project, :repository, :public, :repository_disabled, :wiki_enabled) }
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
+ end
+ end
+
+ context 'and not on the team' do
+ it_behaves_like 'pulls are allowed'
+
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_wiki_error(:write_to_wiki))
+ end
+ end
end
end
+ end
- it 'allows pushes' do
- upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ context "when the project is private" do
+ let(:project) { create(:project, :repository, :private, :wiki_enabled) }
+
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
+
+ context 'when authenticated' do
+ context 'and as a developer on the team' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'but the repo is disabled' do
+ let(:project) { create(:project, :repository, :private, :repository_disabled, :wiki_enabled) }
+
+ it 'allows clones' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ it 'pushes are allowed' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context 'and not on the team' do
+ it 'rejects clones with 404 Not Found' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
+ end
+ end
+
+ it 'rejects pushes with 404 Not Found' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
+ end
+ end
end
end
end
@@ -84,49 +225,60 @@ describe 'Git HTTP requests', lib: true do
let(:path) { "#{project.path_with_namespace}.git" }
context "when the project is public" do
- before do
- project.update_attribute(:visibility_level, Project::PUBLIC)
- end
+ let(:project) { create(:project, :repository, :public) }
- it "downloads get status 200" do
- download(path, {}) do |response|
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
- end
+ it_behaves_like 'pushes require Basic HTTP Authentication'
- it "uploads get status 401" do
- upload(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
+ context 'when not authenticated' do
+ let(:env) { {} }
+
+ it_behaves_like 'pulls are allowed'
end
- context "with correct credentials" do
+ context "when authenticated" do
let(:env) { { user: user.username, password: user.password } }
- it "uploads get status 403" do
- upload(path, env) do |response|
- expect(response).to have_http_status(403)
+ context 'as a developer on the team' do
+ before do
+ project.team << [user, :developer]
end
- end
- context 'but git-receive-pack is disabled' do
- it "responds with status 404" do
- allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
- upload(path, env) do |response|
- expect(response).to have_http_status(403)
+ context 'but git-receive-pack over HTTP is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ end
+
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:receive_pack_disabled_over_http))
+ end
+ end
+ end
+
+ context 'but git-upload-pack over HTTP is disabled in config' do
+ it "rejects pushes with 403 Forbidden" do
+ allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+
+ download(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload_pack_disabled_over_http))
+ end
end
end
end
- end
- context 'but git-upload-pack is disabled' do
- it "responds with status 404" do
- allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+ context 'and not a member of the team' do
+ it_behaves_like 'pulls are allowed'
- download(path, {}) do |response|
- expect(response).to have_http_status(404)
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(change_access_error(:push_code))
+ end
end
end
end
@@ -141,66 +293,41 @@ describe 'Git HTTP requests', lib: true do
context 'when the repo is public' do
context 'but the repo is disabled' do
- it 'does not allow to clone the repo' do
- project = create(:project, :public, :repository_disabled)
+ let(:project) { create(:project, :public, :repository, :repository_disabled) }
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { {} }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:unauthorized)
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
end
context 'but the repo is enabled' do
- it 'allows to clone the repo' do
- project = create(:project, :public, :repository_enabled)
+ let(:project) { create(:project, :public, :repository, :repository_enabled) }
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { {} }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:ok)
- end
- end
+ it_behaves_like 'pulls are allowed'
end
context 'but only project members are allowed' do
- it 'does not allow to clone the repo' do
- project = create(:project, :public, :repository_private)
+ let(:project) { create(:project, :public, :repository, :repository_private) }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:unauthorized)
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
end
end
end
context "when the project is private" do
- before do
- project.update_attribute(:visibility_level, Project::PRIVATE)
- end
-
- context "when no authentication is provided" do
- it "responds with status 401 to downloads" do
- download(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
- end
+ let(:project) { create(:project, :repository, :private) }
- it "responds with status 401 to uploads" do
- upload(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
context "when username and password are provided" do
let(:env) { { user: user.username, password: 'nope' } }
context "when authentication fails" do
- it "responds with status 401" do
- download(path, env) do |response|
- expect(response).to have_http_status(401)
- end
- end
-
context "when the user is IP banned" do
it "responds with status 401" do
expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true)
@@ -208,7 +335,7 @@ describe 'Git HTTP requests', lib: true do
clone_get(path, env)
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
end
@@ -222,37 +349,39 @@ describe 'Git HTTP requests', lib: true do
end
context "when the user is blocked" do
- it "responds with status 401" do
+ it "rejects pulls with 401 Unauthorized" do
user.block
project.team << [user, :master]
download(path, env) do |response|
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
- it "responds with status 401 for unknown projects (no project existence information leak)" do
+ it "rejects pulls with 401 Unauthorized for unknown projects (no project existence information leak)" do
user.block
download('doesnt/exist.git', env) do |response|
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
end
context "when the user isn't blocked" do
- it "downloads get status 200" do
- expect(Rack::Attack::Allow2Ban).to receive(:reset)
-
- clone_get(path, env)
+ it "resets the IP in Rack Attack on download" do
+ expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ download(path, env) do
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
end
- it "uploads get status 200" do
- upload(path, env) do |response|
- expect(response).to have_http_status(200)
+ it "resets the IP in Rack Attack on upload" do
+ expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
+
+ upload(path, env) do
+ expect(response).to have_http_status(:ok)
expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
end
@@ -272,54 +401,76 @@ describe 'Git HTTP requests', lib: true do
@token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api")
end
- it "downloads get status 200" do
- clone_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token
-
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
-
- it "uploads get status 200" do
- push_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { { user: 'oauth2', password: @token.token } }
- expect(response).to have_http_status(200)
- end
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
end
context 'when user has 2FA enabled' do
let(:user) { create(:user, :two_factor) }
let(:access_token) { create(:personal_access_token, user: user) }
+ let(:path) { "#{project.path_with_namespace}.git" }
before do
project.team << [user, :master]
end
context 'when username and password are provided' do
- it 'rejects the clone attempt' do
- download("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(401)
- expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
+ it 'rejects pulls with personal access token error message' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
- it 'rejects the push attempt' do
- upload("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(401)
- expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
+ it 'rejects the push attempt with personal access token error message' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
end
context 'when username and personal access token are provided' do
- it 'allows clones' do
- download("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response|
- expect(response).to have_http_status(200)
- end
+ let(:env) { { user: user.username, password: access_token.token } }
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
+ end
+ end
+
+ context 'when internal auth is disabled' do
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:signin_enabled?) { false }
+ end
+
+ it 'rejects pulls with personal access token error message' do
+ download(path, user: 'foo', password: 'bar') do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
+
+ it 'rejects pushes with personal access token error message' do
+ upload(path, user: 'foo', password: 'bar') do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
+
+ context 'when LDAP is configured' do
+ before do
+ allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
+ allow_any_instance_of(Gitlab::LDAP::Authentication).
+ to receive(:login).and_return(nil)
end
- it 'allows pushes' do
- upload("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response|
- expect(response).to have_http_status(200)
+ it 'does not display the personal access token error message' do
+ upload(path, user: 'foo', password: 'bar') do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
end
@@ -357,15 +508,15 @@ describe 'Git HTTP requests', lib: true do
end
context "when the user doesn't have access to the project" do
- it "downloads get status 404" do
+ it "pulls get status 404" do
download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
it "uploads get status 404" do
upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
end
@@ -373,28 +524,41 @@ describe 'Git HTTP requests', lib: true do
end
context "when a gitlab ci token is provided" do
+ let(:project) { create(:project, :repository) }
let(:build) { create(:ci_build, :running) }
- let(:project) { build.project }
let(:other_project) { create(:empty_project) }
- context 'when build created by system is authenticated' do
- it "downloads get status 200" do
- clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
-
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
-
- it "uploads get status 401 (no project existence information leak)" do
- push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ before do
+ build.update!(project: project) # can't associate it on factory create
+ end
- expect(response).to have_http_status(401)
+ context 'when build created by system is authenticated' do
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { { user: 'gitlab-ci-token', password: build.token } }
+
+ it_behaves_like 'pulls are allowed'
+
+ # A non-401 here is not an information leak since the system is
+ # "authenticated" as CI using the correct token. It does not have
+ # push access, so pushes should be rejected as forbidden, and giving
+ # a reason is fine.
+ #
+ # We know for sure it is not an information leak since pulls using
+ # the build token must be allowed.
+ it "rejects pushes with 403 Forbidden" do
+ push_get(path, env)
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload))
end
- it "downloads from other project get status 404" do
- clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ # We are "authenticated" as CI using a valid token here. But we are
+ # not authorized to see any other project, so return "not found".
+ it "rejects pulls for other project with 404 Not Found" do
+ clone_get("#{other_project.path_with_namespace}.git", env)
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
end
end
@@ -405,31 +569,27 @@ describe 'Git HTTP requests', lib: true do
end
shared_examples 'can download code only' do
- it 'downloads get status 200' do
- allow_any_instance_of(Repository).
- to receive(:exists?).and_return(true)
-
- clone_get "#{project.path_with_namespace}.git",
- user: 'gitlab-ci-token', password: build.token
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { { user: 'gitlab-ci-token', password: build.token } }
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
+ it_behaves_like 'pulls are allowed'
- it 'downloads from non-existing repository and gets 403' do
- allow_any_instance_of(Repository).
- to receive(:exists?).and_return(false)
+ context 'when the repo does not exist' do
+ let(:project) { create(:empty_project) }
- clone_get "#{project.path_with_namespace}.git",
- user: 'gitlab-ci-token', password: build.token
+ it 'rejects pulls with 403 Forbidden' do
+ clone_get path, env
- expect(response).to have_http_status(403)
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:no_repo))
+ end
end
- it 'uploads get status 403' do
- push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ it 'rejects pushes with 403 Forbidden' do
+ push_get path, env
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload))
end
end
@@ -441,7 +601,7 @@ describe 'Git HTTP requests', lib: true do
it 'downloads from other project get status 403' do
clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
- expect(response).to have_http_status(403)
+ expect(response).to have_http_status(:forbidden)
end
end
@@ -453,91 +613,93 @@ describe 'Git HTTP requests', lib: true do
it 'downloads from other project get status 404' do
clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
end
end
end
- end
- context "when the project path doesn't end in .git" do
- context "GET info/refs" do
- let(:path) { "/#{project.path_with_namespace}/info/refs" }
+ context "when the project path doesn't end in .git" do
+ let(:project) { create(:project, :repository, :public, path: 'project.git-project') }
+
+ context "GET info/refs" do
+ let(:path) { "/#{project.path_with_namespace}/info/refs" }
- context "when no params are added" do
- before { get path }
+ context "when no params are added" do
+ before { get path }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs")
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs")
+ end
end
- end
- context "when the upload-pack service is requested" do
- let(:params) { { service: 'git-upload-pack' } }
- before { get path, params }
+ context "when the upload-pack service is requested" do
+ let(:params) { { service: 'git-upload-pack' } }
+ before { get path, params }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ end
end
- end
- context "when the receive-pack service is requested" do
- let(:params) { { service: 'git-receive-pack' } }
- before { get path, params }
+ context "when the receive-pack service is requested" do
+ let(:params) { { service: 'git-receive-pack' } }
+ before { get path, params }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ end
end
- end
- context "when the params are anything else" do
- let(:params) { { service: 'git-implode-pack' } }
- before { get path, params }
+ context "when the params are anything else" do
+ let(:params) { { service: 'git-implode-pack' } }
+ before { get path, params }
- it "redirects to the sign-in page" do
- expect(response).to redirect_to(new_user_session_path)
+ it "redirects to the sign-in page" do
+ expect(response).to redirect_to(new_user_session_path)
+ end
end
end
- end
- context "POST git-upload-pack" do
- it "fails to find a route" do
- expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ context "POST git-upload-pack" do
+ it "fails to find a route" do
+ expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ end
end
- end
- context "POST git-receive-pack" do
- it "failes to find a route" do
- expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ context "POST git-receive-pack" do
+ it "failes to find a route" do
+ expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ end
end
end
- end
- context "retrieving an info/refs file" do
- before { project.update_attribute(:visibility_level, Project::PUBLIC) }
+ context "retrieving an info/refs file" do
+ let(:project) { create(:project, :repository, :public) }
- context "when the file exists" do
- before do
- # Provide a dummy file in its place
- allow_any_instance_of(Repository).to receive(:blob_at).and_call_original
- allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do
- Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt')
- end
+ context "when the file exists" do
+ before do
+ # Provide a dummy file in its place
+ allow_any_instance_of(Repository).to receive(:blob_at).and_call_original
+ allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do
+ Blob.decorate(Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt'), project)
+ end
- get "/#{project.path_with_namespace}/blob/master/info/refs"
- end
+ get "/#{project.path_with_namespace}/blob/master/info/refs"
+ end
- it "returns the file" do
- expect(response).to have_http_status(200)
+ it "returns the file" do
+ expect(response).to have_http_status(:ok)
+ end
end
- end
- context "when the file does not exist" do
- before { get "/#{project.path_with_namespace}/blob/master/info/refs" }
+ context "when the file does not exist" do
+ before { get "/#{project.path_with_namespace}/blob/master/info/refs" }
- it "returns not found" do
- expect(response).to have_http_status(404)
+ it "returns not found" do
+ expect(response).to have_http_status(:not_found)
+ end
end
end
end
@@ -546,6 +708,7 @@ describe 'Git HTTP requests', lib: true do
describe "User with LDAP identity" do
let(:user) { create(:omniauth_user, extern_uid: dn) }
let(:dn) { 'uid=john,ou=people,dc=example,dc=com' }
+ let(:path) { 'doesnt/exist.git' }
before do
allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
@@ -553,44 +716,36 @@ describe 'Git HTTP requests', lib: true do
allow(Gitlab::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user)
end
- context "when authentication fails" do
- context "when no authentication is provided" do
- it "responds with status 401" do
- download('doesnt/exist.git') do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
-
- context "when username and invalid password are provided" do
- it "responds with status 401" do
- download('doesnt/exist.git', user: user.username, password: "nope") do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
context "when authentication succeeds" do
context "when the project doesn't exist" do
- it "responds with status 404" do
- download('/doesnt/exist.git', user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ it "responds with status 404 Not Found" do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
end
end
end
context "when the project exists" do
- let(:project) { create(:project, path: 'project.git-project') }
+ let(:project) { create(:project, :repository) }
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { { user: user.username, password: user.password } }
- before do
- project.team << [user, :master]
- end
+ context 'and the user is on the team' do
+ before do
+ project.team << [user, :master]
+ end
- it "responds with status 200" do
- clone_get(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ it "responds with status 200" do
+ clone_get(path, env) do |response|
+ expect(response).to have_http_status(200)
+ end
end
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
end
end
end
diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb
index a3e7844b2f3..54d7cf5f10d 100644
--- a/spec/requests/jwt_controller_spec.rb
+++ b/spec/requests/jwt_controller_spec.rb
@@ -41,6 +41,19 @@ describe JwtController do
it { expect(response).to have_http_status(401) }
end
+
+ context 'using personal access tokens' do
+ let(:user) { create(:user) }
+ let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) }
+ let(:headers) { { authorization: credentials('personal_access_token', pat.token) } }
+
+ subject! { get '/jwt/auth', parameters, headers }
+
+ it 'authenticates correctly' do
+ expect(response).to have_http_status(200)
+ expect(service_class).to have_received(:new).with(nil, user, parameters)
+ end
+ end
end
context 'using User login' do
@@ -57,7 +70,7 @@ describe JwtController do
context 'without personal token' do
it 'rejects the authorization attempt' do
expect(response).to have_http_status(401)
- expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
@@ -75,9 +88,24 @@ describe JwtController do
context 'using invalid login' do
let(:headers) { { authorization: credentials('invalid', 'password') } }
- subject! { get '/jwt/auth', parameters, headers }
+ context 'when internal auth is enabled' do
+ it 'rejects the authorization attempt' do
+ get '/jwt/auth', parameters, headers
- it { expect(response).to have_http_status(401) }
+ expect(response).to have_http_status(401)
+ expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
+
+ context 'when internal auth is disabled' do
+ it 'rejects the authorization attempt with personal access token message' do
+ allow_any_instance_of(ApplicationSetting).to receive(:signin_enabled?) { false }
+ get '/jwt/auth', parameters, headers
+
+ expect(response).to have_http_status(401)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
end
end
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index 0c9b4121adf..697b150ab34 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -759,8 +759,8 @@ describe 'Git LFS API and storage' do
context 'tries to push to own project' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
@@ -769,8 +769,9 @@ describe 'Git LFS API and storage' do
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ # I'm not sure what this tests that is different from the previous test
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -778,8 +779,8 @@ describe 'Git LFS API and storage' do
context 'does not have user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -979,8 +980,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because the build user can read the project)' do
+ expect(response).to have_http_status(403)
end
end
@@ -993,8 +994,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 404 (do not leak non-public project existence)' do
+ expect(response).to have_http_status(404)
end
end
end
@@ -1006,8 +1007,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 404 (do not leak non-public project existence)' do
+ expect(response).to have_http_status(404)
end
end
end
@@ -1079,8 +1080,8 @@ describe 'Git LFS API and storage' do
context 'tries to push to own project' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
@@ -1089,8 +1090,9 @@ describe 'Git LFS API and storage' do
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ # I'm not sure what this tests that is different from the previous test
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -1098,8 +1100,8 @@ describe 'Git LFS API and storage' do
context 'does not have user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb
index 05176c3beaa..6d1f0b24196 100644
--- a/spec/requests/openid_connect_spec.rb
+++ b/spec/requests/openid_connect_spec.rb
@@ -79,7 +79,7 @@ describe 'OpenID Connect requests' do
'email_verified' => true,
'website' => 'https://example.com',
'profile' => 'http://localhost/alice',
- 'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png"
+ 'picture' => "http://localhost/uploads/system/user/avatar/#{user.id}/dk.png"
})
end
end
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index 54417f6b3e1..0a6778ae2ef 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -201,10 +201,12 @@ describe 'project routing' do
# POST /:project_id/deploy_keys(.:format) deploy_keys#create
# new_project_deploy_key GET /:project_id/deploy_keys/new(.:format) deploy_keys#new
# project_deploy_key GET /:project_id/deploy_keys/:id(.:format) deploy_keys#show
+ # edit_project_deploy_key GET /:project_id/deploy_keys/:id/edit(.:format) deploy_keys#edit
+ # project_deploy_key PATCH /:project_id/deploy_keys/:id(.:format) deploy_keys#update
# DELETE /:project_id/deploy_keys/:id(.:format) deploy_keys#destroy
describe Projects::DeployKeysController, 'routing' do
it_behaves_like 'RESTful project resources' do
- let(:actions) { [:index, :new, :create] }
+ let(:actions) { [:index, :new, :create, :edit, :update] }
let(:controller) { 'deploy_keys' }
end
end
diff --git a/spec/rubocop/cop/activerecord_serialize_spec.rb b/spec/rubocop/cop/activerecord_serialize_spec.rb
index a303b16d264..5bd7e5fa926 100644
--- a/spec/rubocop/cop/activerecord_serialize_spec.rb
+++ b/spec/rubocop/cop/activerecord_serialize_spec.rb
@@ -10,7 +10,7 @@ describe RuboCop::Cop::ActiverecordSerialize do
context 'inside the app/models directory' do
it 'registers an offense when serialize is used' do
- allow(cop).to receive(:in_models?).and_return(true)
+ allow(cop).to receive(:in_model?).and_return(true)
inspect_source(cop, 'serialize :foo')
@@ -23,7 +23,7 @@ describe RuboCop::Cop::ActiverecordSerialize do
context 'outside the app/models directory' do
it 'does nothing' do
- allow(cop).to receive(:in_models?).and_return(false)
+ allow(cop).to receive(:in_model?).and_return(false)
inspect_source(cop, 'serialize :foo')
diff --git a/spec/rubocop/cop/polymorphic_associations_spec.rb b/spec/rubocop/cop/polymorphic_associations_spec.rb
new file mode 100644
index 00000000000..49959aa6419
--- /dev/null
+++ b/spec/rubocop/cop/polymorphic_associations_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/polymorphic_associations'
+
+describe RuboCop::Cop::PolymorphicAssociations do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'inside the app/models directory' do
+ it 'registers an offense when polymorphic: true is used' do
+ allow(cop).to receive(:in_model?).and_return(true)
+
+ inspect_source(cop, 'belongs_to :foo, polymorphic: true')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+ end
+
+ context 'outside the app/models directory' do
+ it 'does nothing' do
+ allow(cop).to receive(:in_model?).and_return(false)
+
+ inspect_source(cop, 'belongs_to :foo, polymorphic: true')
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+end
diff --git a/spec/rubocop/cop/redirect_with_status_spec.rb b/spec/rubocop/cop/redirect_with_status_spec.rb
new file mode 100644
index 00000000000..5ad63567f84
--- /dev/null
+++ b/spec/rubocop/cop/redirect_with_status_spec.rb
@@ -0,0 +1,86 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../rubocop/cop/redirect_with_status'
+
+describe RuboCop::Cop::RedirectWithStatus do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+ let(:controller_fixture_without_status) do
+ %q(
+ class UserController < ApplicationController
+ def show
+ user = User.find(params[:id])
+ redirect_to user_path if user.name == 'John Wick'
+ end
+
+ def destroy
+ user = User.find(params[:id])
+
+ if user.destroy
+ redirect_to root_path
+ else
+ render :show
+ end
+ end
+ end
+ )
+ end
+
+ let(:controller_fixture_with_status) do
+ %q(
+ class UserController < ApplicationController
+ def show
+ user = User.find(params[:id])
+ redirect_to user_path if user.name == 'John Wick'
+ end
+
+ def destroy
+ user = User.find(params[:id])
+
+ if user.destroy
+ redirect_to root_path, status: 302
+ else
+ render :show
+ end
+ end
+ end
+ )
+ end
+
+ context 'in controller' do
+ before do
+ allow(cop).to receive(:in_controller?).and_return(true)
+ end
+
+ it 'registers an offense when a "destroy" action uses "redirect_to" without "status"' do
+ inspect_source(cop, controller_fixture_without_status)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([12]) # 'redirect_to' is located on 12th line in controller_fixture.
+ expect(cop.highlights).to eq(['redirect_to'])
+ end
+ end
+
+ it 'does not register an offense when a "destroy" action uses "redirect_to" with "status"' do
+ inspect_source(cop, controller_fixture_with_status)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+ end
+
+ context 'outside of controller' do
+ it 'registers no offense' do
+ inspect_source(cop, controller_fixture_without_status)
+ inspect_source(cop, controller_fixture_with_status)
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb
index b4eef20d6a6..ad0d3d3839e 100644
--- a/spec/serializers/build_artifact_entity_spec.rb
+++ b/spec/serializers/build_artifact_entity_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe BuildArtifactEntity do
- let(:job) { create(:ci_build, name: 'test:job') }
+ let(:job) { create(:ci_build, name: 'test:job', artifacts_expire_at: 1.hour.from_now) }
let(:entity) do
described_class.new(job, request: double)
@@ -14,9 +14,19 @@ describe BuildArtifactEntity do
expect(subject[:name]).to eq 'test:job'
end
- it 'contains path to the artifacts' do
+ it 'exposes information about expiration of artifacts' do
+ expect(subject).to include(:expired, :expire_at)
+ end
+
+ it 'contains paths to the artifacts' do
expect(subject[:path])
.to include "jobs/#{job.id}/artifacts/download"
+
+ expect(subject[:keep_path])
+ .to include "jobs/#{job.id}/artifacts/keep"
+
+ expect(subject[:browse_path])
+ .to include "jobs/#{job.id}/artifacts/browse"
end
end
end
diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb
new file mode 100644
index 00000000000..e2511e8968c
--- /dev/null
+++ b/spec/serializers/build_details_entity_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe BuildDetailsEntity do
+ set(:user) { create(:admin) }
+
+ it 'inherits from BuildEntity' do
+ expect(described_class).to be < BuildEntity
+ end
+
+ describe '#as_json' do
+ let(:project) { create(:project, :repository) }
+ let!(:build) { create(:ci_build, :failed, project: project) }
+ let(:request) { double('request') }
+ let(:entity) { described_class.new(build, request: request, current_user: user, project: project) }
+ subject { entity.as_json }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ context 'when the user has access to issues and merge requests' do
+ let!(:merge_request) do
+ create(:merge_request, source_project: project, source_branch: build.ref)
+ end
+
+ before do
+ allow(build).to receive(:merge_request).and_return(merge_request)
+ end
+
+ it 'contains the needed key value pairs' do
+ expect(subject).to include(:coverage, :erased_at, :duration)
+ expect(subject).to include(:artifacts, :runner, :pipeline)
+ expect(subject).to include(:raw_path, :merge_request)
+ expect(subject).to include(:new_issue_path)
+ end
+
+ it 'exposes details of the merge request' do
+ expect(subject[:merge_request]).to include(:iid, :path)
+ end
+
+ context 'when the build has been erased' do
+ let!(:build) { create(:ci_build, :erasable, project: project) }
+
+ it 'exposes the user whom erased the build' do
+ expect(subject).to include(:erase_path)
+ end
+ end
+
+ context 'when the build has been erased' do
+ let!(:build) { create(:ci_build, erased_at: Time.now, project: project, erased_by: user) }
+
+ it 'exposes the user whom erased the build' do
+ expect(subject).to include(:erased_by)
+ end
+ end
+ end
+
+ context 'when the user can only read the build' do
+ let(:user) { create(:user) }
+
+ it "won't display the paths to issues and merge requests" do
+ expect(subject['new_issue_path']).to be_nil
+ expect(subject['merge_request_path']).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb
index 6d5e1046e86..46d43a80ef7 100644
--- a/spec/serializers/build_entity_spec.rb
+++ b/spec/serializers/build_entity_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe BuildEntity do
let(:user) { create(:user) }
- let(:build) { create(:ci_build) }
+ let(:build) { create(:ci_build, :failed) }
let(:project) { build.project }
let(:request) { double('request') }
@@ -18,6 +18,7 @@ describe BuildEntity do
it 'contains paths to build page and retry action' do
expect(subject).to include(:build_path, :retry_path)
+ expect(subject[:retry_path]).not_to be_nil
end
it 'does not contain sensitive information' do
diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb
index e73fbe190ca..ed89fccc3d0 100644
--- a/spec/serializers/deploy_key_entity_spec.rb
+++ b/spec/serializers/deploy_key_entity_spec.rb
@@ -12,27 +12,44 @@ describe DeployKeyEntity do
let(:entity) { described_class.new(deploy_key, user: user) }
- it 'returns deploy keys with projects a user can read' do
- expected_result = {
- id: deploy_key.id,
- user_id: deploy_key.user_id,
- title: deploy_key.title,
- fingerprint: deploy_key.fingerprint,
- can_push: deploy_key.can_push,
- destroyed_when_orphaned: true,
- almost_orphaned: false,
- created_at: deploy_key.created_at,
- updated_at: deploy_key.updated_at,
- projects: [
- {
- id: project.id,
- name: project.name,
- full_path: namespace_project_path(project.namespace, project),
- full_name: project.full_name
- }
- ]
- }
-
- expect(entity.as_json).to eq(expected_result)
+ describe 'returns deploy keys with projects a user can read' do
+ let(:expected_result) do
+ {
+ id: deploy_key.id,
+ user_id: deploy_key.user_id,
+ title: deploy_key.title,
+ fingerprint: deploy_key.fingerprint,
+ can_push: deploy_key.can_push,
+ destroyed_when_orphaned: true,
+ almost_orphaned: false,
+ created_at: deploy_key.created_at,
+ updated_at: deploy_key.updated_at,
+ can_edit: false,
+ projects: [
+ {
+ id: project.id,
+ name: project.name,
+ full_path: namespace_project_path(project.namespace, project),
+ full_name: project.full_name
+ }
+ ]
+ }
+ end
+
+ it { expect(entity.as_json).to eq(expected_result) }
+ end
+
+ describe 'returns can_edit true if user is a master of project' do
+ before do
+ project.add_master(user)
+ end
+
+ it { expect(entity.as_json).to include(can_edit: true) }
+ end
+
+ describe 'returns can_edit true if a user admin' do
+ let(:user) { create(:user, :admin) }
+
+ it { expect(entity.as_json).to include(can_edit: true) }
end
end
diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb
index b75c73e78c2..d38433c2365 100644
--- a/spec/serializers/merge_request_entity_spec.rb
+++ b/spec/serializers/merge_request_entity_spec.rb
@@ -26,7 +26,7 @@ describe MergeRequestEntity do
pipeline = build_stubbed(:ci_pipeline)
allow(resource).to receive(:head_pipeline).and_return(pipeline)
- pipeline_payload = PipelineEntity
+ pipeline_payload = PipelineDetailsEntity
.represent(pipeline, request: req)
.as_json
diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb
new file mode 100644
index 00000000000..03cc5ae9b63
--- /dev/null
+++ b/spec/serializers/pipeline_details_entity_spec.rb
@@ -0,0 +1,120 @@
+require 'spec_helper'
+
+describe PipelineDetailsEntity do
+ set(:user) { create(:user) }
+ let(:request) { double('request') }
+
+ it 'inherrits from PipelineEntity' do
+ expect(described_class).to be < PipelineEntity
+ end
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ let(:entity) do
+ described_class.represent(pipeline, request: request)
+ end
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ context 'when pipeline is empty' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'contains details' do
+ expect(subject).to include :details
+ expect(subject[:details])
+ .to include :duration, :finished_at
+ expect(subject[:details])
+ .to include :stages, :artifacts, :manual_actions
+ expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
+ end
+
+ it 'contains flags' do
+ expect(subject).to include :flags
+ expect(subject[:flags])
+ .to include :latest, :stuck,
+ :yaml_errors, :retryable, :cancelable
+ end
+ end
+
+ context 'when pipeline is retryable' do
+ let(:project) { create(:empty_project) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, status: :success, project: project)
+ end
+
+ before do
+ create(:ci_build, :failed, pipeline: pipeline)
+ end
+
+ context 'user has ability to retry pipeline' do
+ before { project.team << [user, :developer] }
+
+ it 'retryable flag is true' do
+ expect(subject[:flags][:retryable]).to eq true
+ end
+ end
+
+ context 'user does not have ability to retry pipeline' do
+ it 'retryable flag is false' do
+ expect(subject[:flags][:retryable]).to eq false
+ end
+ end
+ end
+
+ context 'when pipeline is cancelable' do
+ let(:project) { create(:empty_project) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, status: :running, project: project)
+ end
+
+ before do
+ create(:ci_build, :pending, pipeline: pipeline)
+ end
+
+ context 'user has ability to cancel pipeline' do
+ before { project.add_developer(user) }
+
+ it 'cancelable flag is true' do
+ expect(subject[:flags][:cancelable]).to eq true
+ end
+ end
+
+ context 'user does not have ability to cancel pipeline' do
+ it 'cancelable flag is false' do
+ expect(subject[:flags][:cancelable]).to eq false
+ end
+ end
+ end
+
+ context 'when pipeline has YAML errors' do
+ let(:pipeline) do
+ create(:ci_pipeline, config: { rspec: { invalid: :value } })
+ end
+
+ it 'contains information about error' do
+ expect(subject[:yaml_errors]).to be_present
+ end
+
+ it 'contains flag that indicates there are errors' do
+ expect(subject[:flags][:yaml_errors]).to be true
+ end
+ end
+
+ context 'when pipeline does not have YAML errors' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'does not contain field that normally holds an error' do
+ expect(subject).not_to have_key(:yaml_errors)
+ end
+
+ it 'contains flag that indicates there are no errors' do
+ expect(subject[:flags][:yaml_errors]).to be false
+ end
+ end
+ end
+end
diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb
index 88ec4ed2952..a059c2cc736 100644
--- a/spec/serializers/pipeline_entity_spec.rb
+++ b/spec/serializers/pipeline_entity_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe PipelineEntity do
- let(:user) { create(:user) }
+ set(:user) { create(:user) }
let(:request) { double('request') }
before do
@@ -28,8 +28,6 @@ describe PipelineEntity do
expect(subject).to include :details
expect(subject[:details])
.to include :duration, :finished_at
- expect(subject[:details])
- .to include :stages, :artifacts, :manual_actions
expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
end
@@ -55,20 +53,12 @@ describe PipelineEntity do
context 'user has ability to retry pipeline' do
before { project.team << [user, :developer] }
- it 'retryable flag is true' do
- expect(subject[:flags][:retryable]).to eq true
- end
-
it 'contains retry path' do
expect(subject[:retry_path]).to be_present
end
end
context 'user does not have ability to retry pipeline' do
- it 'retryable flag is false' do
- expect(subject[:flags][:retryable]).to eq false
- end
-
it 'does not contain retry path' do
expect(subject).not_to have_key(:retry_path)
end
@@ -87,11 +77,7 @@ describe PipelineEntity do
end
context 'user has ability to cancel pipeline' do
- before { project.team << [user, :developer] }
-
- it 'cancelable flag is true' do
- expect(subject[:flags][:cancelable]).to eq true
- end
+ before { project.add_developer(user) }
it 'contains cancel path' do
expect(subject[:cancel_path]).to be_present
@@ -99,42 +85,12 @@ describe PipelineEntity do
end
context 'user does not have ability to cancel pipeline' do
- it 'cancelable flag is false' do
- expect(subject[:flags][:cancelable]).to eq false
- end
-
it 'does not contain cancel path' do
expect(subject).not_to have_key(:cancel_path)
end
end
end
- context 'when pipeline has YAML errors' do
- let(:pipeline) do
- create(:ci_pipeline, config: { rspec: { invalid: :value } })
- end
-
- it 'contains flag that indicates there are errors' do
- expect(subject[:flags][:yaml_errors]).to be true
- end
-
- it 'contains information about error' do
- expect(subject[:yaml_errors]).to be_present
- end
- end
-
- context 'when pipeline does not have YAML errors' do
- let(:pipeline) { create(:ci_empty_pipeline) }
-
- it 'contains flag that indicates there are no errors' do
- expect(subject[:flags][:yaml_errors]).to be false
- end
-
- it 'does not contain field that normally holds an error' do
- expect(subject).not_to have_key(:yaml_errors)
- end
- end
-
context 'when pipeline ref is empty' do
let(:pipeline) { create(:ci_empty_pipeline) }
diff --git a/spec/serializers/runner_entity_spec.rb b/spec/serializers/runner_entity_spec.rb
new file mode 100644
index 00000000000..4f25a8dcfa0
--- /dev/null
+++ b/spec/serializers/runner_entity_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe RunnerEntity do
+ let(:runner) { create(:ci_runner, :specific) }
+ let(:entity) { described_class.new(runner, request: request, current_user: user) }
+ let(:request) { double('request') }
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:admin) }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:project).and_return(project)
+ end
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'contains required fields' do
+ expect(subject).to include(:id, :description)
+ expect(subject).to include(:edit_path)
+ end
+ end
+end
diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb
index a8555f5b4a0..effa4633d13 100644
--- a/spec/services/boards/create_service_spec.rb
+++ b/spec/services/boards/create_service_spec.rb
@@ -14,8 +14,9 @@ describe Boards::CreateService, services: true do
it 'creates the default lists' do
board = service.execute
- expect(board.lists.size).to eq 1
- expect(board.lists.first).to be_closed
+ expect(board.lists.size).to eq 2
+ expect(board.lists.first).to be_backlog
+ expect(board.lists.last).to be_closed
end
end
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
index c982031c791..a1e220c2322 100644
--- a/spec/services/boards/issues/list_service_spec.rb
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -13,6 +13,7 @@ describe Boards::Issues::ListService, services: true do
let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
let(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
+ let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board) }
@@ -53,12 +54,20 @@ describe Boards::Issues::ListService, services: true do
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
+ it 'returns opened issues when listing issues from Backlog' do
+ params = { board_id: board.id, id: backlog.id }
+
+ issues = described_class.new(project, user, params).execute
+
+ expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
+ end
+
it 'returns closed issues when listing issues from Closed' do
params = { board_id: board.id, id: closed.id }
issues = described_class.new(project, user, params).execute
- expect(issues).to eq [closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1]
+ expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1]
end
it 'returns opened issues that have label list applied when listing issues from a label list' do
diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb
index ab9fb1bc914..68140759600 100644
--- a/spec/services/boards/lists/list_service_spec.rb
+++ b/spec/services/boards/lists/list_service_spec.rb
@@ -1,16 +1,33 @@
require 'spec_helper'
describe Boards::Lists::ListService, services: true do
+ let(:project) { create(:empty_project) }
+ let(:board) { create(:board, project: project) }
+ let(:label) { create(:label, project: project) }
+ let!(:list) { create(:list, board: board, label: label) }
+ let(:service) { described_class.new(project, double) }
+
describe '#execute' do
- it "returns board's lists" do
- project = create(:empty_project)
- board = create(:board, project: project)
- label = create(:label, project: project)
- list = create(:list, board: board, label: label)
+ context 'when the board has a backlog list' do
+ let!(:backlog_list) { create(:backlog_list, board: board) }
+
+ it 'does not create a backlog list' do
+ expect { service.execute(board) }.not_to change(board.lists, :count)
+ end
+
+ it "returns board's lists" do
+ expect(service.execute(board)).to eq [backlog_list, list, board.closed_list]
+ end
+ end
- service = described_class.new(project, double)
+ context 'when the board does not have a backlog list' do
+ it 'creates a backlog list' do
+ expect { service.execute(board) }.to change(board.lists, :count).by(1)
+ end
- expect(service.execute(board)).to eq [list, board.closed_list]
+ it "returns board's lists" do
+ expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
+ end
end
end
end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index 06fbd7bad90..77c07b71c68 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::CreatePipelineService, services: true do
+describe Ci::CreatePipelineService, :services do
let(:project) { create(:project, :repository) }
let(:user) { create(:admin) }
@@ -30,6 +30,7 @@ describe Ci::CreatePipelineService, services: true do
it 'creates a pipeline' do
expect(pipeline).to be_kind_of(Ci::Pipeline)
expect(pipeline).to be_valid
+ expect(pipeline).to be_persisted
expect(pipeline).to be_push
expect(pipeline).to eq(project.pipelines.last)
expect(pipeline).to have_attributes(user: user)
@@ -37,6 +38,14 @@ describe Ci::CreatePipelineService, services: true do
expect(pipeline.builds.first).to be_kind_of(Ci::Build)
end
+ it 'increments the prometheus counter' do
+ expect(Gitlab::Metrics).to receive(:counter)
+ .with(:pipelines_created_count, "Pipelines created count")
+ .and_call_original
+
+ pipeline
+ end
+
context 'when merge requests already exist for this source branch' do
it 'updates head pipeline of each merge request' do
merge_request_1 = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project)
@@ -296,5 +305,20 @@ describe Ci::CreatePipelineService, services: true do
expect(Environment.find_by(name: "review/master")).not_to be_nil
end
end
+
+ context 'when environment with invalid name' do
+ before do
+ config = YAML.dump(deploy: { environment: { name: 'name,with,commas' }, script: 'ls' })
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'does not create an environment' do
+ expect do
+ result = execute_service
+
+ expect(result).to be_persisted
+ end.not_to change { Environment.count }
+ end
+ end
end
end
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 7254e6b357a..ef9927c5969 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -25,12 +25,24 @@ describe Ci::RetryBuildService, :services do
user_id auto_canceled_by_id retried].freeze
shared_examples 'build duplication' do
+ let(:stage) do
+ # TODO, we still do not have factory for new stages, we will need to
+ # switch existing factory to persist stages, instead of using LegacyStage
+ #
+ Ci::Stage.create!(project: project, pipeline: pipeline, name: 'test')
+ end
+
let(:build) do
create(:ci_build, :failed, :artifacts_expired, :erased,
:queued, :coverage, :tags, :allowed_to_fail, :on_tag,
- :teardown_environment, :triggered, :trace,
- description: 'some build', pipeline: pipeline,
- auto_canceled_by: create(:ci_empty_pipeline))
+ :triggered, :trace, :teardown_environment,
+ description: 'my-job', stage: 'test', pipeline: pipeline,
+ auto_canceled_by: create(:ci_empty_pipeline)) do |build|
+ ##
+ # TODO, workaround for FactoryGirl limitation when having both
+ # stage (text) and stage_id (integer) columns in the table.
+ build.stage_id = stage.id
+ end
end
describe 'clone accessors' do
diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb
index f35d7a33548..5398b5c3f7e 100644
--- a/spec/services/create_deployment_service_spec.rb
+++ b/spec/services/create_deployment_service_spec.rb
@@ -1,156 +1,117 @@
require 'spec_helper'
describe CreateDeploymentService, services: true do
- let(:project) { create(:empty_project) }
let(:user) { create(:user) }
+ let(:options) { nil }
+
+ let(:job) do
+ create(:ci_build,
+ ref: 'master',
+ tag: false,
+ environment: 'production',
+ options: { environment: options })
+ end
- let(:service) { described_class.new(project, user, params) }
+ let(:project) { job.project }
- describe '#execute' do
- let(:options) { nil }
- let(:params) do
- {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142',
- options: options
- }
- end
+ let!(:environment) do
+ create(:environment, project: project, name: 'production')
+ end
- subject { service.execute }
+ let(:service) { described_class.new(job) }
- context 'when no environments exist' do
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
- end
+ describe '#execute' do
+ subject { service.execute }
- it 'does create a deployment' do
+ context 'when environment exists' do
+ it 'creates a deployment' do
expect(subject).to be_persisted
end
end
- context 'when environment exist' do
- let!(:environment) { create(:environment, project: project, name: 'production') }
-
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
+ context 'when environment does not exist' do
+ let(:environment) {}
- it 'does create a deployment' do
- expect(subject).to be_persisted
+ it 'does not create a deployment' do
+ expect do
+ expect(subject).to be_nil
+ end.not_to change { Deployment.count }
end
+ end
- context 'and start action is defined' do
- let(:options) { { action: 'start' } }
+ context 'when start action is defined' do
+ let(:options) { { action: 'start' } }
- context 'and environment is stopped' do
- before do
- environment.stop
- end
+ context 'and environment is stopped' do
+ before do
+ environment.stop
+ end
- it 'makes environment available' do
- subject
+ it 'makes environment available' do
+ subject
- expect(environment.reload).to be_available
- end
+ expect(environment.reload).to be_available
+ end
- it 'does create a deployment' do
- expect(subject).to be_persisted
- end
+ it 'creates a deployment' do
+ expect(subject).to be_persisted
end
end
+ end
- context 'and stop action is defined' do
- let(:options) { { action: 'stop' } }
-
- context 'and environment is available' do
- before do
- environment.start
- end
-
- it 'makes environment stopped' do
- subject
-
- expect(environment.reload).to be_stopped
- end
+ context 'when stop action is defined' do
+ let(:options) { { action: 'stop' } }
- it 'does not create a deployment' do
- expect(subject).to be_nil
- end
+ context 'and environment is available' do
+ before do
+ environment.start
end
- end
- end
- context 'for environment with invalid name' do
- let(:params) do
- {
- environment: 'name,with,commas',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142'
- }
- end
+ it 'makes environment stopped' do
+ subject
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
+ expect(environment.reload).to be_stopped
+ end
- it 'does not create a deployment' do
- expect(subject).to be_nil
+ it 'does not create a deployment' do
+ expect(subject).to be_nil
+ end
end
end
context 'when variables are used' do
- let(:params) do
- {
- environment: 'review-apps/$CI_COMMIT_REF_NAME',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142',
- options: {
- name: 'review-apps/$CI_COMMIT_REF_NAME',
- url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com'
- },
- variables: [
- { key: 'CI_COMMIT_REF_NAME', value: 'feature-review-apps' }
- ]
- }
+ let(:options) do
+ { name: 'review-apps/$CI_COMMIT_REF_NAME',
+ url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' }
end
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
-
- expect(subject.environment.name).to eq('review-apps/feature-review-apps')
- expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com')
+ before do
+ environment.update(name: 'review-apps/master')
+ job.update(environment: 'review-apps/$CI_COMMIT_REF_NAME')
end
- it 'does create a new deployment' do
+ it 'creates a new deployment' do
expect(subject).to be_persisted
end
- context 'and environment exist' do
- let!(:environment) { create(:environment, project: project, name: 'review-apps/feature-review-apps') }
-
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
-
- it 'updates external url' do
- subject
+ it 'does not create a new environment' do
+ expect { subject }.not_to change { Environment.count }
+ end
- expect(subject.environment.name).to eq('review-apps/feature-review-apps')
- expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com')
- end
+ it 'updates external url' do
+ subject
- it 'does create a new deployment' do
- expect(subject).to be_persisted
- end
+ expect(subject.environment.name).to eq('review-apps/master')
+ expect(subject.environment.external_url).to eq('http://master.review-apps.gitlab.com')
end
end
context 'when project was removed' do
- let(:project) { nil }
+ let(:environment) {}
+
+ before do
+ job.update(project: nil)
+ end
it 'does not create deployment or environment' do
expect { subject }.not_to raise_error
@@ -162,34 +123,26 @@ describe CreateDeploymentService, services: true do
end
describe 'processing of builds' do
- let(:environment) { nil }
-
- shared_examples 'does not create environment and deployment' do
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
-
+ shared_examples 'does not create deployment' do
it 'does not create a new deployment' do
expect { subject }.not_to change { Deployment.count }
end
it 'does not call a service' do
expect_any_instance_of(described_class).not_to receive(:execute)
+
subject
end
end
- shared_examples 'does create environment and deployment' do
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
- end
-
- it 'does create a new deployment' do
+ shared_examples 'creates deployment' do
+ it 'creates a new deployment' do
expect { subject }.to change { Deployment.count }.by(1)
end
- it 'does call a service' do
+ it 'calls a service' do
expect_any_instance_of(described_class).to receive(:execute)
+
subject
end
@@ -199,7 +152,7 @@ describe CreateDeploymentService, services: true do
expect(Deployment.last.deployable).to eq(deployable)
end
- it 'create environment has URL set' do
+ it 'updates environment URL' do
subject
expect(Deployment.last.environment.external_url).not_to be_nil
@@ -207,41 +160,39 @@ describe CreateDeploymentService, services: true do
end
context 'without environment specified' do
- let(:build) { create(:ci_build, project: project) }
+ let(:job) { create(:ci_build) }
- it_behaves_like 'does not create environment and deployment' do
- subject { build.success }
+ it_behaves_like 'does not create deployment' do
+ subject { job.success }
end
end
context 'when environment is specified' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
- let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production', options: options) }
+ let(:deployable) { job }
+
let(:options) do
{ environment: { name: 'production', url: 'http://gitlab.com' } }
end
- context 'when build succeeds' do
- it_behaves_like 'does create environment and deployment' do
- let(:deployable) { build }
-
- subject { build.success }
+ context 'when job succeeds' do
+ it_behaves_like 'creates deployment' do
+ subject { job.success }
end
end
- context 'when build fails' do
- it_behaves_like 'does not create environment and deployment' do
- subject { build.drop }
+ context 'when job fails' do
+ it_behaves_like 'does not create deployment' do
+ subject { job.drop }
end
end
- context 'when build is retried' do
- it_behaves_like 'does create environment and deployment' do
+ context 'when job is retried' do
+ it_behaves_like 'creates deployment' do
before do
project.add_developer(user)
end
- let(:deployable) { Ci::Build.retry(build, user) }
+ let(:deployable) { Ci::Build.retry(job, user) }
subject { deployable.success }
end
@@ -250,15 +201,6 @@ describe CreateDeploymentService, services: true do
end
describe "merge request metrics" do
- let(:params) do
- {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142b'
- }
- end
-
let(:merge_request) { create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: project) }
context "while updating the 'first_deployed_to_production_at' time" do
@@ -273,8 +215,8 @@ describe CreateDeploymentService, services: true do
end
it "doesn't set the time if the deploy's environment is not 'production'" do
- staging_params = params.merge(environment: 'staging')
- service = described_class.new(project, user, staging_params)
+ job.update(environment: 'staging')
+ service = described_class.new(job)
service.execute
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
@@ -298,7 +240,7 @@ describe CreateDeploymentService, services: true do
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time)
# Current deploy
- service = described_class.new(project, user, params)
+ service = described_class.new(job)
Timecop.freeze(time + 12.hours) { service.execute }
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time)
@@ -318,7 +260,7 @@ describe CreateDeploymentService, services: true do
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
# Current deploy
- service = described_class.new(project, user, params)
+ service = described_class.new(job)
Timecop.freeze(time + 12.hours) { service.execute }
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb
index 6437d00e451..eb9b1670c71 100644
--- a/spec/services/issuable/bulk_update_service_spec.rb
+++ b/spec/services/issuable/bulk_update_service_spec.rb
@@ -72,7 +72,7 @@ describe Issuable::BulkUpdateService, services: true do
end
context "when the new assignee ID is #{IssuableFinder::NONE}" do
- it "unassigns the issues" do
+ it 'unassigns the issues' do
expect { bulk_update(merge_request, assignee_id: IssuableFinder::NONE) }
.to change { merge_request.reload.assignee }.to(nil)
end
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 0670ac2faa2..5ce8e17976b 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -11,7 +11,7 @@ describe Members::CreateService, services: true do
params = { user_ids: project_user.id.to_s, access_level: Gitlab::Access::GUEST }
result = described_class.new(project, user, params).execute
- expect(result).to be_truthy
+ expect(result[:status]).to eq(:success)
expect(project.users).to include project_user
end
@@ -19,7 +19,19 @@ describe Members::CreateService, services: true do
params = { user_ids: '', access_level: Gitlab::Access::GUEST }
result = described_class.new(project, user, params).execute
- expect(result).to be_falsey
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to be_present
+ expect(project.users).not_to include project_user
+ end
+
+ it 'limits the number of users to 100' do
+ user_ids = 1.upto(101).to_a.join(',')
+ params = { user_ids: user_ids, access_level: Gitlab::Access::GUEST }
+
+ result = described_class.new(project, user, params).execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to be_present
expect(project.users).not_to include project_user
end
end
diff --git a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb
index 935f4710851..bb46e1dd9ab 100644
--- a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb
+++ b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb
@@ -10,8 +10,8 @@ describe MergeRequests::MergeRequestDiffCacheService do
expect(Rails.cache).to receive(:read).with(cache_key).and_return({})
expect(Rails.cache).to receive(:write).with(cache_key, anything)
- allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(double("text?" => true))
- allow_any_instance_of(Repository).to receive(:diffable?).and_return(true)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(true)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(true)
subject.execute(merge_request)
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index 033e6ecd18c..40298dcb723 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -115,7 +115,7 @@ describe Projects::CreateService, '#execute', services: true do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
opts.merge!(
- visibility_level: Gitlab::VisibilityLevel.options['Public']
+ visibility_level: Gitlab::VisibilityLevel::PUBLIC
)
end
@@ -161,15 +161,13 @@ describe Projects::CreateService, '#execute', services: true do
end
context 'when a bad service template is created' do
- before do
- create(:service, type: 'DroneCiService', project: nil, template: true, active: true)
- end
-
it 'reports an error in the imported project' do
opts[:import_url] = 'http://www.gitlab.com/gitlab-org/gitlab-ce'
+ create(:service, type: 'DroneCiService', project: nil, template: true, active: true)
+
project = create_project(user, opts)
- expect(project.errors.full_messages_for(:base).first).to match /Unable to save project. Error: Unable to save DroneCiService/
+ expect(project.errors.full_messages_for(:base).first).to match(/Unable to save project. Error: Unable to save DroneCiService/)
expect(project.services.count).to eq 0
end
end
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index f8eb34f2ef4..0df81f3abcb 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -3,8 +3,8 @@ require 'spec_helper'
describe Projects::ForkService, services: true do
describe 'fork by user' do
before do
- @from_namespace = create(:namespace)
- @from_user = create(:user, namespace: @from_namespace )
+ @from_user = create(:user)
+ @from_namespace = @from_user.namespace
avatar = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")
@from_project = create(:project,
:repository,
@@ -13,8 +13,8 @@ describe Projects::ForkService, services: true do
star_count: 107,
avatar: avatar,
description: 'wow such project')
- @to_namespace = create(:namespace)
- @to_user = create(:user, namespace: @to_namespace)
+ @to_user = create(:user)
+ @to_namespace = @to_user.namespace
@from_project.add_user(@to_user, :developer)
end
diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb
index 852a4ac852f..44db299812f 100644
--- a/spec/services/projects/import_service_spec.rb
+++ b/spec/services/projects/import_service_spec.rb
@@ -186,7 +186,7 @@ describe Projects::ImportService, services: true do
}
)
- allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider])
+ stub_omniauth_setting(providers: [provider])
end
end
end
diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb
index 0657b7e93fe..d75851134ee 100644
--- a/spec/services/projects/participants_service_spec.rb
+++ b/spec/services/projects/participants_service_spec.rb
@@ -13,7 +13,7 @@ describe Projects::ParticipantsService, services: true do
groups = participants.groups
expect(groups.size).to eq 1
- expect(groups.first[:avatar_url]).to eq("/uploads/group/avatar/#{group.id}/dk.png")
+ expect(groups.first[:avatar_url]).to eq("/uploads/system/group/avatar/#{group.id}/dk.png")
end
it 'should return an url for the avatar with relative url' do
@@ -24,7 +24,7 @@ describe Projects::ParticipantsService, services: true do
groups = participants.groups
expect(groups.size).to eq 1
- expect(groups.first[:avatar_url]).to eq("/gitlab/uploads/group/avatar/#{group.id}/dk.png")
+ expect(groups.first[:avatar_url]).to eq("/gitlab/uploads/system/group/avatar/#{group.id}/dk.png")
end
end
end
diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb
index e5e400ee281..4db491fd5f3 100644
--- a/spec/services/slash_commands/interpret_service_spec.rb
+++ b/spec/services/slash_commands/interpret_service_spec.rb
@@ -384,7 +384,7 @@ describe SlashCommands::InterpretService, services: true do
it 'fetches assignee and populates assignee_id if content contains /assign' do
_, updates = service.execute(content, issue)
- expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id])
+ expect(updates[:assignee_ids]).to match_array([developer.id])
end
end
diff --git a/spec/services/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb
new file mode 100644
index 00000000000..63a1e78f274
--- /dev/null
+++ b/spec/services/submit_usage_ping_service_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe SubmitUsagePingService do
+ context 'when usage ping is disabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: false)
+ end
+
+ it 'does not run' do
+ expect(HTTParty).not_to receive(:post)
+
+ result = subject.execute
+
+ expect(result).to eq false
+ end
+ end
+
+ context 'when usage ping is enabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: true)
+ end
+
+ it 'sends a POST request' do
+ response = stub_response(without_conv_index_params)
+
+ subject.execute
+
+ expect(response).to have_been_requested
+ end
+
+ it 'refreshes usage data statistics before submitting' do
+ stub_response(without_conv_index_params)
+
+ expect(Gitlab::UsageData).to receive(:to_json)
+ .with(force_refresh: true)
+ .and_call_original
+
+ subject.execute
+ end
+
+ it 'saves conversational development index data from the response' do
+ stub_response(with_conv_index_params)
+
+ expect { subject.execute }
+ .to change { ConversationalDevelopmentIndex::Metric.count }
+ .by(1)
+
+ expect(ConversationalDevelopmentIndex::Metric.last.leader_issues).to eq 10.2
+ end
+ end
+
+ def without_conv_index_params
+ {
+ conv_index: {}
+ }
+ end
+
+ def with_conv_index_params
+ {
+ conv_index: {
+ leader_issues: 10.2,
+ instance_issues: 3.2,
+
+ leader_notes: 25.3,
+ instance_notes: 23.2,
+
+ leader_milestones: 16.2,
+ instance_milestones: 5.5,
+
+ leader_boards: 5.2,
+ instance_boards: 3.2,
+
+ leader_merge_requests: 5.2,
+ instance_merge_requests: 3.2,
+
+ leader_ci_pipelines: 25.1,
+ instance_ci_pipelines: 21.3,
+
+ leader_environments: 3.3,
+ instance_environments: 2.2,
+
+ leader_deployments: 41.3,
+ instance_deployments: 15.2,
+
+ leader_projects_prometheus_active: 0.31,
+ instance_projects_prometheus_active: 0.30,
+
+ leader_service_desk_issues: 15.8,
+ instance_service_desk_issues: 15.1
+ }
+ }
+ end
+
+ def stub_response(body)
+ stub_request(:post, 'https://version.gitlab.com/usage_data').
+ to_return(
+ headers: { 'Content-Type' => 'application/json' },
+ body: body.to_json
+ )
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 994c7dcbb46..8b8fbf6e862 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -3,6 +3,7 @@ SimpleCovEnv.start!
ENV["RAILS_ENV"] ||= 'test'
ENV["IN_MEMORY_APPLICATION_SETTINGS"] = 'true'
+# ENV['prometheus_multiproc_dir'] = 'tmp/prometheus_multiproc_dir_test'
require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'
@@ -55,6 +56,7 @@ RSpec.configure do |config|
config.include StubGitlabCalls
config.include StubGitlabData
config.include ApiHelpers, :api
+ config.include MigrationsHelpers, :migration
config.infer_spec_type_from_file_location!
@@ -96,6 +98,17 @@ RSpec.configure do |config|
Sidekiq.redis(&:flushall)
end
+ config.around(:example, :migration) do |example|
+ begin
+ ActiveRecord::Migrator
+ .migrate(migrations_paths, previous_migration.version)
+
+ example.run
+ ensure
+ ActiveRecord::Migrator.migrate(migrations_paths)
+ end
+ end
+
config.around(:each, :nested_groups) do |example|
example.run if Group.supports_nested_groups?
end
diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb
index 66545127a44..6e1eb5c678d 100644
--- a/spec/support/cycle_analytics_helpers.rb
+++ b/spec/support/cycle_analytics_helpers.rb
@@ -51,12 +51,43 @@ module CycleAnalyticsHelpers
end
def deploy_master(environment: 'production')
- CreateDeploymentService.new(project, user, {
- environment: environment,
- ref: 'master',
- tag: false,
- sha: project.repository.commit('master').sha
- }).execute
+ dummy_job =
+ case environment
+ when 'production'
+ dummy_production_job
+ when 'staging'
+ dummy_staging_job
+ else
+ raise ArgumentError
+ end
+
+ CreateDeploymentService.new(dummy_job).execute
+ end
+
+ def dummy_production_job
+ @dummy_job ||= new_dummy_job('production')
+ end
+
+ def dummy_staging_job
+ @dummy_job ||= new_dummy_job('staging')
+ end
+
+ def dummy_pipeline
+ @dummy_pipeline ||=
+ Ci::Pipeline.new(sha: project.repository.commit('master').sha)
+ end
+
+ def new_dummy_job(environment)
+ project.environments.find_or_create_by(name: environment)
+
+ Ci::Build.new(
+ project: project,
+ user: user,
+ environment: environment,
+ ref: 'master',
+ tag: false,
+ name: 'dummy',
+ pipeline: dummy_pipeline)
end
end
diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb
index 6f31828b825..7f5769209bb 100644
--- a/spec/support/db_cleaner.rb
+++ b/spec/support/db_cleaner.rb
@@ -19,6 +19,10 @@ RSpec.configure do |config|
DatabaseCleaner.strategy = :truncation
end
+ config.before(:each, :migration) do
+ DatabaseCleaner.strategy = :truncation
+ end
+
config.before(:each) do
DatabaseCleaner.start
end
diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb
new file mode 100644
index 00000000000..0d80c95e826
--- /dev/null
+++ b/spec/support/features/reportable_note_shared_examples.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+shared_examples 'reportable note' do
+ include NotesHelper
+
+ let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") }
+ let(:more_actions_selector) { '.more-actions.dropdown' }
+ let(:abuse_report_path) { new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) }
+
+ it 'has a `More actions` dropdown' do
+ expect(comment).to have_selector(more_actions_selector)
+ end
+
+ it 'dropdown has Edit, Report and Delete links' do
+ dropdown = comment.find(more_actions_selector)
+
+ dropdown.click
+ dropdown.find('.dropdown-menu li', match: :first)
+
+ expect(dropdown).to have_button('Edit comment')
+ expect(dropdown).to have_link('Report as abuse', href: abuse_report_path)
+ expect(dropdown).to have_link('Delete comment', href: note_url(note, project))
+ end
+
+ it 'Report button links to a report page' do
+ dropdown = comment.find(more_actions_selector)
+
+ dropdown.click
+ dropdown.find('.dropdown-menu li', match: :first)
+
+ dropdown.click_link('Report as abuse')
+
+ expect(find('#user_name')['value']).to match(note.author.username)
+ expect(find('#abuse_report_message')['value']).to match(noteable_note_url(note))
+ end
+end
diff --git a/spec/support/git_http_helpers.rb b/spec/support/git_http_helpers.rb
index 46b686fce94..b8289e6c5f1 100644
--- a/spec/support/git_http_helpers.rb
+++ b/spec/support/git_http_helpers.rb
@@ -35,9 +35,14 @@ module GitHttpHelpers
yield response
end
+ def download_or_upload(*args, &block)
+ download(*args, &block)
+ upload(*args, &block)
+ end
+
def auth_env(user, password, spnego_request_token)
env = workhorse_internal_api_request_header
- if user && password
+ if user
env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password)
elsif spnego_request_token
env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}"
@@ -45,4 +50,19 @@ module GitHttpHelpers
env
end
+
+ def git_access_error(error_key)
+ message = Gitlab::GitAccess::ERROR_MESSAGES[error_key]
+ message || raise("GitAccess error message key '#{error_key}' not found")
+ end
+
+ def git_access_wiki_error(error_key)
+ message = Gitlab::GitAccessWiki::ERROR_MESSAGES[error_key]
+ message || raise("GitAccessWiki error message key '#{error_key}' not found")
+ end
+
+ def change_access_error(error_key)
+ message = Gitlab::Checks::ChangeAccess::ERROR_MESSAGES[error_key]
+ message || raise("ChangeAccess error message key '#{error_key}' not found")
+ end
end
diff --git a/spec/support/helpers/key_generator_helper.rb b/spec/support/helpers/key_generator_helper.rb
new file mode 100644
index 00000000000..b1c289ffef7
--- /dev/null
+++ b/spec/support/helpers/key_generator_helper.rb
@@ -0,0 +1,41 @@
+module Spec
+ module Support
+ module Helpers
+ class KeyGeneratorHelper
+ # The components in a openssh .pub / known_host RSA public key.
+ RSA_COMPONENTS = ['ssh-rsa', :e, :n].freeze
+
+ attr_reader :size
+
+ def initialize(size = 2048)
+ @size = size
+ end
+
+ def generate
+ key = OpenSSL::PKey::RSA.generate(size)
+ components = RSA_COMPONENTS.map do |component|
+ key.respond_to?(component) ? encode_mpi(key.public_send(component)) : component
+ end
+
+ # Ruby tries to be helpful and adds new lines every 60 bytes :(
+ 'ssh-rsa ' + [pack_pubkey_components(components)].pack('m').delete("\n")
+ end
+
+ private
+
+ # Encodes an openssh-mpi-encoded integer.
+ def encode_mpi(n)
+ chars, n = [], n.to_i
+ chars << (n & 0xff) && n >>= 8 while n != 0
+ chars << 0 if chars.empty? || chars.last >= 0x80
+ chars.reverse.pack('C*')
+ end
+
+ # Packs string components into an openssh-encoded pubkey.
+ def pack_pubkey_components(strings)
+ (strings.map { |s| [s.length].pack('N') }).zip(strings).flatten.join
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/note_interaction_helpers.rb b/spec/support/helpers/note_interaction_helpers.rb
new file mode 100644
index 00000000000..551c759133c
--- /dev/null
+++ b/spec/support/helpers/note_interaction_helpers.rb
@@ -0,0 +1,8 @@
+module NoteInteractionHelpers
+ def open_more_actions_dropdown(note)
+ note_element = find("#note_#{note.id}")
+
+ note_element.find('.more-actions').click
+ note_element.find('.more-actions .dropdown-menu li', match: :first)
+ end
+end
diff --git a/spec/support/import_spec_helper.rb b/spec/support/import_spec_helper.rb
index 6710962f082..d4eced724fa 100644
--- a/spec/support/import_spec_helper.rb
+++ b/spec/support/import_spec_helper.rb
@@ -28,6 +28,6 @@ module ImportSpecHelper
app_id: 'asd123',
app_secret: 'asd123'
)
- allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider])
+ stub_omniauth_setting(providers: [provider])
end
end
diff --git a/spec/support/javascript_fixtures_helpers.rb b/spec/support/javascript_fixtures_helpers.rb
index a982b159b48..aace4b3adee 100644
--- a/spec/support/javascript_fixtures_helpers.rb
+++ b/spec/support/javascript_fixtures_helpers.rb
@@ -48,7 +48,7 @@ module JavaScriptFixturesHelpers
link_tags = doc.css('link')
link_tags.remove
- scripts = doc.css("script:not([type='text/template'])")
+ scripts = doc.css("script:not([type='text/template']):not([type='text/x-template'])")
scripts.remove
fixture = doc.to_html
diff --git a/spec/support/migrations_helpers.rb b/spec/support/migrations_helpers.rb
new file mode 100644
index 00000000000..91fbb4eaf48
--- /dev/null
+++ b/spec/support/migrations_helpers.rb
@@ -0,0 +1,29 @@
+module MigrationsHelpers
+ def table(name)
+ Class.new(ActiveRecord::Base) { self.table_name = name }
+ end
+
+ def migrations_paths
+ ActiveRecord::Migrator.migrations_paths
+ end
+
+ def table_exists?(name)
+ ActiveRecord::Base.connection.table_exists?(name)
+ end
+
+ def migrations
+ ActiveRecord::Migrator.migrations(migrations_paths)
+ end
+
+ def previous_migration
+ migrations.each_cons(2) do |previous, migration|
+ break previous if migration.name == described_class.name
+ end
+ end
+
+ def migrate!
+ ActiveRecord::Migrator.up(migrations_paths) do |migration|
+ migration.name == described_class.name
+ end
+ end
+end
diff --git a/spec/support/milestone_tabs_examples.rb b/spec/support/milestone_tabs_examples.rb
index 4ad8b0a16e1..7cfc1e06975 100644
--- a/spec/support/milestone_tabs_examples.rb
+++ b/spec/support/milestone_tabs_examples.rb
@@ -1,10 +1,14 @@
shared_examples 'milestone tabs' do
def go(path, extra_params = {})
- params = if milestone.is_a?(GlobalMilestone)
- { group_id: group.to_param, id: milestone.safe_title, title: milestone.title }
- else
- { namespace_id: project.namespace.to_param, project_id: project, id: milestone.iid }
- end
+ params =
+ case milestone
+ when DashboardMilestone
+ { id: milestone.safe_title, title: milestone.title }
+ when GroupMilestone
+ { group_id: group.to_param, id: milestone.safe_title, title: milestone.title }
+ else
+ { namespace_id: project.namespace.to_param, project_id: project, id: milestone.iid }
+ end
get path, params.merge(extra_params)
end
diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb
index 444adcc1906..b39a23bd18a 100644
--- a/spec/support/stub_configuration.rb
+++ b/spec/support/stub_configuration.rb
@@ -25,6 +25,10 @@ module StubConfiguration
allow(Gitlab.config.mattermost).to receive_messages(messages)
end
+ def stub_omniauth_setting(messages)
+ allow(Gitlab.config.omniauth).to receive_messages(messages)
+ end
+
private
# Modifies stubbed messages to also stub possible predicate versions
diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb
index 72b3b226c1e..3f472e59c49 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -54,6 +54,8 @@ module TestEnv
'conflict-resolvable-fork' => '404fa3f'
}.freeze
+ TMP_TEST_PATH = Rails.root.join('tmp', 'tests', '**')
+
# Test environment
#
# See gitlab.yml.example test section for paths
@@ -98,9 +100,7 @@ module TestEnv
#
# Keeps gitlab-shell and gitlab-test
def clean_test_path
- tmp_test_path = Rails.root.join('tmp', 'tests', '**')
-
- Dir[tmp_test_path].each do |entry|
+ Dir[TMP_TEST_PATH].each do |entry|
unless File.basename(entry) =~ /\A(gitaly|gitlab-(shell|test|test_bare|test-fork|test-fork_bare))\z/
FileUtils.rm_rf(entry)
end
@@ -111,6 +111,14 @@ module TestEnv
FileUtils.mkdir_p(pages_path)
end
+ def clean_gitlab_test_path
+ Dir[TMP_TEST_PATH].each do |entry|
+ if File.basename(entry) =~ /\A(gitlab-(test|test_bare|test-fork|test-fork_bare))\z/
+ FileUtils.rm_rf(entry)
+ end
+ end
+ end
+
def setup_gitlab_shell
unless File.directory?(Gitlab.config.gitlab_shell.path)
unless system('rake', 'gitlab:shell:install')
@@ -249,7 +257,7 @@ module TestEnv
# Before we used Git clone's --mirror option, bare repos could end up
# with missing refs, clearing them and retrying should fix the issue.
- cleanup && init unless reset.call
+ cleanup && clean_gitlab_test_path && init unless reset.call
end
end
diff --git a/spec/unicorn/unicorn_spec.rb b/spec/unicorn/unicorn_spec.rb
index 8518c047a47..41de94d35c2 100644
--- a/spec/unicorn/unicorn_spec.rb
+++ b/spec/unicorn/unicorn_spec.rb
@@ -67,8 +67,8 @@ describe 'Unicorn' do
end
def wait_unicorn_boot!(master_pid, ready_file)
- # Unicorn should boot in under 60 seconds so 120 seconds seems like a good timeout.
- timeout = 120
+ # We have seen the boot timeout after 2 minutes in CI so let's set it to 5 minutes.
+ timeout = 5 * 60
timeout.times do
return if File.exist?(ready_file)
pid = Process.waitpid(master_pid, Process::WNOHANG)
diff --git a/spec/uploaders/attachment_uploader_spec.rb b/spec/uploaders/attachment_uploader_spec.rb
index ea714fb08f0..d82dbe871d5 100644
--- a/spec/uploaders/attachment_uploader_spec.rb
+++ b/spec/uploaders/attachment_uploader_spec.rb
@@ -3,6 +3,17 @@ require 'spec_helper'
describe AttachmentUploader do
let(:uploader) { described_class.new(build_stubbed(:user)) }
+ describe "#store_dir" do
+ it "stores in the system dir" do
+ expect(uploader.store_dir).to start_with("uploads/system/user")
+ end
+
+ it "uses the old path when using object storage" do
+ expect(described_class).to receive(:file_storage?).and_return(false)
+ expect(uploader.store_dir).to start_with("uploads/user")
+ end
+ end
+
describe '#move_to_cache' do
it 'is true' do
expect(uploader.move_to_cache).to eq(true)
diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb
index c4d558805ab..201fe6949aa 100644
--- a/spec/uploaders/avatar_uploader_spec.rb
+++ b/spec/uploaders/avatar_uploader_spec.rb
@@ -3,6 +3,17 @@ require 'spec_helper'
describe AvatarUploader do
let(:uploader) { described_class.new(build_stubbed(:user)) }
+ describe "#store_dir" do
+ it "stores in the system dir" do
+ expect(uploader.store_dir).to start_with("uploads/system/user")
+ end
+
+ it "uses the old path when using object storage" do
+ expect(described_class).to receive(:file_storage?).and_return(false)
+ expect(uploader.store_dir).to start_with("uploads/user")
+ end
+ end
+
describe '#move_to_cache' do
it 'is false' do
expect(uploader.move_to_cache).to eq(false)
diff --git a/spec/uploaders/file_mover_spec.rb b/spec/uploaders/file_mover_spec.rb
new file mode 100644
index 00000000000..896cb410ed5
--- /dev/null
+++ b/spec/uploaders/file_mover_spec.rb
@@ -0,0 +1,63 @@
+require 'spec_helper'
+
+describe FileMover do
+ let(:filename) { 'banana_sample.gif' }
+ let(:file) { fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) }
+ let(:temp_description) do
+ 'test ![banana_sample](/uploads/temp/secret55/banana_sample.gif) same ![banana_sample]'\
+ '(/uploads/temp/secret55/banana_sample.gif)'
+ end
+ let(:temp_file_path) { File.join('secret55', filename).to_s }
+ let(:file_path) { File.join('uploads', 'personal_snippet', snippet.id.to_s, 'secret55', filename).to_s }
+
+ let(:snippet) { create(:personal_snippet, description: temp_description) }
+
+ subject { described_class.new(file_path, snippet).execute }
+
+ describe '#execute' do
+ before do
+ expect(FileUtils).to receive(:mkdir_p).with(a_string_including(File.dirname(file_path)))
+ expect(FileUtils).to receive(:move).with(a_string_including(temp_file_path), a_string_including(file_path))
+ allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:exists?).and_return(true)
+ allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:size).and_return(10)
+ end
+
+ context 'when move and field update successful' do
+ it 'updates the description correctly' do
+ subject
+
+ expect(snippet.reload.description)
+ .to eq(
+ "test ![banana_sample](/uploads/personal_snippet/#{snippet.id}/secret55/banana_sample.gif)"\
+ " same ![banana_sample](/uploads/personal_snippet/#{snippet.id}/secret55/banana_sample.gif)"
+ )
+ end
+
+ it 'creates a new update record' do
+ expect { subject }.to change { Upload.count }.by(1)
+ end
+ end
+
+ context 'when update_markdown fails' do
+ before do
+ expect(FileUtils).to receive(:move).with(a_string_including(file_path), a_string_including(temp_file_path))
+ end
+
+ subject { described_class.new(file_path, snippet, :non_existing_field).execute }
+
+ it 'does not update the description' do
+ subject
+
+ expect(snippet.reload.description)
+ .to eq(
+ "test ![banana_sample](/uploads/temp/secret55/banana_sample.gif)"\
+ " same ![banana_sample](/uploads/temp/secret55/banana_sample.gif)"
+ )
+ end
+
+ it 'does not create a new update record' do
+ expect { subject }.not_to change { Upload.count }
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb
index d9113ef4095..47e9365e13d 100644
--- a/spec/uploaders/file_uploader_spec.rb
+++ b/spec/uploaders/file_uploader_spec.rb
@@ -15,6 +15,16 @@ describe FileUploader do
end
end
+ describe "#store_dir" do
+ it "stores in the namespace path" do
+ project = build_stubbed(:empty_project)
+ uploader = described_class.new(project)
+
+ expect(uploader.store_dir).to include(project.path_with_namespace)
+ expect(uploader.store_dir).not_to include("system")
+ end
+ end
+
describe 'initialize' do
it 'generates a secret if none is provided' do
expect(SecureRandom).to receive(:hex).and_return('secret')
diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb
new file mode 100644
index 00000000000..c3b72e7d677
--- /dev/null
+++ b/spec/uploaders/lfs_object_uploader_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe LfsObjectUploader do
+ let(:uploader) { described_class.new(build_stubbed(:empty_project)) }
+
+ describe '#cache!' do
+ it 'caches the file in the cache directory' do
+ # One to get the work dir, the other to remove it
+ expect(uploader).to receive(:workfile_path).exactly(2).times.and_call_original
+ expect(FileUtils).to receive(:mv).with(anything, /^#{uploader.work_dir}/).and_call_original
+ expect(FileUtils).to receive(:mv).with(/^#{uploader.work_dir}/, /^#{uploader.cache_dir}/).and_call_original
+
+ fixture = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg')
+ uploader.cache!(fixture_file_upload(fixture))
+
+ expect(uploader.file.path).to start_with(uploader.cache_dir)
+ end
+ end
+
+ describe '#move_to_cache' do
+ it 'is true' do
+ expect(uploader.move_to_cache).to eq(true)
+ end
+ end
+
+ describe '#move_to_store' do
+ it 'is true' do
+ expect(uploader.move_to_store).to eq(true)
+ end
+ end
+end
diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb
index 5c26e334a6e..bb32ee62ccb 100644
--- a/spec/uploaders/records_uploads_spec.rb
+++ b/spec/uploaders/records_uploads_spec.rb
@@ -1,7 +1,7 @@
require 'rails_helper'
describe RecordsUploads do
- let(:uploader) do
+ let!(:uploader) do
class RecordsUploadsExampleUploader < GitlabUploader
include RecordsUploads
@@ -57,6 +57,13 @@ describe RecordsUploads do
uploader.store!(upload_fixture('rails_sample.jpg'))
end
+ it 'does not create an Upload record if model is missing' do
+ expect_any_instance_of(RecordsUploadsExampleUploader).to receive(:model).and_return(nil)
+ expect(Upload).not_to receive(:record).with(uploader)
+
+ uploader.store!(upload_fixture('rails_sample.jpg'))
+ end
+
it 'it destroys Upload records at the same path before recording' do
existing = Upload.create!(
path: File.join('uploads', 'rails_sample.jpg'),
diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb
index 26241044533..49b4e04dc7c 100644
--- a/spec/workers/gitlab_usage_ping_worker_spec.rb
+++ b/spec/workers/gitlab_usage_ping_worker_spec.rb
@@ -3,21 +3,11 @@ require 'spec_helper'
describe GitlabUsagePingWorker do
subject { described_class.new }
- it "sends POST request" do
- stub_application_setting(usage_ping_enabled: true)
+ it 'delegates to SubmitUsagePingService' do
+ allow(subject).to receive(:try_obtain_lease).and_return(true)
- stub_request(:post, "https://version.gitlab.com/usage_data").
- to_return(status: 200, body: '', headers: {})
- expect(Gitlab::UsageData).to receive(:to_json).with({ force_refresh: true }).and_call_original
- expect(subject).to receive(:try_obtain_lease).and_return(true)
+ expect_any_instance_of(SubmitUsagePingService).to receive(:execute)
- expect(subject.perform.response.code.to_i).to eq(200)
- end
-
- it "does not run if usage ping is disabled" do
- stub_application_setting(usage_ping_enabled: false)
-
- expect(subject).not_to receive(:try_obtain_lease)
- expect(subject).not_to receive(:perform)
+ subject.perform
end
end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index f4bc63bcc6a..44163c735ba 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -94,26 +94,23 @@ describe PostReceive do
it { expect{ subject }.not_to change{ Ci::Pipeline.count } }
end
end
- end
- describe '#process_repository_update' do
- let(:changes) {'123456 789012 refs/heads/tést'}
- let(:fake_hook_data) do
- { event_name: 'repository_update' }
- end
+ context 'after project changes hooks' do
+ let(:changes) { '123456 789012 refs/heads/tést' }
+ let(:fake_hook_data) { Hash.new(event_name: 'repository_update') }
- before do
- allow_any_instance_of(Gitlab::GitPostReceive).to receive(:identify).and_return(project.owner)
- allow_any_instance_of(Gitlab::DataBuilder::Repository).to receive(:update).and_return(fake_hook_data)
- # silence hooks so we can isolate
- allow_any_instance_of(Key).to receive(:post_create_hook).and_return(true)
- allow(subject).to receive(:process_project_changes).and_return(true)
- end
+ before do
+ allow_any_instance_of(Gitlab::DataBuilder::Repository).to receive(:update).and_return(fake_hook_data)
+ # silence hooks so we can isolate
+ allow_any_instance_of(Key).to receive(:post_create_hook).and_return(true)
+ allow_any_instance_of(GitPushService).to receive(:execute).and_return(true)
+ end
- it 'calls SystemHooksService' do
- expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with(fake_hook_data, :repository_update_hooks).and_return(true)
+ it 'calls SystemHooksService' do
+ expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with(fake_hook_data, :repository_update_hooks).and_return(true)
- subject.perform(pwd(project), key_id, base64_changes)
+ described_class.new.perform(project_identifier, key_id, base64_changes)
+ end
end
end
diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb
index 5e1cb74c7fc..6ea5569b438 100644
--- a/spec/workers/repository_fork_worker_spec.rb
+++ b/spec/workers/repository_fork_worker_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe RepositoryForkWorker do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :import_scheduled) }
let(:fork_project) { create(:project, :repository, forked_from_project: project) }
let(:shell) { Gitlab::Shell.new }
@@ -46,15 +46,27 @@ describe RepositoryForkWorker do
end
it "handles bad fork" do
+ source_path = project.full_path
+ target_path = fork_project.namespace.full_path
+ error_message = "Unable to fork project #{project.id} for repository #{source_path} -> #{target_path}"
+
expect(shell).to receive(:fork_repository).and_return(false)
- expect(subject.logger).to receive(:error)
+ expect do
+ subject.perform(project.id, '/test/path', source_path, target_path)
+ end.to raise_error(RepositoryForkWorker::ForkError, error_message)
+ end
- subject.perform(
- project.id,
- '/test/path',
- project.full_path,
- fork_project.namespace.full_path)
+ it 'handles unexpected error' do
+ source_path = project.full_path
+ target_path = fork_project.namespace.full_path
+
+ allow_any_instance_of(Gitlab::Shell).to receive(:fork_repository).and_raise(RuntimeError)
+
+ expect do
+ subject.perform(project.id, '/test/path', source_path, target_path)
+ end.to raise_error(RepositoryForkWorker::ForkError)
+ expect(project.reload.import_status).to eq('failed')
end
end
end
diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb
index 5a2c0671dac..9c277c501f1 100644
--- a/spec/workers/repository_import_worker_spec.rb
+++ b/spec/workers/repository_import_worker_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe RepositoryImportWorker do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:empty_project, :import_scheduled) }
subject { described_class.new }
@@ -21,15 +21,26 @@ describe RepositoryImportWorker do
context 'when the import has failed' do
it 'hide the credentials that were used in the import URL' do
error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found }
- expect_any_instance_of(Projects::ImportService).to receive(:execute).
- and_return({ status: :error, message: error })
- allow(subject).to receive(:jid).and_return('123')
- subject.perform(project.id)
+ expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error })
+ allow(subject).to receive(:jid).and_return('123')
- expect(project.reload.import_error).to include("https://*****:*****@test.com/root/repoC.git/")
+ expect do
+ subject.perform(project.id)
+ end.to raise_error(RepositoryImportWorker::ImportError, error)
expect(project.reload.import_jid).not_to be_nil
end
end
+
+ context 'with unexpected error' do
+ it 'marks import as failed' do
+ allow_any_instance_of(Projects::ImportService).to receive(:execute).and_raise(RuntimeError)
+
+ expect do
+ subject.perform(project.id)
+ end.to raise_error(RepositoryImportWorker::ImportError)
+ expect(project.reload.import_status).to eq('failed')
+ end
+ end
end
end
diff --git a/tmp/prometheus_multiproc_dir/.gitkeep b/tmp/prometheus_multiproc_dir/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/tmp/prometheus_multiproc_dir/.gitkeep