summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc6
-rw-r--r--.gitlab-ci.yml7
-rw-r--r--CHANGELOG.md4
-rw-r--r--Gemfile5
-rw-r--r--Gemfile.lock14
-rw-r--r--app/assets/javascripts/activities.js37
-rw-r--r--app/assets/javascripts/activities.js.es636
-rw-r--r--app/assets/javascripts/build.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es643
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es645
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es642
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es645
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es655
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es642
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es642
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.js.es618
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6213
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es641
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es690
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es67
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es67
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es67
-rw-r--r--app/assets/javascripts/dispatcher.js.es69
-rw-r--r--app/assets/javascripts/environments/components/environment.js.es6248
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.js.es667
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.js.es622
-rw-r--r--app/assets/javascripts/environments/components/environment_item.js.es6496
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.js.es631
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.js.es626
-rw-r--r--app/assets/javascripts/environments/environments_bundle.js.es621
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js.es622
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js.es6131
-rw-r--r--app/assets/javascripts/environments/vue_resource_interceptor.js.es612
-rw-r--r--app/assets/javascripts/group_label_subscription.js.es653
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js5
-rw-r--r--app/assets/javascripts/lib/utils/pretty_time.js.es667
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js3
-rw-r--r--app/assets/javascripts/merge_request_widget.js.es62
-rw-r--r--app/assets/javascripts/notes.js57
-rw-r--r--app/assets/javascripts/pager.js64
-rw-r--r--app/assets/javascripts/pager.js.es673
-rw-r--r--app/assets/javascripts/pipelines.js.es618
-rw-r--r--app/assets/javascripts/project_label_subscription.js.es653
-rw-r--r--app/assets/javascripts/smart_interval.js.es6130
-rw-r--r--app/assets/javascripts/subbable_resource.js.es654
-rw-r--r--app/assets/javascripts/user_tabs.js.es62
-rw-r--r--app/assets/javascripts/vue_common_component/commit.js.es6176
-rw-r--r--app/assets/stylesheets/framework.scss2
-rw-r--r--app/assets/stylesheets/framework/blocks.scss29
-rw-r--r--app/assets/stylesheets/framework/buttons.scss6
-rw-r--r--app/assets/stylesheets/framework/common.scss16
-rw-r--r--app/assets/stylesheets/framework/highlight.scss4
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss9
-rw-r--r--app/assets/stylesheets/framework/page-header.scss67
-rw-r--r--app/assets/stylesheets/framework/variables.scss8
-rw-r--r--app/assets/stylesheets/framework/wells.scss45
-rw-r--r--app/assets/stylesheets/pages/admin.scss6
-rw-r--r--app/assets/stylesheets/pages/builds.scss29
-rw-r--r--app/assets/stylesheets/pages/commit.scss142
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss354
-rw-r--r--app/assets/stylesheets/pages/environments.scss43
-rw-r--r--app/assets/stylesheets/pages/labels.scss10
-rw-r--r--app/assets/stylesheets/pages/login.scss23
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss2
-rw-r--r--app/assets/stylesheets/pages/milestone.scss104
-rw-r--r--app/assets/stylesheets/pages/notes.scss96
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss3
-rw-r--r--app/assets/stylesheets/pages/projects.scss30
-rw-r--r--app/controllers/autocomplete_controller.rb8
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb7
-rw-r--r--app/controllers/concerns/issuable_actions.rb2
-rw-r--r--app/controllers/concerns/issuable_collections.rb4
-rw-r--r--app/controllers/concerns/issues_action.rb1
-rw-r--r--app/controllers/concerns/merge_requests_action.rb1
-rw-r--r--app/controllers/concerns/toggle_subscription_action.rb6
-rw-r--r--app/controllers/groups/labels_controller.rb7
-rw-r--r--app/controllers/profiles/chat_names_controller.rb64
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb65
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb47
-rw-r--r--app/controllers/projects/environments_controller.rb15
-rw-r--r--app/controllers/projects/forks_controller.rb1
-rw-r--r--app/controllers/projects/issues_controller.rb2
-rw-r--r--app/controllers/projects/labels_controller.rb7
-rw-r--r--app/controllers/projects/merge_requests_controller.rb11
-rw-r--r--app/controllers/projects/notes_controller.rb25
-rw-r--r--app/controllers/projects/services_controller.rb2
-rw-r--r--app/controllers/sent_notifications_controller.rb2
-rw-r--r--app/controllers/users_controller.rb2
-rw-r--r--app/helpers/application_settings_helper.rb10
-rw-r--r--app/helpers/environment_helper.rb29
-rw-r--r--app/helpers/environments_helper.rb7
-rw-r--r--app/helpers/groups_helper.rb4
-rw-r--r--app/helpers/issuables_helper.rb6
-rw-r--r--app/helpers/labels_helper.rb30
-rw-r--r--app/helpers/preferences_helper.rb6
-rw-r--r--app/helpers/projects_helper.rb4
-rw-r--r--app/helpers/services_helper.rb2
-rw-r--r--app/helpers/triggers_helper.rb4
-rw-r--r--app/models/chat_name.rb12
-rw-r--r--app/models/ci/build.rb40
-rw-r--r--app/models/concerns/issuable.rb13
-rw-r--r--app/models/concerns/mentionable.rb2
-rw-r--r--app/models/concerns/select_for_project_authorization.rb9
-rw-r--r--app/models/concerns/subscribable.rb64
-rw-r--r--app/models/cycle_analytics.rb68
-rw-r--r--app/models/environment.rb10
-rw-r--r--app/models/group.rb13
-rw-r--r--app/models/issue.rb2
-rw-r--r--app/models/member.rb13
-rw-r--r--app/models/merge_request.rb2
-rw-r--r--app/models/merge_request/metrics.rb1
-rw-r--r--app/models/merge_request_diff.rb6
-rw-r--r--app/models/project.rb41
-rw-r--r--app/models/project_authorization.rb8
-rw-r--r--app/models/project_feature.rb4
-rw-r--r--app/models/project_group_link.rb7
-rw-r--r--app/models/project_services/chat_service.rb21
-rw-r--r--app/models/project_services/jira_service.rb195
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb56
-rw-r--r--app/models/project_services/slack_service/pipeline_message.rb7
-rw-r--r--app/models/repository.rb419
-rw-r--r--app/models/service.rb4
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/subscription.rb7
-rw-r--r--app/models/tree.rb4
-rw-r--r--app/models/user.rb174
-rw-r--r--app/serializers/analytics_build_entity.rb40
-rw-r--r--app/serializers/analytics_build_serializer.rb3
-rw-r--r--app/serializers/analytics_commit_entity.rb13
-rw-r--r--app/serializers/analytics_commit_serializer.rb3
-rw-r--r--app/serializers/analytics_generic_serializer.rb7
-rw-r--r--app/serializers/analytics_issue_entity.rb29
-rw-r--r--app/serializers/analytics_issue_serializer.rb3
-rw-r--r--app/serializers/analytics_merge_request_entity.rb7
-rw-r--r--app/serializers/analytics_merge_request_serializer.rb3
-rw-r--r--app/serializers/build_entity.rb16
-rw-r--r--app/serializers/commit_entity.rb7
-rw-r--r--app/serializers/deployment_entity.rb4
-rw-r--r--app/serializers/entity_date_helper.rb35
-rw-r--r--app/serializers/environment_entity.rb11
-rw-r--r--app/serializers/issuable_entity.rb16
-rw-r--r--app/serializers/issue_entity.rb9
-rw-r--r--app/serializers/issue_serializer.rb3
-rw-r--r--app/serializers/label_entity.rb11
-rw-r--r--app/serializers/merge_request_entity.rb14
-rw-r--r--app/serializers/merge_request_serializer.rb3
-rw-r--r--app/services/chat_names/authorize_user_service.rb38
-rw-r--r--app/services/chat_names/find_user_service.rb26
-rw-r--r--app/services/destroy_group_service.rb10
-rw-r--r--app/services/git_push_service.rb23
-rw-r--r--app/services/issuable_base_service.rb4
-rw-r--r--app/services/merge_requests/add_todo_when_build_fails_service.rb7
-rw-r--r--app/services/merge_requests/build_service.rb4
-rw-r--r--app/services/merge_requests/refresh_service.rb21
-rw-r--r--app/services/notes/create_service.rb2
-rw-r--r--app/services/notification_service.rb28
-rw-r--r--app/services/projects/create_service.rb2
-rw-r--r--app/services/slash_commands/interpret_service.rb4
-rw-r--r--app/services/user_project_access_changed_service.rb9
-rw-r--r--app/views/admin/application_settings/_form.html.haml5
-rw-r--r--app/views/admin/builds/index.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml2
-rw-r--r--app/views/admin/groups/edit.html.haml2
-rw-r--r--app/views/admin/groups/new.html.haml2
-rw-r--r--app/views/devise/sessions/_new_base.html.haml4
-rw-r--r--app/views/devise/shared/_signup_box.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml7
-rw-r--r--app/views/groups/issues.html.haml39
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml1
-rw-r--r--app/views/layouts/nav/_profile.html.haml4
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml27
-rw-r--r--app/views/profiles/chat_names/index.html.haml30
-rw-r--r--app/views/profiles/chat_names/new.html.haml15
-rw-r--r--app/views/projects/_activity.html.haml2
-rw-r--r--app/views/projects/builds/_header.html.haml2
-rw-r--r--app/views/projects/builds/show.html.haml24
-rw-r--r--app/views/projects/commit/_commit_box.html.haml13
-rw-r--r--app/views/projects/commit/_pipeline.html.haml4
-rw-r--r--app/views/projects/commits/_commit.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_empty_stage.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_no_access.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_overview.html.haml15
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml99
-rw-r--r--app/views/projects/environments/_environment.html.haml35
-rw-r--r--app/views/projects/environments/index.html.haml58
-rw-r--r--app/views/projects/issues/_issues.html.haml3
-rw-r--r--app/views/projects/issues/index.html.haml24
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml3
-rw-r--r--app/views/projects/merge_requests/widget/_open.html.haml6
-rw-r--r--app/views/projects/milestones/show.html.haml17
-rw-r--r--app/views/projects/notes/_note.html.haml7
-rw-r--r--app/views/projects/pipelines/_info.html.haml68
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml51
-rw-r--r--app/views/projects/pipelines/show.html.haml8
-rw-r--r--app/views/projects/refs/logs_tree.js.haml4
-rw-r--r--app/views/shared/_issues.html.haml2
-rw-r--r--app/views/shared/_label.html.haml55
-rw-r--r--app/views/shared/_service_settings.html.haml41
-rw-r--r--app/views/shared/empty_states/_issues.html.haml22
-rw-r--r--app/views/shared/empty_states/icons/_issues.svg1
-rw-r--r--app/views/shared/icons/_delta.svg3
-rw-r--r--app/views/shared/icons/_icon_cycle_analytics_overview.svg81
-rw-r--r--app/views/shared/icons/_icon_lock.svg25
-rw-r--r--app/views/shared/icons/_icon_no_data.svg27
-rw-r--r--app/views/shared/icons/_icon_status_skipped.svg2
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/milestones/_summary.html.haml60
-rw-r--r--app/workers/authorized_projects_worker.rb15
-rw-r--r--app/workers/build_success_worker.rb4
-rw-r--r--app/workers/pipeline_metrics_worker.rb4
-rw-r--r--app/workers/project_cache_worker.rb54
-rw-r--r--changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml4
-rw-r--r--changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml4
-rw-r--r--changelogs/unreleased/22539-display-folders.yml4
-rw-r--r--changelogs/unreleased/23223-group-deletion-race-condition.yml4
-rw-r--r--changelogs/unreleased/23449-cycle-analytics-2-frontend.yml4
-rw-r--r--changelogs/unreleased/23637-title-bar-pipelines.yml4
-rw-r--r--changelogs/unreleased/23990-project-show-error-when-empty-repo.yml4
-rw-r--r--changelogs/unreleased/24070-project-margins.yml4
-rw-r--r--changelogs/unreleased/24072-improve-importing-of-github-pull-requests.yml4
-rw-r--r--changelogs/unreleased/24276-usernames-with-dots.yml4
-rw-r--r--changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml4
-rw-r--r--changelogs/unreleased/24616-mr-shows-the-build-for-this-merge-request-failed-although-builds-still-running.yml4
-rw-r--r--changelogs/unreleased/24627-fix-bad-mr-error-message.yml4
-rw-r--r--changelogs/unreleased/add-chat-names.yml4
-rw-r--r--changelogs/unreleased/assignee-dropdown-autocomplete.yml4
-rw-r--r--changelogs/unreleased/bugfix-html-only-mail.yml4
-rw-r--r--changelogs/unreleased/changelog-update.yml4
-rw-r--r--changelogs/unreleased/chatops-deploy-command.yml4
-rw-r--r--changelogs/unreleased/create-pipeline-endpoint.yml4
-rw-r--r--changelogs/unreleased/dz-fix-500-group-git.yml4
-rw-r--r--changelogs/unreleased/dz-fix-group-name-dot.yml4
-rw-r--r--changelogs/unreleased/feature-cycle-analytics-events.yml4
-rw-r--r--changelogs/unreleased/feature-precalculate-authorized-projects.yml4
-rw-r--r--changelogs/unreleased/feature-send-registry-address-with-build-payload.yml4
-rw-r--r--changelogs/unreleased/feature-subscribe-to-group-level-labels.yml4
-rw-r--r--changelogs/unreleased/fix-Build-timeFor.yml4
-rw-r--r--changelogs/unreleased/fix-admin-ci-table.yml4
-rw-r--r--changelogs/unreleased/fix-cycle-analytics-permissions.yml4
-rw-r--r--changelogs/unreleased/fix-do-not-add-todo-when-build-allowed-to-fail.yml4
-rw-r--r--changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml4
-rw-r--r--changelogs/unreleased/fix-require-build-script-configuration-entry.yml4
-rw-r--r--changelogs/unreleased/fix-singin-redirect-for-fork-new.yml5
-rw-r--r--changelogs/unreleased/fix-slack-pipeline-event.yml4
-rw-r--r--changelogs/unreleased/hide-empty-merge-request-diffs.yml4
-rw-r--r--changelogs/unreleased/issue-24512.yml4
-rw-r--r--changelogs/unreleased/issue_13232.yml4
-rw-r--r--changelogs/unreleased/issue_24303.yml4
-rw-r--r--changelogs/unreleased/issue_5541.yml4
-rw-r--r--changelogs/unreleased/jira_service_simplify.yml4
-rw-r--r--changelogs/unreleased/less-intrusive-system-note.yml4
-rw-r--r--changelogs/unreleased/namespace-validation.yml4
-rw-r--r--changelogs/unreleased/optimize-mr-index.yml4
-rw-r--r--changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml4
-rw-r--r--changelogs/unreleased/rack_attack_logging.yml4
-rw-r--r--changelogs/unreleased/remove-unnecessary-self-from-user-model.yml4
-rw-r--r--changelogs/unreleased/smarter-cache-invalidation.yml4
-rw-r--r--changelogs/unreleased/sort-api-groups.yml4
-rw-r--r--changelogs/unreleased/zj-slash-commands-mattermost.yml4
-rw-r--r--config/application.rb1
-rw-r--r--config/initializers/rack_attack_logging.rb7
-rw-r--r--config/no_todos_messages.yml12
-rw-r--r--config/routes/group.rb16
-rw-r--r--config/routes/profile.rb6
-rw-r--r--config/routes/project.rb12
-rw-r--r--config/sidekiq_queues.yml1
-rw-r--r--db/fixtures/development/04_project.rb1
-rw-r--r--db/fixtures/development/17_cycle_analytics.rb3
-rw-r--r--db/fixtures/support/serialized_transaction.rb9
-rw-r--r--db/migrate/20161010142410_create_project_authorizations.rb15
-rw-r--r--db/migrate/20161017091941_add_authorized_projects_populated_to_users.rb9
-rw-r--r--db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb33
-rw-r--r--db/migrate/20161031171301_add_project_id_to_subscriptions.rb14
-rw-r--r--db/migrate/20161031174110_migrate_subscriptions_project_id.rb44
-rw-r--r--db/migrate/20161031181638_add_unique_index_to_subscriptions.rb18
-rw-r--r--db/migrate/20161113184239_create_user_chat_names_table.rb21
-rw-r--r--db/migrate/20161117114805_remove_undeleted_groups.rb16
-rw-r--r--db/migrate/20161118183841_add_commit_events_to_services.rb15
-rw-r--r--db/schema.rb36
-rw-r--r--doc/api/groups.md9
-rw-r--r--doc/api/pipelines.md46
-rw-r--r--doc/api/projects.md4
-rw-r--r--doc/api/users.md44
-rw-r--r--doc/ci/environments.md20
-rw-r--r--doc/ci/examples/test-scala-application.md41
-rw-r--r--doc/ci/review_apps/index.md2
-rw-r--r--doc/ci/yaml/README.md9
-rw-r--r--doc/development/README.md1
-rw-r--r--doc/development/frontend.md51
-rw-r--r--doc/development/limit_ee_conflicts.md272
-rw-r--r--doc/development/testing.md1
-rw-r--r--doc/development/ux_guide/copy.md73
-rw-r--r--doc/install/requirements.md9
-rw-r--r--doc/workflow/gitlab_flow.md2
-rw-r--r--doc/workflow/todos.md6
-rw-r--r--features/steps/project/issues/issues.rb2
-rw-r--r--lib/api/entities.rb6
-rw-r--r--lib/api/groups.rb4
-rw-r--r--lib/api/helpers.rb4
-rw-r--r--lib/api/issues.rb10
-rw-r--r--lib/api/merge_requests.rb10
-rw-r--r--lib/api/milestones.rb2
-rw-r--r--lib/api/pipelines.rb21
-rw-r--r--lib/api/project_snippets.rb156
-rw-r--r--lib/api/repositories.rb97
-rw-r--r--lib/api/services.rb29
-rw-r--r--lib/api/subscriptions.rb12
-rw-r--r--lib/api/users.rb506
-rw-r--r--lib/ci/api/entities.rb6
-rw-r--r--lib/gitlab/chat_commands/base_command.rb49
-rw-r--r--lib/gitlab/chat_commands/command.rb62
-rw-r--r--lib/gitlab/chat_commands/deploy.rb57
-rw-r--r--lib/gitlab/chat_commands/issue_command.rb17
-rw-r--r--lib/gitlab/chat_commands/issue_create.rb24
-rw-r--r--lib/gitlab/chat_commands/issue_show.rb17
-rw-r--r--lib/gitlab/chat_commands/result.rb5
-rw-r--r--lib/gitlab/chat_name_token.rb45
-rw-r--r--lib/gitlab/ci/build/credentials/base.rb13
-rw-r--r--lib/gitlab/ci/build/credentials/factory.rb27
-rw-r--r--lib/gitlab/ci/build/credentials/registry.rb24
-rw-r--r--lib/gitlab/ci/config.rb41
-rw-r--r--lib/gitlab/ci/config/entry/configurable.rb2
-rw-r--r--lib/gitlab/ci/config/entry/job.rb32
-rw-r--r--lib/gitlab/cycle_analytics/base_event.rb57
-rw-r--r--lib/gitlab/cycle_analytics/code_event.rb28
-rw-r--r--lib/gitlab/cycle_analytics/events.rb38
-rw-r--r--lib/gitlab/cycle_analytics/events_query.rb37
-rw-r--r--lib/gitlab/cycle_analytics/issue_allowed.rb9
-rw-r--r--lib/gitlab/cycle_analytics/issue_event.rb27
-rw-r--r--lib/gitlab/cycle_analytics/merge_request_allowed.rb9
-rw-r--r--lib/gitlab/cycle_analytics/metrics_fetcher.rb60
-rw-r--r--lib/gitlab/cycle_analytics/metrics_tables.rb37
-rw-r--r--lib/gitlab/cycle_analytics/permissions.rb44
-rw-r--r--lib/gitlab/cycle_analytics/plan_event.rb44
-rw-r--r--lib/gitlab/cycle_analytics/production_event.rb26
-rw-r--r--lib/gitlab/cycle_analytics/review_event.rb25
-rw-r--r--lib/gitlab/cycle_analytics/staging_event.rb31
-rw-r--r--lib/gitlab/cycle_analytics/test_event.rb13
-rw-r--r--lib/gitlab/cycle_analytics/updater.rb30
-rw-r--r--lib/gitlab/database.rb7
-rw-r--r--lib/gitlab/database/date_time.rb28
-rw-r--r--lib/gitlab/email/html_parser.rb34
-rw-r--r--lib/gitlab/email/reply_parser.rb19
-rw-r--r--lib/gitlab/file_detector.rb63
-rw-r--r--lib/gitlab/github_import/importer.rb50
-rw-r--r--lib/gitlab/github_import/issue_formatter.rb4
-rw-r--r--lib/gitlab/regex.rb9
-rw-r--r--lib/mattermost/presenter.rb131
-rw-r--r--lib/tasks/gitlab/cleanup.rake23
-rw-r--r--lib/tasks/gitlab/dev.rake5
-rwxr-xr-xscripts/prepare_build.sh9
-rw-r--r--spec/controllers/groups/labels_controller_spec.rb22
-rw-r--r--spec/controllers/projects/boards/issues_controller_spec.rb2
-rw-r--r--spec/controllers/projects/cycle_analytics_controller_spec.rb43
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb33
-rw-r--r--spec/controllers/projects/forks_controller_spec.rb58
-rw-r--r--spec/controllers/projects/labels_controller_spec.rb30
-rw-r--r--spec/controllers/sent_notifications_controller_spec.rb18
-rw-r--r--spec/factories/chat_names.rb16
-rw-r--r--spec/factories/ci/builds.rb6
-rw-r--r--spec/factories/subscriptions.rb7
-rw-r--r--spec/features/admin/admin_groups_spec.rb35
-rw-r--r--spec/features/environment_spec.rb161
-rw-r--r--spec/features/environments_spec.rb303
-rw-r--r--spec/features/issues/filter_issues_spec.rb10
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb35
-rw-r--r--spec/features/issues_spec.rb5
-rw-r--r--spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb (renamed from spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions.rb)0
-rw-r--r--spec/features/merge_requests/create_new_mr_spec.rb7
-rw-r--r--spec/features/merge_requests/deleted_source_branch_spec.rb30
-rw-r--r--spec/features/merge_requests/diff_notes_resolve_spec.rb2
-rw-r--r--spec/features/merge_requests/merge_request_versions_spec.rb7
-rw-r--r--spec/features/merge_requests/merge_when_build_succeeds_spec.rb4
-rw-r--r--spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb (renamed from spec/features/merge_requests/only_allow_merge_if_build_succeeds.rb)45
-rw-r--r--spec/features/merge_requests/toggle_whitespace_changes_spec.rb (renamed from spec/features/merge_requests/toggle_whitespace_changes.rb)0
-rw-r--r--spec/features/profiles/chat_names_spec.rb77
-rw-r--r--spec/features/projects/commits/cherry_pick_spec.rb2
-rw-r--r--spec/features/projects/labels/subscription_spec.rb74
-rw-r--r--spec/features/projects/pipelines_spec.rb3
-rw-r--r--spec/features/unsubscribe_links_spec.rb14
-rw-r--r--spec/features/users_spec.rb19
-rw-r--r--spec/finders/group_projects_finder_spec.rb5
-rw-r--r--spec/fixtures/emails/outlook_html.eml140
-rw-r--r--spec/helpers/application_helper_spec.rb4
-rw-r--r--spec/helpers/gitlab_markdown_helper_spec.rb2
-rw-r--r--spec/helpers/preferences_helper_spec.rb41
-rw-r--r--spec/javascripts/activities_spec.js.es62
-rw-r--r--spec/javascripts/build_spec.js.es615
-rw-r--r--spec/javascripts/environments/environment_actions_spec.js.es637
-rw-r--r--spec/javascripts/environments/environment_external_url_spec.js.es622
-rw-r--r--spec/javascripts/environments/environment_item_spec.js.es6215
-rw-r--r--spec/javascripts/environments/environment_rollback_spec.js.es648
-rw-r--r--spec/javascripts/environments/environment_stop_spec.js.es628
-rw-r--r--spec/javascripts/environments/environments_store_spec.js.es669
-rw-r--r--spec/javascripts/environments/mock_data.js.es6135
-rw-r--r--spec/javascripts/fixtures/build.html.haml5
-rw-r--r--spec/javascripts/fixtures/environments/element.html.haml1
-rw-r--r--spec/javascripts/fixtures/environments/environments.html.haml9
-rw-r--r--spec/javascripts/fixtures/environments/table.html.haml11
-rw-r--r--spec/javascripts/merge_request_widget_spec.js58
-rw-r--r--spec/javascripts/pretty_time_spec.js.es6134
-rw-r--r--spec/javascripts/smart_interval_spec.js.es6159
-rw-r--r--spec/javascripts/subbable_resource_spec.js.es665
-rw-r--r--spec/javascripts/vue_common_components/commit_spec.js.es6126
-rw-r--r--spec/lib/ci/gitlab_ci_yaml_processor_spec.rb4
-rw-r--r--spec/lib/gitlab/chat_commands/command_spec.rb94
-rw-r--r--spec/lib/gitlab/chat_commands/deploy_spec.rb79
-rw-r--r--spec/lib/gitlab/chat_commands/issue_create_spec.rb52
-rw-r--r--spec/lib/gitlab/chat_commands/issue_show_spec.rb40
-rw-r--r--spec/lib/gitlab/chat_name_token_spec.rb37
-rw-r--r--spec/lib/gitlab/ci/build/credentials/factory_spec.rb38
-rw-r--r--spec/lib/gitlab/ci/build/credentials/registry_spec.rb41
-rw-r--r--spec/lib/gitlab/ci/config/entry/global_spec.rb58
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb14
-rw-r--r--spec/lib/gitlab/cycle_analytics/code_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/events_spec.rb326
-rw-r--r--spec/lib/gitlab/cycle_analytics/issue_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/permissions_spec.rb127
-rw-r--r--spec/lib/gitlab/cycle_analytics/plan_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/production_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/review_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/shared_event_spec.rb21
-rw-r--r--spec/lib/gitlab/cycle_analytics/staging_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/test_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/updater_spec.rb25
-rw-r--r--spec/lib/gitlab/email/reply_parser_spec.rb4
-rw-r--r--spec/lib/gitlab/file_detector_spec.rb59
-rw-r--r--spec/lib/gitlab/github_import/importer_spec.rb3
-rw-r--r--spec/lib/gitlab/github_import/issue_formatter_spec.rb10
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml4
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml1
-rw-r--r--spec/lib/gitlab/middleware/go_spec.rb2
-rw-r--r--spec/lib/light_url_builder_spec.rb119
-rw-r--r--spec/mailers/emails/profile_spec.rb2
-rw-r--r--spec/models/broadcast_message_spec.rb2
-rw-r--r--spec/models/build_spec.rb128
-rw-r--r--spec/models/chat_name_spec.rb16
-rw-r--r--spec/models/ci/build_spec.rb6
-rw-r--r--spec/models/concerns/issuable_spec.rb22
-rw-r--r--spec/models/concerns/subscribable_spec.rb117
-rw-r--r--spec/models/environment_spec.rb12
-rw-r--r--spec/models/key_spec.rb2
-rw-r--r--spec/models/member_spec.rb22
-rw-r--r--spec/models/merge_request_spec.rb20
-rw-r--r--spec/models/project_group_link_spec.rb16
-rw-r--r--spec/models/project_services/chat_service_spec.rb15
-rw-r--r--spec/models/project_services/gitlab_issue_tracker_service_spec.rb6
-rw-r--r--spec/models/project_services/jira_service_spec.rb55
-rw-r--r--spec/models/project_services/mattermost_slash_commands_service_spec.rb99
-rw-r--r--spec/models/project_services/slack_service/pipeline_message_spec.rb4
-rw-r--r--spec/models/project_spec.rb14
-rw-r--r--spec/models/repository_spec.rb499
-rw-r--r--spec/models/subscription_spec.rb20
-rw-r--r--spec/models/user_spec.rb77
-rw-r--r--spec/requests/api/branches_spec.rb2
-rw-r--r--spec/requests/api/groups_spec.rb37
-rw-r--r--spec/requests/api/internal_spec.rb4
-rw-r--r--spec/requests/api/issues_spec.rb4
-rw-r--r--spec/requests/api/labels_spec.rb6
-rw-r--r--spec/requests/api/pipelines_spec.rb46
-rw-r--r--spec/requests/api/project_snippets_spec.rb64
-rw-r--r--spec/requests/api/services_spec.rb57
-rw-r--r--spec/requests/api/users_spec.rb76
-rw-r--r--spec/requests/ci/api/builds_spec.rb39
-rw-r--r--spec/requests/projects/cycle_analytics_events_spec.rb140
-rw-r--r--spec/routing/routing_spec.rb18
-rw-r--r--spec/serializers/analytics_build_entity_spec.rb27
-rw-r--r--spec/serializers/analytics_build_serializer_spec.rb22
-rw-r--r--spec/serializers/analytics_generic_entity_spec.rb39
-rw-r--r--spec/serializers/analytics_issue_serializer_spec.rb33
-rw-r--r--spec/serializers/analytics_merge_request_serializer_spec.rb34
-rw-r--r--spec/serializers/build_entity_spec.rb10
-rw-r--r--spec/serializers/commit_entity_spec.rb6
-rw-r--r--spec/serializers/deployment_entity_spec.rb2
-rw-r--r--spec/serializers/entity_date_helper_spec.rb45
-rw-r--r--spec/serializers/environment_entity_spec.rb2
-rw-r--r--spec/serializers/environment_serializer_spec.rb2
-rw-r--r--spec/services/chat_names/authorize_user_service_spec.rb25
-rw-r--r--spec/services/chat_names/find_user_service_spec.rb36
-rw-r--r--spec/services/destroy_group_service_spec.rb40
-rw-r--r--spec/services/git_push_service_spec.rb116
-rw-r--r--spec/services/git_tag_push_service_spec.rb8
-rw-r--r--spec/services/issuable/bulk_update_service_spec.rb6
-rw-r--r--spec/services/issues/update_service_spec.rb2
-rw-r--r--spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb33
-rw-r--r--spec/services/merge_requests/get_urls_service_spec.rb6
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb22
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb10
-rw-r--r--spec/services/merge_requests/update_service_spec.rb2
-rw-r--r--spec/services/notification_service_spec.rb156
-rw-r--r--spec/services/projects/create_service_spec.rb3
-rw-r--r--spec/services/slash_commands/interpret_service_spec.rb4
-rw-r--r--spec/services/system_note_service_spec.rb124
-rw-r--r--spec/spec_helper.rb7
-rw-r--r--spec/support/database_connection_helpers.rb9
-rw-r--r--spec/support/db_cleaner.rb4
-rw-r--r--spec/support/features/issuable_slash_commands_shared_examples.rb10
-rw-r--r--spec/support/jira_service_helper.rb8
-rw-r--r--spec/support/matchers/be_url.rb5
-rw-r--r--spec/views/projects/builds/show.html.haml_spec.rb133
-rw-r--r--spec/workers/authorized_projects_worker_spec.rb22
-rw-r--r--spec/workers/pipeline_metrics_worker_spec.rb18
-rw-r--r--spec/workers/project_cache_worker_spec.rb86
506 files changed, 13514 insertions, 2813 deletions
diff --git a/.eslintrc b/.eslintrc
index fd26215b843..5850c107a02 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -23,7 +23,9 @@
"spyOn": false,
"spyOnEvent": false,
"Turbolinks": false,
- "window": false
+ "window": false,
+ "Vue": false,
+ "Flash": false,
+ "Cookies": false
}
}
-
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 436e9ec6c60..bb94fc41ad1 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -319,12 +319,11 @@ migration paths:
- master@gitlab/gitlabhq
- master@gitlab/gitlab-ee
script:
- - git checkout HEAD .
- - git fetch --tags
- - git checkout v8.5.9
+ - git fetch origin v8.5.9
+ - git checkout -f FETCH_HEAD
- cp config/resque.yml.example config/resque.yml
- sed -i 's/localhost/redis/g' config/resque.yml
- - bundle install --without postgres production --jobs $(nproc) "${FLAGS[@]}" --retry=3
+ - bundle install --without postgres production --jobs $(nproc) ${FLAGS[@]} --retry=3
- rake db:drop db:create db:schema:load db:seed_fu
- git checkout $CI_BUILD_REF
- source scripts/prepare_build.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73f2703a0a4..5b139ac8314 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@ entry.
- Fix sidekiq stats in admin area (blackst0ne)
- Added label description as tooltip to issue board list title
- Created cycle analytics bundle JavaScript file
+- Make the milestone page more responsive (yury-n)
- Hides container registry when repository is disabled
- API: Fix booleans not recognized as such when using the `to_boolean` helper
- Removed delete branch tooltip !6954
@@ -79,6 +80,8 @@ entry.
## 8.13.6 (2016-11-17)
- Omniauth auto link LDAP user falls back to find by DN when user cannot be found by UID. !7002
+- Fix Milestone dropdown not stay selected for `Upcoming` and `No Milestone` option. !7117
+- Fix relative links in Markdown wiki when displayed in "Project" tab. !7218
- Fix no "Register" tab if ldap auth is enabled (#24038). !7274 (Luc Didry)
- Fix cache for commit status in commits list to respect branches. !7372
- Fix issue causing Labels not to appear in sidebar on MR page. !7416 (Alex Sanford)
@@ -114,7 +117,6 @@ entry.
- Removes any symlinks before importing a project export file. CVE-2016-9086
- Fixed Import/Export foreign key issue to do with project members.
-- Fix relative links in Markdown wiki when displayed in "Project" tab !7218
- Changed build dropdown list length to be 6,5 builds long in the pipeline graph
## 8.13.2 (2016-10-31)
diff --git a/Gemfile b/Gemfile
index f2291568d25..9e815925a1f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -330,13 +330,10 @@ gem 'octokit', '~> 4.3.0'
gem 'mail_room', '~> 0.9.0'
gem 'email_reply_parser', '~> 0.5.8'
+gem 'html2text'
gem 'ruby-prof', '~> 0.16.2'
-## CI
-gem 'activerecord-session_store', '~> 1.0.0'
-gem 'nested_form', '~> 0.3.2'
-
# OAuth
gem 'oauth2', '~> 1.2.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 81b43f2238a..bdc60552480 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -32,12 +32,6 @@ GEM
activemodel (= 4.2.7.1)
activesupport (= 4.2.7.1)
arel (~> 6.0)
- activerecord-session_store (1.0.0)
- actionpack (>= 4.0, < 5.1)
- activerecord (>= 4.0, < 5.1)
- multi_json (~> 1.11, >= 1.11.2)
- rack (>= 1.5.2, < 3)
- railties (>= 4.0, < 5.1)
activerecord_sane_schema_dumper (0.2)
rails (>= 4, < 5)
activesupport (4.2.7.1)
@@ -345,6 +339,8 @@ GEM
html-pipeline (1.11.0)
activesupport (>= 2)
nokogiri (~> 1.4)
+ html2text (0.2.0)
+ nokogiri (~> 1.6)
htmlentities (4.3.4)
httparty (0.13.7)
json (~> 1.8)
@@ -416,7 +412,6 @@ GEM
multi_xml (0.5.5)
multipart-post (2.0.0)
mysql2 (0.3.20)
- nested_form (0.3.2)
net-ldap (0.12.1)
net-ssh (3.0.1)
newrelic_rpm (3.16.0.318)
@@ -598,7 +593,7 @@ GEM
railties (>= 4.2.0, < 5.1)
rinku (2.0.0)
rotp (2.1.2)
- rouge (2.0.6)
+ rouge (2.0.7)
rqrcode (0.7.0)
chunky_png
rqrcode-rails3 (0.1.7)
@@ -809,7 +804,6 @@ PLATFORMS
DEPENDENCIES
RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.1.0)
- activerecord-session_store (~> 1.0.0)
activerecord_sane_schema_dumper (= 0.2)
acts-as-taggable-on (~> 4.0)
addressable (~> 2.3.8)
@@ -881,6 +875,7 @@ DEPENDENCIES
health_check (~> 2.2.0)
hipchat (~> 1.5.0)
html-pipeline (~> 1.11.0)
+ html2text
httparty (~> 0.13.3)
influxdb (~> 0.2)
jira-ruby (~> 1.1.2)
@@ -901,7 +896,6 @@ DEPENDENCIES
minitest (~> 5.7.0)
mousetrap-rails (~> 1.4.6)
mysql2 (~> 0.3.16)
- nested_form (~> 0.3.2)
net-ssh (~> 3.0.1)
newrelic_rpm (~> 3.16)
nokogiri (~> 1.6.7, >= 1.6.7.2)
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
deleted file mode 100644
index 906a1a69d93..00000000000
--- a/app/assets/javascripts/activities.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-undef, quotes, no-var, padded-blocks, max-len */
-(function() {
- this.Activities = (function() {
- function Activities() {
- Pager.init(20, true, false, this.updateTooltips);
- $(".event-filter-link").on("click", (function(_this) {
- return function(event) {
- event.preventDefault();
- _this.toggleFilter($(event.currentTarget));
- return _this.reloadActivities();
- };
- })(this));
- }
-
- Activities.prototype.updateTooltips = function() {
- gl.utils.localTimeAgo($('.js-timeago', '.content_list'));
- };
-
- Activities.prototype.reloadActivities = function() {
- $(".content_list").html('');
- Pager.init(20, true, false, this.updateTooltips);
- };
-
- Activities.prototype.toggleFilter = function(sender) {
- var filter = sender.attr("id").split("_")[0];
-
- $('.event-filter .active').removeClass("active");
- Cookies.set("event_filter", filter);
-
- sender.closest('li').toggleClass("active");
- };
-
- return Activities;
-
- })();
-
-}).call(this);
diff --git a/app/assets/javascripts/activities.js.es6 b/app/assets/javascripts/activities.js.es6
new file mode 100644
index 00000000000..19bcfef89fb
--- /dev/null
+++ b/app/assets/javascripts/activities.js.es6
@@ -0,0 +1,36 @@
+/* eslint-disable no-param-reassign, class-methods-use-this */
+/* global Pager, Cookies */
+
+((global) => {
+ class Activities {
+ constructor() {
+ Pager.init(20, true, false, this.updateTooltips);
+ $('.event-filter-link').on('click', (e) => {
+ e.preventDefault();
+ this.toggleFilter(e.currentTarget);
+ this.reloadActivities();
+ });
+ }
+
+ updateTooltips() {
+ gl.utils.localTimeAgo($('.js-timeago', '.content_list'));
+ }
+
+ reloadActivities() {
+ $('.content_list').html('');
+ Pager.init(20, true, false, this.updateTooltips);
+ }
+
+ toggleFilter(sender) {
+ const $sender = $(sender);
+ const filter = $sender.attr('id').split('_')[0];
+
+ $('.event-filter .active').removeClass('active');
+ Cookies.set('event_filter', filter);
+
+ $sender.closest('li').toggleClass('active');
+ }
+ }
+
+ global.Activities = Activities;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index 68012e8cf42..e198306e67a 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -172,7 +172,7 @@
$date = $('.js-artifacts-remove');
if ($date.length) {
date = $date.text();
- return $date.text(gl.utils.timefor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '));
+ return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '));
}
};
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6
new file mode 100644
index 00000000000..520cee7738b
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6
@@ -0,0 +1,43 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageCodeComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="mergeRequest in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <h5 class="item-title merge-merquest-title">
+ <a :href="mergeRequest.url">
+ {{ mergeRequest.title }}
+ </a>
+ </h5>
+ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="mergeRequest.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6
new file mode 100644
index 00000000000..3bb01c67206
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6
@@ -0,0 +1,45 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageIssueComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="issue in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="issue.author.avatarUrl">
+ <h5 class="item-title issue-title">
+ <a class="issue-title" :href="issue.url">
+ {{ issue.title }}
+ </a>
+ </h5>
+ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="issue.author.webUrl" class="issue-author-link">
+ {{ issue.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="issue.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6
new file mode 100644
index 00000000000..b568ab62a69
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6
@@ -0,0 +1,42 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StagePlanComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="commit in items" class="stage-event-item">
+ <div class="item-details item-conmmit-component">
+ <img class="avatar" :src="commit.author.avatarUrl">
+ <h5 class="item-title commit-title">
+ <a :href="commit.commitUrl">
+ {{ commit.title }}
+ </a>
+ </h5>
+ <span>
+ First
+ <span class="commit-icon">${global.cycleAnalytics.svgs.iconCommit}</span>
+ <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
+ pushed by
+ <a :href="commit.author.webUrl" class="commit-author-link">
+ {{ commit.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="commit.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6
new file mode 100644
index 00000000000..a6b6d817a82
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6
@@ -0,0 +1,45 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageProductionComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="issue in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="issue.author.avatarUrl">
+ <h5 class="item-title issue-title">
+ <a class="issue-title" :href="issue.url">
+ {{ issue.title }}
+ </a>
+ </h5>
+ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="issue.author.webUrl" class="issue-author-link">
+ {{ issue.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="issue.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6
new file mode 100644
index 00000000000..9e819c1d420
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6
@@ -0,0 +1,55 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageReviewComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="mergeRequest in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <h5 class="item-title merge-merquest-title">
+ <a :href="mergeRequest.url">
+ {{ mergeRequest.title }}
+ </a>
+ </h5>
+ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
+ </span>
+ <template v-if="mergeRequest.state === 'closed'">
+ <span class="merge-request-state">
+ <i class="fa fa-ban"></i>
+ {{ mergeRequest.state.toUpperCase() }}
+ </span>
+ </template>
+ <template v-else>
+ <span class="merge-request-branch" v-if="mergeRequest.branch">
+ <i class= "fa fa-code-fork"></i>
+ <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a>
+ </span>
+ </template>
+ </div>
+ <div class="item-time">
+ <total-time :time="mergeRequest.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6
new file mode 100644
index 00000000000..b30c3a31010
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6
@@ -0,0 +1,42 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageStagingComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="build in items" class="stage-event-item item-build-component">
+ <div class="item-details">
+ <img class="avatar" :src="build.author.avatarUrl">
+ <h5 class="item-title">
+ <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
+ <i class="fa fa-code-fork"></i>
+ <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <span class="icon-branch">${global.cycleAnalytics.svgs.iconBranch}</span>
+ <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ </h5>
+ <span>
+ <a :href="build.url" class="build-date">{{ build.date }}</a>
+ by
+ <a :href="build.author.webUrl" class="issue-author-link">
+ {{ build.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="build.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6
new file mode 100644
index 00000000000..c54d6b6ee37
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6
@@ -0,0 +1,42 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageTestComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="build in items" class="stage-event-item item-build-component">
+ <div class="item-details">
+ <h5 class="item-title">
+ <span class="icon-build-status">${global.cycleAnalytics.svgs.iconBuildStatus}</span>
+ <a :href="build.url" class="item-build-name">{{ build.name }}</a>
+ &middot;
+ <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
+ <i class="fa fa-code-fork"></i>
+ <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <span class="icon-branch">${global.cycleAnalytics.svgs.iconBranch}</span>
+ <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ </h5>
+ <span>
+ <a :href="build.url" class="issue-date">
+ {{ build.date }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="build.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6
new file mode 100644
index 00000000000..8403fbeaab5
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6
@@ -0,0 +1,18 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.TotalTimeComponent = Vue.extend({
+ props: {
+ time: Object,
+ },
+ template: `
+ <span class="total-time">
+ <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template>
+ <template v-if="time.hours">{{ time.hours }} <span>hr</span></template>
+ <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template>
+ <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template>
+ </span>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6
index 331f0209888..f1ddd139c48 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6
@@ -1,98 +1,121 @@
-/* eslint-disable */
//= require vue
-
-((global) => {
-
- const COOKIE_NAME = 'cycle_analytics_help_dismissed';
- const store = gl.cycleAnalyticsStore = {
- isLoading: true,
- hasError: false,
- isHelpDismissed: Cookies.get(COOKIE_NAME),
- analytics: {}
- };
-
- gl.CycleAnalytics = class CycleAnalytics {
- constructor() {
- const that = this;
-
- this.vue = new Vue({
- el: '#cycle-analytics',
- name: 'CycleAnalytics',
- created: this.fetchData(),
- data: store,
- methods: {
- dismissLanding() {
- that.dismissLanding();
- }
+//= require_tree ./svg
+//= require_tree .
+
+$(() => {
+ const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
+ const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
+ const cycleAnalyticsStore = gl.cycleAnalytics.CycleAnalyticsStore;
+ const cycleAnalyticsService = new gl.cycleAnalytics.CycleAnalyticsService({
+ requestPath: cycleAnalyticsEl.dataset.requestPath,
+ });
+
+ gl.cycleAnalyticsApp = new Vue({
+ el: '#cycle-analytics',
+ name: 'CycleAnalytics',
+ data: {
+ state: cycleAnalyticsStore.state,
+ isLoading: false,
+ isLoadingStage: false,
+ isEmptyStage: false,
+ hasError: false,
+ startDate: 30,
+ isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ },
+ computed: {
+ currentStage() {
+ return cycleAnalyticsStore.currentActiveStage();
+ },
+ },
+ components: {
+ 'stage-issue-component': gl.cycleAnalytics.StageIssueComponent,
+ 'stage-plan-component': gl.cycleAnalytics.StagePlanComponent,
+ 'stage-code-component': gl.cycleAnalytics.StageCodeComponent,
+ 'stage-test-component': gl.cycleAnalytics.StageTestComponent,
+ 'stage-review-component': gl.cycleAnalytics.StageReviewComponent,
+ 'stage-staging-component': gl.cycleAnalytics.StageStagingComponent,
+ 'stage-production-component': gl.cycleAnalytics.StageProductionComponent,
+ },
+ created() {
+ this.fetchCycleAnalyticsData();
+ },
+ methods: {
+ handleError() {
+ cycleAnalyticsStore.setErrorState(true);
+ return new Flash('There was an error while fetching cycle analytics data.');
+ },
+ initDropdown() {
+ const $dropdown = $('.js-ca-dropdown');
+ const $label = $dropdown.find('.dropdown-label');
+
+ $dropdown.find('li a').off('click').on('click', (e) => {
+ e.preventDefault();
+ const $target = $(e.currentTarget);
+ this.startDate = $target.data('value');
+
+ $label.text($target.text().trim());
+ this.fetchCycleAnalyticsData({ startDate: this.startDate });
+ });
+ },
+ fetchCycleAnalyticsData(options) {
+ const fetchOptions = options || { startDate: this.startDate };
+
+ this.isLoading = true;
+
+ cycleAnalyticsService
+ .fetchCycleAnalyticsData(fetchOptions)
+ .done((response) => {
+ cycleAnalyticsStore.setCycleAnalyticsData(response);
+ this.selectDefaultStage();
+ this.initDropdown();
+ })
+ .error(() => {
+ this.handleError();
+ })
+ .always(() => {
+ this.isLoading = false;
+ });
+ },
+ selectDefaultStage() {
+ const stage = this.state.stages.first();
+ this.selectStage(stage);
+ },
+ selectStage(stage) {
+ if (this.isLoadingStage) return;
+ if (this.currentStage === stage) return;
+
+ if (!stage.isUserAllowed) {
+ cycleAnalyticsStore.setActiveStage(stage);
+ return;
}
- });
- }
-
- fetchData(options) {
- store.isLoading = true;
- options = options || { startDate: 30 };
-
- $.ajax({
- url: $('#cycle-analytics').data('request-path'),
- method: 'GET',
- dataType: 'json',
- contentType: 'application/json',
- data: {
- cycle_analytics: {
- start_date: options.startDate
- }
- }
- }).done((data) => {
- this.decorateData(data);
- this.initDropdown();
- })
- .error((data) => {
- this.handleError(data);
- })
- .always(() => {
- store.isLoading = false;
- })
- }
-
- decorateData(data) {
- data.summary = data.summary || [];
- data.stats = data.stats || [];
-
- data.summary.forEach((item) => {
- item.value = item.value || '-';
- });
-
- data.stats.forEach((item) => {
- item.value = item.value || '- - -';
- });
-
- store.analytics = data;
- }
-
- handleError(data) {
- store.hasError = true;
- new Flash('There was an error while fetching cycle analytics data.', 'alert');
- }
-
- dismissLanding() {
- store.isHelpDismissed = true;
- Cookies.set(COOKIE_NAME, true);
- }
-
- initDropdown() {
- const $dropdown = $('.js-ca-dropdown');
- const $label = $dropdown.find('.dropdown-label');
-
- $dropdown.find('li a').off('click').on('click', (e) => {
- e.preventDefault();
- const $target = $(e.currentTarget);
- const value = $target.data('value');
-
- $label.text($target.text().trim());
- this.fetchData({ startDate: value });
- })
- }
-
- }
-})(window.gl || (window.gl = {}));
+ this.isLoadingStage = true;
+ cycleAnalyticsStore.setStageEvents([]);
+ cycleAnalyticsStore.setActiveStage(stage);
+
+ cycleAnalyticsService
+ .fetchStageData({
+ stage,
+ startDate: this.startDate,
+ })
+ .done((response) => {
+ this.isEmptyStage = !response.events.length;
+ cycleAnalyticsStore.setStageEvents(response.events);
+ })
+ .error(() => {
+ this.isEmptyStage = true;
+ })
+ .always(() => {
+ this.isLoadingStage = false;
+ });
+ },
+ dismissOverviewDialog() {
+ this.isOverviewDialogDismissed = true;
+ Cookies.set(OVERVIEW_DIALOG_COOKIE, '1');
+ },
+ },
+ });
+
+ // Register global components
+ Vue.component('total-time', gl.cycleAnalytics.TotalTimeComponent);
+});
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6
new file mode 100644
index 00000000000..9f74b14c4b9
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6
@@ -0,0 +1,41 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ class CycleAnalyticsService {
+ constructor(options) {
+ this.requestPath = options.requestPath;
+ }
+
+ fetchCycleAnalyticsData(options) {
+ options = options || { startDate: 30 };
+
+ return $.ajax({
+ url: this.requestPath,
+ method: 'GET',
+ dataType: 'json',
+ contentType: 'application/json',
+ data: {
+ cycle_analytics: {
+ start_date: options.startDate,
+ },
+ },
+ });
+ }
+
+ fetchStageData(options) {
+ const {
+ stage,
+ startDate,
+ } = options;
+
+ return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, {
+ cycle_analytics: {
+ start_date: startDate,
+ },
+ });
+ }
+ }
+
+ global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6
new file mode 100644
index 00000000000..9b905874167
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6
@@ -0,0 +1,90 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ const EMPTY_STAGE_TEXTS = {
+ issue: '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.',
+ plan: '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.',
+ code: '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.',
+ test: '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.',
+ review: '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.',
+ staging: '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.',
+ production: '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.',
+ };
+
+ global.cycleAnalytics.CycleAnalyticsStore = {
+ state: {
+ summary: '',
+ stats: '',
+ analytics: '',
+ events: [],
+ stages: [],
+ },
+ setCycleAnalyticsData(data) {
+ this.state = Object.assign(this.state, this.decorateData(data));
+ },
+ decorateData(data) {
+ const newData = {};
+
+ newData.stages = data.stats || [];
+ newData.summary = data.summary || [];
+
+ newData.summary.forEach((item) => {
+ item.value = item.value || '-';
+ });
+
+ newData.stages.forEach((item) => {
+ const stageName = item.title.toLowerCase();
+ item.active = false;
+ item.isUserAllowed = data.permissions[stageName];
+ item.emptyStageText = EMPTY_STAGE_TEXTS[stageName];
+ item.component = `stage-${stageName}-component`;
+ });
+ newData.analytics = data;
+ return newData;
+ },
+ setLoadingState(state) {
+ this.state.isLoading = state;
+ },
+ setErrorState(state) {
+ this.state.hasError = state;
+ },
+ deactivateAllStages() {
+ this.state.stages.forEach((stage) => {
+ stage.active = false;
+ });
+ },
+ setActiveStage(stage) {
+ this.deactivateAllStages();
+ stage.active = true;
+ },
+ setStageEvents(events) {
+ this.state.events = this.decorateEvents(events);
+ },
+ decorateEvents(events) {
+ const newEvents = events;
+
+ newEvents.forEach((item) => {
+ item.totalTime = item.total_time;
+ item.author.webUrl = item.author.web_url;
+ item.author.avatarUrl = item.author.avatar_url;
+
+ if (item.created_at) item.createdAt = item.created_at;
+ if (item.short_sha) item.shortSha = item.short_sha;
+ if (item.commit_url) item.commitUrl = item.commit_url;
+
+ delete item.author.web_url;
+ delete item.author.avatar_url;
+ delete item.total_time;
+ delete item.created_at;
+ delete item.short_sha;
+ delete item.commit_url;
+ });
+
+ return newEvents;
+ },
+ currentActiveStage() {
+ return this.state.stages.find(stage => stage.active);
+ },
+ };
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es6 b/app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es6
new file mode 100644
index 00000000000..5d486bcaf66
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es6
@@ -0,0 +1,7 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+ global.cycleAnalytics.svgs = global.cycleAnalytics.svgs || {};
+
+ global.cycleAnalytics.svgs.iconBranch = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path fill="#8C8C8C" fill-rule="evenodd" d="M9.678 6.722C9.353 5.167 8.053 4 6.5 4S3.647 5.167 3.322 6.722h-2.6c-.397 0-.722.35-.722.778 0 .428.325.778.722.778h2.6C3.647 9.833 4.947 11 6.5 11s2.853-1.167 3.178-2.722h2.6c.397 0 .722-.35.722-.778 0-.428-.325-.778-.722-.778h-2.6zM4.694 7.5c0-1.09.795-1.944 1.806-1.944 1.01 0 1.806.855 1.806 1.944 0 1.09-.795 1.944-1.806 1.944-1.01 0-1.806-.855-1.806-1.944z"/></svg>';
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es6 b/app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es6
new file mode 100644
index 00000000000..661bf9e9f1c
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es6
@@ -0,0 +1,7 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+ global.cycleAnalytics.svgs = global.cycleAnalytics.svgs || {};
+
+ global.cycleAnalytics.svgs.iconBuildStatus = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><g fill="#31AF64" fill-rule="evenodd"><path d="M12.5 7c0-3.038-2.462-5.5-5.5-5.5S1.5 3.962 1.5 7s2.462 5.5 5.5 5.5 5.5-2.462 5.5-5.5zM0 7c0-3.866 3.134-7 7-7s7 3.134 7 7-3.134 7-7 7-7-3.134-7-7z"/><path d="M6.28 7.697L5.045 6.464c-.117-.117-.305-.117-.42-.002l-.614.614c-.11.113-.11.303.007.42l1.91 1.91c.19.19.51.197.703.004l.264-.265L9.997 6.04c.108-.107.107-.293-.01-.408l-.612-.614c-.114-.113-.298-.12-.41-.01L6.28 7.7z"/></g></svg>';
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es6 b/app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es6
new file mode 100644
index 00000000000..2208c27a619
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es6
@@ -0,0 +1,7 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+ global.cycleAnalytics.svgs = global.cycleAnalytics.svgs || {};
+
+ global.cycleAnalytics.svgs.iconCommit = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path fill="#8F8F8F" fill-rule="evenodd" d="M28.777 18c-.91-4.008-4.494-7-8.777-7-4.283 0-7.868 2.992-8.777 7H4.01C2.9 18 2 18.895 2 20c0 1.112.9 2 2.01 2h7.213c.91 4.008 4.494 7 8.777 7 4.283 0 7.868-2.992 8.777-7h7.214C37.1 22 38 21.105 38 20c0-1.112-.9-2-2.01-2h-7.213zM20 25c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5z"/></svg>';
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6
index 756a24cc0fc..c2d4670b7e9 100644
--- a/app/assets/javascripts/dispatcher.js.es6
+++ b/app/assets/javascripts/dispatcher.js.es6
@@ -110,10 +110,10 @@
Issuable.init();
break;
case 'dashboard:activity':
- new Activities();
+ new gl.Activities();
break;
case 'dashboard:projects:starred':
- new Activities();
+ new gl.Activities();
break;
case 'projects:commit:show':
new Commit();
@@ -139,7 +139,7 @@
new gl.Pipelines();
break;
case 'groups:activity':
- new Activities();
+ new gl.Activities();
break;
case 'groups:show':
shortcut_handler = new ShortcutsNavigation();
@@ -208,9 +208,6 @@
new gl.ProtectedBranchCreate();
new gl.ProtectedBranchEditList();
break;
- case 'projects:cycle_analytics:show':
- new gl.CycleAnalytics();
- break;
}
switch (path.first()) {
case 'admin':
diff --git a/app/assets/javascripts/environments/components/environment.js.es6 b/app/assets/javascripts/environments/components/environment.js.es6
new file mode 100644
index 00000000000..1043e516483
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment.js.es6
@@ -0,0 +1,248 @@
+//= require vue
+//= require vue-resource
+//= require_tree ../services/
+//= require ./environment_item
+
+/* globals Vue, EnvironmentsService */
+/* eslint-disable no-param-reassign */
+
+(() => { // eslint-disable-line
+ window.gl = window.gl || {};
+
+ /**
+ * Given the visibility prop provided by the url query parameter and which
+ * changes according to the active tab we need to filter which environments
+ * should be visible.
+ *
+ * The environments array is a recursive tree structure and we need to filter
+ * both root level environments and children environments.
+ *
+ * In order to acomplish that, both `filterState` and `filterEnvironmnetsByState`
+ * functions work together.
+ * The first one works as the filter that verifies if the given environment matches
+ * the given state.
+ * The second guarantees both root level and children elements are filtered as well.
+ */
+
+ const filterState = state => environment => environment.state === state && environment;
+ /**
+ * Given the filter function and the array of environments will return only
+ * the environments that match the state provided to the filter function.
+ *
+ * @param {Function} fn
+ * @param {Array} array
+ * @return {Array}
+ */
+ const filterEnvironmnetsByState = (fn, arr) => arr.map((item) => {
+ if (item.children) {
+ const filteredChildren = filterEnvironmnetsByState(fn, item.children).filter(Boolean);
+ if (filteredChildren.length) {
+ item.children = filteredChildren;
+ return item;
+ }
+ }
+ return fn(item);
+ }).filter(Boolean);
+
+ window.gl.environmentsList.EnvironmentsComponent = Vue.component('environment-component', {
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+
+ components: {
+ 'environment-item': window.gl.environmentsList.EnvironmentItem,
+ },
+
+ data() {
+ const environmentsData = document.querySelector('#environments-list-view').dataset;
+
+ return {
+ state: this.store.state,
+ visibility: 'available',
+ isLoading: false,
+ cssContainerClass: environmentsData.cssClass,
+ endpoint: environmentsData.environmentsDataEndpoint,
+ canCreateDeployment: environmentsData.canCreateDeployment,
+ canReadEnvironment: environmentsData.canReadEnvironment,
+ canCreateEnvironment: environmentsData.canCreateEnvironment,
+ projectEnvironmentsPath: environmentsData.projectEnvironmentsPath,
+ projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
+ newEnvironmentPath: environmentsData.newEnvironmentPath,
+ helpPagePath: environmentsData.helpPagePath,
+ };
+ },
+
+ computed: {
+ filteredEnvironments() {
+ return filterEnvironmnetsByState(filterState(this.visibility), this.state.environments);
+ },
+
+ scope() {
+ return this.$options.getQueryParameter('scope');
+ },
+
+ canReadEnvironmentParsed() {
+ return this.$options.convertPermissionToBoolean(this.canReadEnvironment);
+ },
+
+ canCreateDeploymentParsed() {
+ return this.$options.convertPermissionToBoolean(this.canCreateDeployment);
+ },
+
+ canCreateEnvironmentParsed() {
+ return this.$options.convertPermissionToBoolean(this.canCreateEnvironment);
+ },
+ },
+
+ /**
+ * Fetches all the environmnets and stores them.
+ * Toggles loading property.
+ */
+ created() {
+ gl.environmentsService = new EnvironmentsService(this.endpoint);
+
+ const scope = this.$options.getQueryParameter('scope');
+ if (scope) {
+ this.visibility = scope;
+ }
+
+ this.isLoading = true;
+
+ return gl.environmentsService.all()
+ .then(resp => resp.json())
+ .then((json) => {
+ this.store.storeEnvironments(json);
+ this.isLoading = false;
+ });
+ },
+
+ /**
+ * Transforms the url parameter into an object and
+ * returns the one requested.
+ *
+ * @param {String} param
+ * @returns {String} The value of the requested parameter.
+ */
+ getQueryParameter(parameter) {
+ return window.location.search.substring(1).split('&').reduce((acc, param) => {
+ const paramSplited = param.split('=');
+ acc[paramSplited[0]] = paramSplited[1];
+ return acc;
+ }, {})[parameter];
+ },
+
+ /**
+ * Converts permission provided as strings to booleans.
+ * @param {String} string
+ * @returns {Boolean}
+ */
+ convertPermissionToBoolean(string) {
+ return string === 'true';
+ },
+
+ methods: {
+ toggleRow(model) {
+ return this.store.toggleFolder(model.name);
+ },
+ },
+
+ template: `
+ <div :class="cssContainerClass">
+ <div class="top-area">
+ <ul v-if="!isLoading" class="nav-links">
+ <li v-bind:class="{ 'active': scope === undefined }">
+ <a :href="projectEnvironmentsPath">
+ Available
+ <span class="badge js-available-environments-count">
+ {{state.availableCounter}}
+ </span>
+ </a>
+ </li>
+ <li v-bind:class="{ 'active' : scope === 'stopped' }">
+ <a :href="projectStoppedEnvironmentsPath">
+ Stopped
+ <span class="badge js-stopped-environments-count">
+ {{state.stoppedCounter}}
+ </span>
+ </a>
+ </li>
+ </ul>
+ <div v-if="canCreateEnvironmentParsed && !isLoading" class="nav-controls">
+ <a :href="newEnvironmentPath" class="btn btn-create">
+ New environment
+ </a>
+ </div>
+ </div>
+
+ <div class="environments-container">
+ <div class="environments-list-loading text-center" v-if="isLoading">
+ <i class="fa fa-spinner spin"></i>
+ </div>
+
+ <div class="blank-state blank-state-no-icon"
+ v-if="!isLoading && state.environments.length === 0">
+ <h2 class="blank-state-title">
+ You don't have any environments right now.
+ </h2>
+ <p class="blank-state-text">
+ Environments are places where code gets deployed, such as staging or production.
+ <br />
+ <a :href="helpPagePath">
+ Read more about environments
+ </a>
+ </p>
+
+ <a
+ v-if="canCreateEnvironmentParsed"
+ :href="newEnvironmentPath"
+ class="btn btn-create">
+ New Environment
+ </a>
+ </div>
+
+ <div class="table-holder"
+ v-if="!isLoading && state.environments.length > 0">
+ <table class="table ci-table environments">
+ <thead>
+ <tr>
+ <th>Environment</th>
+ <th>Last deployment</th>
+ <th>Build</th>
+ <th>Commit</th>
+ <th></th>
+ <th class="hidden-xs"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <template v-for="model in filteredEnvironments"
+ v-bind:model="model">
+
+ <tr
+ is="environment-item"
+ :model="model"
+ :toggleRow="toggleRow.bind(model)"
+ :can-create-deployment="canCreateDeploymentParsed"
+ :can-read-environment="canReadEnvironmentParsed"></tr>
+
+ <tr v-if="model.isOpen && model.children && model.children.length > 0"
+ is="environment-item"
+ v-for="children in model.children"
+ :model="children"
+ :toggleRow="toggleRow.bind(children)"
+ :can-create-deployment="canCreateDeploymentParsed"
+ :can-read-environment="canReadEnvironmentParsed">
+ </tr>
+
+ </template>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/environments/components/environment_actions.js.es6 b/app/assets/javascripts/environments/components/environment_actions.js.es6
new file mode 100644
index 00000000000..d149a446e0b
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment_actions.js.es6
@@ -0,0 +1,67 @@
+/*= require vue */
+/* global Vue */
+
+(() => {
+ window.gl = window.gl || {};
+ window.gl.environmentsList = window.gl.environmentsList || {};
+
+ window.gl.environmentsList.ActionsComponent = Vue.component('actions-component', {
+ props: {
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+
+ /**
+ * Appends the svg icon that were render in the index page.
+ * In order to reuse the svg instead of copy and paste in this template
+ * we need to render it outside this component using =custom_icon partial.
+ *
+ * TODO: Remove this when webpack is merged.
+ *
+ */
+ mounted() {
+ const playIcon = document.querySelector('.play-icon-svg.hidden svg');
+
+ const dropdownContainer = this.$el.querySelector('.dropdown-play-icon-container');
+ const actionContainers = this.$el.querySelectorAll('.action-play-icon-container');
+ // Phantomjs does not have support to iterate a nodelist.
+ const actionsArray = [].slice.call(actionContainers);
+
+ if (playIcon && actionsArray && dropdownContainer) {
+ dropdownContainer.appendChild(playIcon.cloneNode(true));
+
+ actionsArray.forEach((element) => {
+ element.appendChild(playIcon.cloneNode(true));
+ });
+ }
+ },
+
+ template: `
+ <div class="inline">
+ <div class="dropdown">
+ <a class="dropdown-new btn btn-default" data-toggle="dropdown">
+ <span class="dropdown-play-icon-container"></span>
+ <i class="fa fa-caret-down"></i>
+ </a>
+
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li v-for="action in actions">
+ <a :href="action.play_path"
+ data-method="post"
+ rel="nofollow"
+ class="js-manual-action-link">
+ <span class="action-play-icon-container"></span>
+ <span>
+ {{action.name}}
+ </span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/environments/components/environment_external_url.js.es6 b/app/assets/javascripts/environments/components/environment_external_url.js.es6
new file mode 100644
index 00000000000..79cd5ded5bd
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment_external_url.js.es6
@@ -0,0 +1,22 @@
+/*= require vue */
+/* global Vue */
+
+(() => {
+ window.gl = window.gl || {};
+ window.gl.environmentsList = window.gl.environmentsList || {};
+
+ window.gl.environmentsList.ExternalUrlComponent = Vue.component('external-url-component', {
+ props: {
+ external_url: {
+ type: String,
+ default: '',
+ },
+ },
+
+ template: `
+ <a class="btn external_url" :href="external_url" target="_blank">
+ <i class="fa fa-external-link"></i>
+ </a>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6
new file mode 100644
index 00000000000..da7db5c05bd
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment_item.js.es6
@@ -0,0 +1,496 @@
+/*= require lib/utils/timeago */
+/*= require lib/utils/text_utility */
+/*= require vue_common_component/commit */
+/*= require ./environment_actions */
+/*= require ./environment_external_url */
+/*= require ./environment_stop */
+/*= require ./environment_rollback */
+
+/* globals Vue, timeago */
+
+(() => {
+ /**
+ * Envrionment Item Component
+ *
+ * Used in a hierarchical structure to show folders with children
+ * in a table.
+ * Recursive component based on [Tree View](https://vuejs.org/examples/tree-view.html)
+ *
+ * See this [issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/22539)
+ * for more information.15
+ */
+
+ window.gl = window.gl || {};
+ window.gl.environmentsList = window.gl.environmentsList || {};
+
+ gl.environmentsList.EnvironmentItem = Vue.component('environment-item', {
+
+ components: {
+ 'commit-component': window.gl.CommitComponent,
+ 'actions-component': window.gl.environmentsList.ActionsComponent,
+ 'external-url-component': window.gl.environmentsList.ExternalUrlComponent,
+ 'stop-component': window.gl.environmentsList.StopComponent,
+ 'rollback-component': window.gl.environmentsList.RollbackComponent,
+ },
+
+ props: {
+ model: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+
+ toggleRow: {
+ type: Function,
+ required: false,
+ },
+
+ canCreateDeployment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ canReadEnvironment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ rowClass: {
+ 'children-row': this.model['vue-isChildren'],
+ },
+ };
+ },
+
+ computed: {
+
+ /**
+ * If an item has a `children` entry it means it is a folder.
+ * Folder items have different behaviours - it is possible to toggle
+ * them and show their children.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ isFolder() {
+ return this.model.children && this.model.children.length > 0;
+ },
+
+ /**
+ * If an item is inside a folder structure will return true.
+ * Used for css purposes.
+ *
+ * @returns {Boolean|undefined}
+ */
+ isChildren() {
+ return this.model['vue-isChildren'];
+ },
+
+ /**
+ * Counts the number of environments in each folder.
+ * Used to show a badge with the counter.
+ *
+ * @returns {Number|Undefined} The number of environments for the current folder.
+ */
+ childrenCounter() {
+ return this.model.children && this.model.children.length;
+ },
+
+ /**
+ * Verifies if `last_deployment` key exists in the current Envrionment.
+ * This key is required to render most of the html - this method works has
+ * an helper.
+ *
+ * @returns {Boolean}
+ */
+ hasLastDeploymentKey() {
+ if (this.model.last_deployment &&
+ !this.$options.isObjectEmpty(this.model.last_deployment)) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Verifies is the given environment has manual actions.
+ * Used to verify if we should render them or nor.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ hasManualActions() {
+ return this.model.last_deployment && this.model.last_deployment.manual_actions &&
+ this.model.last_deployment.manual_actions.length > 0;
+ },
+
+ /**
+ * Returns the value of the `stoppable?` key provided in the response.
+ *
+ * @returns {Boolean}
+ */
+ isStoppable() {
+ return this.model['stoppable?'];
+ },
+
+ /**
+ * Verifies if the `deployable` key is present in `last_deployment` key.
+ * Used to verify whether we should or not render the rollback partial.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ canRetry() {
+ return this.hasLastDeploymentKey &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable;
+ },
+
+ /**
+ * Human readable date.
+ *
+ * @returns {String}
+ */
+ createdDate() {
+ const timeagoInstance = new timeago(); // eslint-disable-line
+
+ return timeagoInstance.format(this.model.created_at);
+ },
+
+ /**
+ * Returns the manual actions with the name parsed.
+ *
+ * @returns {Array.<Object>|Undefined}
+ */
+ manualActions() {
+ if (this.hasManualActions) {
+ return this.model.last_deployment.manual_actions.map((action) => {
+ const parsedAction = {
+ name: gl.text.humanize(action.name),
+ play_path: action.play_path,
+ };
+ return parsedAction;
+ });
+ }
+ return [];
+ },
+
+ /**
+ * Builds the string used in the user image alt attribute.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.user &&
+ this.model.last_deployment.user.username) {
+ return `${this.model.last_deployment.user.username}'s avatar'`;
+ }
+ return '';
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTag() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.tag) {
+ return this.model.last_deployment.tag;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit ref.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitRef() {
+ if (this.model.last_deployment && this.model.last_deployment.ref) {
+ return this.model.last_deployment.ref;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit url.
+ *
+ * @returns {String|Undefined}
+ */
+ commitUrl() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.commit_path) {
+ return this.model.last_deployment.commit.commit_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit short sha.
+ *
+ * @returns {String|Undefined}
+ */
+ commitShortSha() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.short_id) {
+ return this.model.last_deployment.commit.short_id;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit title.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTitle() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.title) {
+ return this.model.last_deployment.commit.title;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitAuthor() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.author) {
+ return this.model.last_deployment.commit.author;
+ }
+
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `retry_path` key is present and returns its value.
+ *
+ * @returns {String|Undefined}
+ */
+ retryUrl() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.retry_path) {
+ return this.model.last_deployment.deployable.retry_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `last?` key is present and returns its value.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ isLastDeployment() {
+ return this.model.last_deployment && this.model.last_deployment['last?'];
+ },
+
+ /**
+ * Builds the name of the builds needed to display both the name and the id.
+ *
+ * @returns {String}
+ */
+ buildName() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.deployable) {
+ return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`;
+ }
+ return '';
+ },
+
+ /**
+ * Builds the needed string to show the internal id.
+ *
+ * @returns {String}
+ */
+ deploymentInternalId() {
+ if (this.model.last_deployment &&
+ this.model.last_deployment.iid) {
+ return `#${this.model.last_deployment.iid}`;
+ }
+ return '';
+ },
+
+ /**
+ * Verifies if the user object is present under last_deployment object.
+ *
+ * @returns {Boolean}
+ */
+ deploymentHasUser() {
+ return !this.$options.isObjectEmpty(this.model.last_deployment) &&
+ !this.$options.isObjectEmpty(this.model.last_deployment.user);
+ },
+
+ /**
+ * Returns the user object nested with the last_deployment object.
+ * Used to render the template.
+ *
+ * @returns {Object}
+ */
+ deploymentUser() {
+ if (!this.$options.isObjectEmpty(this.model.last_deployment) &&
+ !this.$options.isObjectEmpty(this.model.last_deployment.user)) {
+ return this.model.last_deployment.user;
+ }
+ return {};
+ },
+
+ /**
+ * Verifies if the build name column should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderBuildName() {
+ return !this.isFolder &&
+ !this.$options.isObjectEmpty(this.model.last_deployment) &&
+ !this.$options.isObjectEmpty(this.model.last_deployment.deployable);
+ },
+
+ /**
+ * Verifies if deplyment internal ID should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderDeploymentID() {
+ return !this.isFolder &&
+ !this.$options.isObjectEmpty(this.model.last_deployment) &&
+ this.model.last_deployment.iid !== undefined;
+ },
+ },
+
+ /**
+ * Helper to verify if certain given object are empty.
+ * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty
+ * @param {Object} object
+ * @returns {Bollean}
+ */
+ isObjectEmpty(object) {
+ for (const key in object) { // eslint-disable-line
+ if (hasOwnProperty.call(object, key)) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ template: `
+ <tr>
+ <td v-bind:class="{ 'children-row': isChildren}">
+ <a v-if="!isFolder"
+ class="environment-name"
+ :href="model.environment_path">
+ {{model.name}}
+ </a>
+ <span v-else v-on:click="toggleRow(model)" class="folder-name">
+ <span class="folder-icon">
+ <i v-show="model.isOpen" class="fa fa-caret-down"></i>
+ <i v-show="!model.isOpen" class="fa fa-caret-right"></i>
+ </span>
+
+ <span>
+ {{model.name}}
+ </span>
+
+ <span class="badge">
+ {{childrenCounter}}
+ </span>
+ </span>
+ </td>
+
+ <td class="deployment-column">
+ <span v-if="shouldRenderDeploymentID">
+ {{deploymentInternalId}}
+ </span>
+
+ <span v-if="!isFolder && deploymentHasUser">
+ by
+ <a :href="deploymentUser.web_url" class="js-deploy-user-container">
+ <img class="avatar has-tooltip s20"
+ :src="deploymentUser.avatar_url"
+ :alt="userImageAltDescription"
+ :title="deploymentUser.username" />
+ </a>
+ </span>
+ </td>
+
+ <td>
+ <a v-if="shouldRenderBuildName"
+ class="build-link"
+ :href="model.last_deployment.deployable.build_path">
+ {{buildName}}
+ </a>
+ </td>
+
+ <td>
+ <div v-if="!isFolder && hasLastDeploymentKey" class="js-commit-component">
+ <commit-component
+ :tag="commitTag"
+ :ref="commitRef"
+ :commit_url="commitUrl"
+ :short_sha="commitShortSha"
+ :title="commitTitle"
+ :author="commitAuthor">
+ </commit-component>
+ </div>
+ <p v-if="!isFolder && !hasLastDeploymentKey" class="commit-title">
+ No deployments yet
+ </p>
+ </td>
+
+ <td>
+ <span
+ v-if="!isFolder && model.last_deployment"
+ class="environment-created-date-timeago">
+ {{createdDate}}
+ </span>
+ </td>
+
+ <td class="hidden-xs">
+ <div v-if="!isFolder">
+ <div v-if="hasManualActions && canCreateDeployment"
+ class="inline js-manual-actions-container">
+ <actions-component
+ :actions="manualActions">
+ </actions-component>
+ </div>
+
+ <div v-if="model.external_url && canReadEnvironment"
+ class="inline js-external-url-container">
+ <external-url-component
+ :external_url="model.external_url">
+ </external_url-component>
+ </div>
+
+ <div v-if="isStoppable && canCreateDeployment"
+ class="inline js-stop-component-container">
+ <stop-component
+ :stop_url="model.stop_path">
+ </stop-component>
+ </div>
+
+ <div v-if="canRetry && canCreateDeployment"
+ class="inline js-rollback-component-container">
+ <rollback-component
+ :is_last_deployment="isLastDeployment"
+ :retry_url="retryUrl">
+ </rollback-component>
+ </div>
+ </div>
+ </td>
+ </tr>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/environments/components/environment_rollback.js.es6 b/app/assets/javascripts/environments/components/environment_rollback.js.es6
new file mode 100644
index 00000000000..55e5c826e07
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment_rollback.js.es6
@@ -0,0 +1,31 @@
+/*= require vue */
+/* global Vue */
+
+(() => {
+ window.gl = window.gl || {};
+ window.gl.environmentsList = window.gl.environmentsList || {};
+
+ window.gl.environmentsList.RollbackComponent = Vue.component('rollback-component', {
+ props: {
+ retry_url: {
+ type: String,
+ default: '',
+ },
+ is_last_deployment: {
+ type: Boolean,
+ default: true,
+ },
+ },
+
+ template: `
+ <a class="btn" :href="retry_url" data-method="post" rel="nofollow">
+ <span v-if="is_last_deployment">
+ Re-deploy
+ </span>
+ <span v-else>
+ Rollback
+ </span>
+ </a>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/environments/components/environment_stop.js.es6 b/app/assets/javascripts/environments/components/environment_stop.js.es6
new file mode 100644
index 00000000000..e6d66a0148c
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment_stop.js.es6
@@ -0,0 +1,26 @@
+/*= require vue */
+/* global Vue */
+
+(() => {
+ window.gl = window.gl || {};
+ window.gl.environmentsList = window.gl.environmentsList || {};
+
+ window.gl.environmentsList.StopComponent = Vue.component('stop-component', {
+ props: {
+ stop_url: {
+ type: String,
+ default: '',
+ },
+ },
+
+ template: `
+ <a class="btn stop-env-link"
+ :href="stop_url"
+ data-confirm="Are you sure you want to stop this environment?"
+ data-method="post"
+ rel="nofollow">
+ <i class="fa fa-stop stop-env-icon"></i>
+ </a>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/environments/environments_bundle.js.es6 b/app/assets/javascripts/environments/environments_bundle.js.es6
new file mode 100644
index 00000000000..20eee7976ec
--- /dev/null
+++ b/app/assets/javascripts/environments/environments_bundle.js.es6
@@ -0,0 +1,21 @@
+//= require vue
+//= require_tree ./stores/
+//= require ./components/environment
+//= require ./vue_resource_interceptor
+
+
+$(() => {
+ window.gl = window.gl || {};
+
+ if (window.gl.EnvironmentsListApp) {
+ window.gl.EnvironmentsListApp.$destroy(true);
+ }
+ const Store = window.gl.environmentsList.EnvironmentsStore;
+
+ window.gl.EnvironmentsListApp = new window.gl.environmentsList.EnvironmentsComponent({
+ el: document.querySelector('#environments-list-view'),
+ propsData: {
+ store: Store.create(),
+ },
+ });
+});
diff --git a/app/assets/javascripts/environments/services/environments_service.js.es6 b/app/assets/javascripts/environments/services/environments_service.js.es6
new file mode 100644
index 00000000000..15ec7b76c3d
--- /dev/null
+++ b/app/assets/javascripts/environments/services/environments_service.js.es6
@@ -0,0 +1,22 @@
+/* globals Vue */
+/* eslint-disable no-unused-vars, no-param-reassign */
+class EnvironmentsService {
+
+ constructor(root) {
+ Vue.http.options.root = root;
+
+ this.environments = Vue.resource(root);
+
+ Vue.http.interceptors.push((request, next) => {
+ // needed in order to not break the tests.
+ if ($.rails) {
+ request.headers['X-CSRF-Token'] = $.rails.csrfToken();
+ }
+ next();
+ });
+ }
+
+ all() {
+ return this.environments.get();
+ }
+}
diff --git a/app/assets/javascripts/environments/stores/environments_store.js.es6 b/app/assets/javascripts/environments/stores/environments_store.js.es6
new file mode 100644
index 00000000000..0204a903ab5
--- /dev/null
+++ b/app/assets/javascripts/environments/stores/environments_store.js.es6
@@ -0,0 +1,131 @@
+/* eslint-disable no-param-reassign */
+(() => {
+ window.gl = window.gl || {};
+ window.gl.environmentsList = window.gl.environmentsList || {};
+
+ gl.environmentsList.EnvironmentsStore = {
+ state: {},
+
+ create() {
+ this.state.environments = [];
+ this.state.stoppedCounter = 0;
+ this.state.availableCounter = 0;
+
+ return this;
+ },
+
+ /**
+ * In order to display a tree view we need to modify the received
+ * data in to a tree structure based on `environment_type`
+ * sorted alphabetically.
+ * In each children a `vue-` property will be added. This property will be
+ * used to know if an item is a children mostly for css purposes. This is
+ * needed because the children row is a fragment instance and therfore does
+ * not accept non-prop attributes.
+ *
+ *
+ * @example
+ * it will transform this:
+ * [
+ * { name: "environment", environment_type: "review" },
+ * { name: "environment_1", environment_type: null }
+ * { name: "environment_2, environment_type: "review" }
+ * ]
+ * into this:
+ * [
+ * { name: "review", children:
+ * [
+ * { name: "environment", environment_type: "review", vue-isChildren: true},
+ * { name: "environment_2", environment_type: "review", vue-isChildren: true}
+ * ]
+ * },
+ * {name: "environment_1", environment_type: null}
+ * ]
+ *
+ *
+ * @param {Array} environments List of environments.
+ * @returns {Array} Tree structured array with the received environments.
+ */
+ storeEnvironments(environments = []) {
+ this.state.stoppedCounter = this.countByState(environments, 'stopped');
+ this.state.availableCounter = this.countByState(environments, 'available');
+
+ const environmentsTree = environments.reduce((acc, environment) => {
+ if (environment.environment_type !== null) {
+ const occurs = acc.filter(element => element.children &&
+ element.name === environment.environment_type);
+
+ environment['vue-isChildren'] = true;
+
+ if (occurs.length) {
+ acc[acc.indexOf(occurs[0])].children.push(environment);
+ acc[acc.indexOf(occurs[0])].children.sort(this.sortByName);
+ } else {
+ acc.push({
+ name: environment.environment_type,
+ children: [environment],
+ isOpen: false,
+ 'vue-isChildren': environment['vue-isChildren'],
+ });
+ }
+ } else {
+ acc.push(environment);
+ }
+
+ return acc;
+ }, []).sort(this.sortByName);
+
+ this.state.environments = environmentsTree;
+
+ return environmentsTree;
+ },
+
+ /**
+ * Toggles folder open property given the environment type.
+ *
+ * @param {String} envType
+ * @return {Array}
+ */
+ toggleFolder(envType) {
+ const environments = this.state.environments;
+
+ const environmentsCopy = environments.map((env) => {
+ if (env['vue-isChildren'] && env.name === envType) {
+ env.isOpen = !env.isOpen;
+ }
+
+ return env;
+ });
+
+ this.state.environments = environmentsCopy;
+
+ return environmentsCopy;
+ },
+
+ /**
+ * Given an array of environments, returns the number of environments
+ * that have the given state.
+ *
+ * @param {Array} environments
+ * @param {String} state
+ * @returns {Number}
+ */
+ countByState(environments, state) {
+ return environments.filter(env => env.state === state).length;
+ },
+
+ /**
+ * Sorts the two objects provided by their name.
+ *
+ * @param {Object} a
+ * @param {Object} b
+ * @returns {Number}
+ */
+ sortByName(a, b) {
+ const nameA = a.name.toUpperCase();
+ const nameB = b.name.toUpperCase();
+
+ return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; // eslint-disable-line
+ },
+ };
+})();
diff --git a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6
new file mode 100644
index 00000000000..406bdbc1c7d
--- /dev/null
+++ b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6
@@ -0,0 +1,12 @@
+/* global Vue */
+Vue.http.interceptors.push((request, next) => {
+ Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
+
+ next((response) => {
+ if (typeof response.data === 'string') {
+ response.data = JSON.parse(response.data); // eslint-disable-line
+ }
+
+ Vue.activeResources--; // eslint-disable-line
+ });
+});
diff --git a/app/assets/javascripts/group_label_subscription.js.es6 b/app/assets/javascripts/group_label_subscription.js.es6
new file mode 100644
index 00000000000..eea6cd40859
--- /dev/null
+++ b/app/assets/javascripts/group_label_subscription.js.es6
@@ -0,0 +1,53 @@
+/* eslint-disable */
+(function(global) {
+ class GroupLabelSubscription {
+ constructor(container) {
+ const $container = $(container);
+ this.$dropdown = $container.find('.dropdown');
+ this.$subscribeButtons = $container.find('.js-subscribe-button');
+ this.$unsubscribeButtons = $container.find('.js-unsubscribe-button');
+
+ this.$subscribeButtons.on('click', this.subscribe.bind(this));
+ this.$unsubscribeButtons.on('click', this.unsubscribe.bind(this));
+ }
+
+ unsubscribe(event) {
+ event.preventDefault();
+
+ const url = this.$unsubscribeButtons.attr('data-url');
+
+ $.ajax({
+ type: 'POST',
+ url: url
+ }).done(() => {
+ this.toggleSubscriptionButtons();
+ this.$unsubscribeButtons.removeAttr('data-url');
+ });
+ }
+
+ subscribe(event) {
+ event.preventDefault();
+
+ const $btn = $(event.currentTarget);
+ const url = $btn.attr('data-url');
+
+ this.$unsubscribeButtons.attr('data-url', url);
+
+ $.ajax({
+ type: 'POST',
+ url: url
+ }).done(() => {
+ this.toggleSubscriptionButtons();
+ });
+ }
+
+ toggleSubscriptionButtons() {
+ this.$dropdown.toggleClass('hidden');
+ this.$subscribeButtons.toggleClass('hidden');
+ this.$unsubscribeButtons.toggleClass('hidden');
+ }
+ }
+
+ global.GroupLabelSubscription = GroupLabelSubscription;
+
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 2a38ac28172..d83c41fae9d 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -125,6 +125,11 @@
// Close any open tooltips
$('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy');
};
+
+ gl.utils.isMetaKey = function(e) {
+ return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
+ };
+
})(window);
}).call(this);
diff --git a/app/assets/javascripts/lib/utils/pretty_time.js.es6 b/app/assets/javascripts/lib/utils/pretty_time.js.es6
new file mode 100644
index 00000000000..ccaf447eb0b
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/pretty_time.js.es6
@@ -0,0 +1,67 @@
+(() => {
+ /*
+ * TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints,
+ * stringifyTime condensed or non-condensed, abbreviateTimelengths)
+ * */
+
+ class PrettyTime {
+
+ /*
+ * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
+ * Seconds can be negative or positive, zero or non-zero.
+ */
+ static parseSeconds(seconds) {
+ const DAYS_PER_WEEK = 5;
+ const HOURS_PER_DAY = 8;
+ const MINUTES_PER_HOUR = 60;
+ const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
+ const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
+
+ const timePeriodConstraints = {
+ weeks: MINUTES_PER_WEEK,
+ days: MINUTES_PER_DAY,
+ hours: MINUTES_PER_HOUR,
+ minutes: 1,
+ };
+
+ let unorderedMinutes = PrettyTime.secondsToMinutes(seconds);
+
+ return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
+ const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
+
+ unorderedMinutes -= (periodCount * minutesPerPeriod);
+
+ return periodCount;
+ });
+ }
+
+ /*
+ * Accepts a timeObject and returns a condensed string representation of it
+ * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
+ */
+
+ static stringifyTime(timeObject) {
+ const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
+ const isNonZero = !!unitValue;
+ return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
+ }, '').trim();
+ return reducedTime.length ? reducedTime : '0m';
+ }
+
+ /*
+ * Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
+ * the first non-zero unit/value pair.
+ */
+
+ static abbreviateTime(timeStr) {
+ return timeStr.split(' ')
+ .filter(unitStr => unitStr.charAt(0) !== '0')[0];
+ }
+
+ static secondsToMinutes(seconds) {
+ return Math.abs(seconds / 60);
+ }
+ }
+
+ gl.PrettyTime = PrettyTime;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 5b4123a483b..ac44b81ee22 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -112,6 +112,9 @@
gl.text.removeListeners = function(form) {
return $('.js-md', form).off();
};
+ gl.text.humanize = function(string) {
+ return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
+ }
return gl.text.truncate = function(string, maxLength) {
return string.substr(0, (maxLength - 3)) + '...';
};
diff --git a/app/assets/javascripts/merge_request_widget.js.es6 b/app/assets/javascripts/merge_request_widget.js.es6
index 56c87af3226..54929cd8f24 100644
--- a/app/assets/javascripts/merge_request_widget.js.es6
+++ b/app/assets/javascripts/merge_request_widget.js.es6
@@ -218,7 +218,7 @@
}
if (environment.deployed_at && environment.deployed_at_formatted) {
- environment.deployed_at = gl.utils.getTimeago(environment.deployed_at) + '.';
+ environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.';
} else {
$('.js-environment-timeago', $template).remove();
environment.name += '.';
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 44079bc3ca3..6cb87f9ba81 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -12,7 +12,7 @@
var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
this.Notes = (function() {
- var isMetaKey;
+ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
Notes.interval = null;
@@ -33,6 +33,7 @@
this.resetMainTargetForm = bind(this.resetMainTargetForm, this);
this.refresh = bind(this.refresh, this);
this.keydownNoteText = bind(this.keydownNoteText, this);
+ this.toggleCommitList = bind(this.toggleCommitList, this);
this.notes_url = notes_url;
this.note_ids = note_ids;
this.last_fetched_at = last_fetched_at;
@@ -46,6 +47,7 @@
this.setPollingInterval();
this.setupMainTargetNoteForm();
this.initTaskList();
+ this.collapseLongCommitList();
}
Notes.prototype.addBinding = function() {
@@ -81,10 +83,13 @@
$(document).on("click", ".js-add-diff-note-button", this.addDiffNote);
// hide diff note form
$(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm);
+ // toggle commit list
+ $(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList);
// fetch notes when tab becomes visible
$(document).on("visibilitychange", this.visibilityChange);
// when issue status changes, we need to refresh data
$(document).on("issuable:change", this.refresh);
+
// when a key is clicked on the notes
return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
};
@@ -114,9 +119,10 @@
Notes.prototype.keydownNoteText = function(e) {
var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
- if (isMetaKey(e)) {
+ if (gl.utils.isMetaKey(e)) {
return;
}
+
$textarea = $(e.target);
// Edit previous note when UP arrow is hit
switch (e.which) {
@@ -156,10 +162,6 @@
}
};
- isMetaKey = function(e) {
- return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
- };
-
Notes.prototype.initRefresh = function() {
clearInterval(Notes.interval);
return Notes.interval = setInterval((function(_this) {
@@ -263,6 +265,7 @@
$notesList.append(note.html).syntaxHighlight();
// Update datetime format on the recent note
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
+ this.collapseLongCommitList();
this.initTaskList();
this.refresh();
return this.updateNotesCount(1);
@@ -433,9 +436,9 @@
var $form = $(xhr.target);
if ($form.attr('data-resolve-all') != null) {
- var projectPath = $form.data('project-path')
- discussionId = $form.data('discussion-id'),
- mergeRequestId = $form.data('noteable-iid');
+ var projectPath = $form.data('project-path');
+ var discussionId = $form.data('discussion-id');
+ var mergeRequestId = $form.data('noteable-iid');
if (ResolveService != null) {
ResolveService.toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId);
@@ -844,9 +847,9 @@
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount);
};
- Notes.prototype.resolveDiscussion = function () {
- var $this = $(this),
- discussionId = $this.attr('data-discussion-id');
+ Notes.prototype.resolveDiscussion = function() {
+ var $this = $(this);
+ var discussionId = $this.attr('data-discussion-id');
$this
.closest('form')
@@ -855,6 +858,36 @@
.attr('data-project-path', $this.attr('data-project-path'));
};
+ Notes.prototype.toggleCommitList = function(e) {
+ const $element = $(e.target);
+ const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
+
+ $closestSystemCommitList.toggleClass('hide-shade');
+ };
+
+ /**
+ Scans system notes with `ul` elements in system note body
+ then collapse long commit list pushed by user to make it less
+ intrusive.
+ */
+ Notes.prototype.collapseLongCommitList = function() {
+ const systemNotes = $('#notes-list').find('li.system-note').has('ul');
+
+ $.each(systemNotes, function(index, systemNote) {
+ const $systemNote = $(systemNote);
+ const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', '');
+
+ $systemNote.find('.note-header .system-note-message').html(headerMessage);
+
+ if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) {
+ $systemNote.find('.note-text').addClass('system-note-commit-list');
+ $systemNote.find('.system-note-commit-list-toggler').show();
+ } else {
+ $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade');
+ }
+ });
+ };
+
return Notes;
})();
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
deleted file mode 100644
index d22d2d9dbae..00000000000
--- a/app/assets/javascripts/pager.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, object-shorthand, quotes, no-undef, prefer-template, wrap-iife, comma-dangle, no-return-assign, no-else-return, consistent-return, no-unused-vars, padded-blocks, max-len */
-(function() {
- this.Pager = {
- init: function(limit, preload, disable, callback) {
- this.limit = limit != null ? limit : 0;
- this.disable = disable != null ? disable : false;
- this.callback = callback != null ? callback : $.noop;
- this.loading = $('.loading').first();
- if (preload) {
- this.offset = 0;
- this.getOld();
- } else {
- this.offset = this.limit;
- }
- return this.initLoadMore();
- },
- getOld: function() {
- this.loading.show();
- return $.ajax({
- type: "GET",
- url: $(".content_list").data('href') || location.href,
- data: "limit=" + this.limit + "&offset=" + this.offset,
- complete: (function(_this) {
- return function() {
- return _this.loading.hide();
- };
- })(this),
- success: function(data) {
- Pager.append(data.count, data.html);
- return Pager.callback();
- },
- dataType: "json"
- });
- },
- append: function(count, html) {
- $(".content_list").append(html);
- if (count > 0) {
- return this.offset += count;
- } else {
- return this.disable = true;
- }
- },
- initLoadMore: function() {
- $(document).unbind('scroll');
- return $(document).endlessScroll({
- bottomPixels: 400,
- fireDelay: 1000,
- fireOnce: true,
- ceaseFire: function() {
- return Pager.disable;
- },
- callback: (function(_this) {
- return function(i) {
- if (!_this.loading.is(':visible')) {
- _this.loading.show();
- return Pager.getOld();
- }
- };
- })(this)
- });
- }
- };
-
-}).call(this);
diff --git a/app/assets/javascripts/pager.js.es6 b/app/assets/javascripts/pager.js.es6
new file mode 100644
index 00000000000..e35cf6d295e
--- /dev/null
+++ b/app/assets/javascripts/pager.js.es6
@@ -0,0 +1,73 @@
+(() => {
+ const ENDLESS_SCROLL_BOTTOM_PX = 400;
+ const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
+
+ const Pager = {
+ init(limit = 0, preload = false, disable = false, callback = $.noop) {
+ this.limit = limit;
+ this.offset = this.limit;
+ this.disable = disable;
+ this.callback = callback;
+ this.loading = $('.loading').first();
+ if (preload) {
+ this.offset = 0;
+ this.getOld();
+ }
+ this.initLoadMore();
+ },
+
+ getOld() {
+ this.loading.show();
+ $.ajax({
+ type: 'GET',
+ url: $('.content_list').data('href') || window.location.href,
+ data: `limit=${this.limit}&offset=${this.offset}`,
+ dataType: 'json',
+ error: () => this.loading.hide(),
+ success: (data) => {
+ this.append(data.count, data.html);
+ this.callback();
+
+ // keep loading until we've filled the viewport height
+ if (!this.disable && !this.isScrollable()) {
+ this.getOld();
+ } else {
+ this.loading.hide();
+ }
+ },
+ });
+ },
+
+ append(count, html) {
+ $('.content_list').append(html);
+ if (count > 0) {
+ this.offset += count;
+ } else {
+ this.disable = true;
+ }
+ },
+
+ isScrollable() {
+ const $w = $(window);
+ return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX;
+ },
+
+ initLoadMore() {
+ $(document).unbind('scroll');
+ $(document).endlessScroll({
+ bottomPixels: ENDLESS_SCROLL_BOTTOM_PX,
+ fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS,
+ fireOnce: true,
+ ceaseFire: () => this.disable === true,
+ callback: () => {
+ if (!this.loading.is(':visible')) {
+ this.loading.show();
+ this.getOld();
+ }
+ },
+ });
+ },
+ };
+
+ window.Pager = Pager;
+})();
diff --git a/app/assets/javascripts/pipelines.js.es6 b/app/assets/javascripts/pipelines.js.es6
index e6fada5c84c..a84db9c0233 100644
--- a/app/assets/javascripts/pipelines.js.es6
+++ b/app/assets/javascripts/pipelines.js.es6
@@ -3,26 +3,12 @@
class Pipelines {
constructor() {
- this.initGraphToggle();
this.addMarginToBuildColumns();
}
- initGraphToggle() {
- this.pipelineGraph = document.querySelector('.pipeline-graph');
- this.toggleButton = document.querySelector('.toggle-pipeline-btn');
- this.toggleButtonText = this.toggleButton.querySelector('.toggle-btn-text');
- this.toggleButton.addEventListener('click', this.toggleGraph.bind(this));
- }
-
- toggleGraph() {
- const graphCollapsed = this.pipelineGraph.classList.contains('graph-collapsed');
- this.toggleButton.classList.toggle('graph-collapsed');
- this.pipelineGraph.classList.toggle('graph-collapsed');
- this.toggleButtonText.textContent = graphCollapsed ? 'Hide' : 'Expand';
- }
-
addMarginToBuildColumns() {
- const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)');
+ this.pipelineGraph = document.querySelector('.pipeline-graph');
+ const secondChildBuildNodes = document.querySelector('.pipeline-graph').querySelectorAll('.build:nth-child(2)');
for (buildNodeIndex in secondChildBuildNodes) {
const buildNode = secondChildBuildNodes[buildNodeIndex];
const firstChildBuildNode = buildNode.previousElementSibling;
diff --git a/app/assets/javascripts/project_label_subscription.js.es6 b/app/assets/javascripts/project_label_subscription.js.es6
new file mode 100644
index 00000000000..03a115cb35b
--- /dev/null
+++ b/app/assets/javascripts/project_label_subscription.js.es6
@@ -0,0 +1,53 @@
+/* eslint-disable */
+(function(global) {
+ class ProjectLabelSubscription {
+ constructor(container) {
+ this.$container = $(container);
+ this.$buttons = this.$container.find('.js-subscribe-button');
+
+ this.$buttons.on('click', this.toggleSubscription.bind(this));
+ }
+
+ toggleSubscription(event) {
+ event.preventDefault();
+
+ const $btn = $(event.currentTarget);
+ const $span = $btn.find('span');
+ const url = $btn.attr('data-url');
+ const oldStatus = $btn.attr('data-status');
+
+ $btn.addClass('disabled');
+ $span.toggleClass('hidden');
+
+ $.ajax({
+ type: 'POST',
+ url: url
+ }).done(() => {
+ let newStatus, newAction;
+
+ if (oldStatus === 'unsubscribed') {
+ [newStatus, newAction] = ['subscribed', 'Unsubscribe'];
+ } else {
+ [newStatus, newAction] = ['unsubscribed', 'Subscribe'];
+ }
+
+ $span.toggleClass('hidden');
+ $btn.removeClass('disabled');
+
+ this.$buttons.attr('data-status', newStatus);
+ this.$buttons.find('> span').text(newAction);
+
+ for (let button of this.$buttons) {
+ let $button = $(button);
+
+ if ($button.attr('data-original-title')) {
+ $button.tooltip('hide').attr('data-original-title', newAction).tooltip('fixTitle');
+ }
+ }
+ });
+ }
+ }
+
+ global.ProjectLabelSubscription = ProjectLabelSubscription;
+
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/smart_interval.js.es6 b/app/assets/javascripts/smart_interval.js.es6
new file mode 100644
index 00000000000..5eb15dba79b
--- /dev/null
+++ b/app/assets/javascripts/smart_interval.js.es6
@@ -0,0 +1,130 @@
+/*
+* Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
+* and controllable by a public API.
+*
+* */
+
+(() => {
+ class SmartInterval {
+ /**
+ * @param { function } callback Function to be called on each iteration (required)
+ * @param { milliseconds } startingInterval `currentInterval` is set to this initially
+ * @param { milliseconds } maxInterval `currentInterval` will be incremented to this
+ * @param { integer } incrementByFactorOf `currentInterval` is incremented by this factor
+ * @param { boolean } lazyStart Configure if timer is initialized on instantiation or lazily
+ */
+ constructor({ callback, startingInterval, maxInterval, incrementByFactorOf, lazyStart }) {
+ this.cfg = {
+ callback,
+ startingInterval,
+ maxInterval,
+ incrementByFactorOf,
+ lazyStart,
+ };
+
+ this.state = {
+ intervalId: null,
+ currentInterval: startingInterval,
+ pageVisibility: 'visible',
+ };
+
+ this.initInterval();
+ }
+ /* public */
+
+ start() {
+ const cfg = this.cfg;
+ const state = this.state;
+
+ state.intervalId = window.setInterval(() => {
+ cfg.callback();
+
+ if (this.getCurrentInterval() === cfg.maxInterval) {
+ return;
+ }
+
+ this.incrementInterval();
+ this.resume();
+ }, this.getCurrentInterval());
+ }
+
+ // cancel the existing timer, setting the currentInterval back to startingInterval
+ cancel() {
+ this.setCurrentInterval(this.cfg.startingInterval);
+ this.stopTimer();
+ }
+
+ // start a timer, using the existing interval
+ resume() {
+ this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
+ this.start();
+ }
+
+ destroy() {
+ this.cancel();
+ $(document).off('visibilitychange').off('page:before-unload');
+ }
+
+ /* private */
+
+ initInterval() {
+ const cfg = this.cfg;
+
+ if (!cfg.lazyStart) {
+ this.start();
+ }
+
+ this.initVisibilityChangeHandling();
+ this.initPageUnloadHandling();
+ }
+
+ initVisibilityChangeHandling() {
+ // cancel interval when tab no longer shown (prevents cached pages from polling)
+ $(document)
+ .off('visibilitychange').on('visibilitychange', (e) => {
+ this.state.pageVisibility = e.target.visibilityState;
+ this.handleVisibilityChange();
+ });
+ }
+
+ initPageUnloadHandling() {
+ // prevent interval continuing after page change, when kept in cache by Turbolinks
+ $(document).on('page:before-unload', () => this.cancel());
+ }
+
+ handleVisibilityChange() {
+ const state = this.state;
+
+ const intervalAction = state.pageVisibility === 'hidden' ? this.cancel : this.resume;
+
+ intervalAction.apply(this);
+ }
+
+ getCurrentInterval() {
+ return this.state.currentInterval;
+ }
+
+ setCurrentInterval(newInterval) {
+ this.state.currentInterval = newInterval;
+ }
+
+ incrementInterval() {
+ const cfg = this.cfg;
+ const currentInterval = this.getCurrentInterval();
+ let nextInterval = currentInterval * cfg.incrementByFactorOf;
+
+ if (nextInterval > cfg.maxInterval) {
+ nextInterval = cfg.maxInterval;
+ }
+
+ this.setCurrentInterval(nextInterval);
+ }
+
+ stopTimer() {
+ const state = this.state;
+
+ state.intervalId = window.clearInterval(state.intervalId);
+ }
+ }
+ gl.SmartInterval = SmartInterval;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/subbable_resource.js.es6 b/app/assets/javascripts/subbable_resource.js.es6
new file mode 100644
index 00000000000..932120157a3
--- /dev/null
+++ b/app/assets/javascripts/subbable_resource.js.es6
@@ -0,0 +1,54 @@
+//= require vue
+//= require vue-resource
+
+(() => {
+/*
+* SubbableResource can be extended to provide a pubsub-style service for one-off REST
+* calls. Subscribe by passing a callback or render method you will use to handle responses.
+ *
+* */
+
+ class SubbableResource {
+ constructor(resourcePath) {
+ this.endpoint = resourcePath;
+
+ // TODO: Switch to axios.create
+ this.resource = $.ajax;
+ this.subscribers = [];
+ }
+
+ subscribe(callback) {
+ this.subscribers.push(callback);
+ }
+
+ publish(newResponse) {
+ const responseCopy = _.extend({}, newResponse);
+ this.subscribers.forEach((fn) => {
+ fn(responseCopy);
+ });
+ return newResponse;
+ }
+
+ get(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+
+ post(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+
+ put(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+
+ delete(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+ }
+
+ gl.SubbableResource = SubbableResource;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/user_tabs.js.es6 b/app/assets/javascripts/user_tabs.js.es6
index 2b310da319c..5a625611987 100644
--- a/app/assets/javascripts/user_tabs.js.es6
+++ b/app/assets/javascripts/user_tabs.js.es6
@@ -134,7 +134,7 @@ content on the Users#show page.
}
const $calendarWrap = this.$parentEl.find('.user-calendar');
$calendarWrap.load($calendarWrap.data('href'));
- new Activities();
+ new gl.Activities();
return this.loaded['activity'] = true;
}
diff --git a/app/assets/javascripts/vue_common_component/commit.js.es6 b/app/assets/javascripts/vue_common_component/commit.js.es6
new file mode 100644
index 00000000000..1bc68c1ba2f
--- /dev/null
+++ b/app/assets/javascripts/vue_common_component/commit.js.es6
@@ -0,0 +1,176 @@
+/*= require vue */
+/* global Vue */
+(() => {
+ window.gl = window.gl || {};
+
+ window.gl.CommitComponent = Vue.component('commit-component', {
+
+ props: {
+ /**
+ * Indicates the existance of a tag.
+ * Used to render the correct icon, if true will render `fa-tag` icon,
+ * if false will render `fa-code-fork` icon.
+ */
+ tag: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ /**
+ * If provided is used to render the branch name and url.
+ * Should contain the following properties:
+ * name
+ * ref_url
+ */
+ ref: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+
+ /**
+ * Used to link to the commit sha.
+ */
+ commit_url: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ /**
+ * Used to show the commit short_sha that links to the commit url.
+ */
+ short_sha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ /**
+ * If provided shows the commit tile.
+ */
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ /**
+ * If provided renders information about the author of the commit.
+ * When provided should include:
+ * `avatar_url` to render the avatar icon
+ * `web_url` to link to user profile
+ * `username` to render alt and title tags
+ */
+ author: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+
+ computed: {
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * ref section were provided.
+ *
+ * TODO: Improve this! Use lodash _.has when we have it.
+ *
+ * @returns {Boolean}
+ */
+ hasRef() {
+ return this.ref && this.ref.name && this.ref.ref_url;
+ },
+
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * author section were provided.
+ *
+ * TODO: Improve this! Use lodash _.has when we have it.
+ *
+ * @returns {Boolean}
+ */
+ hasAuthor() {
+ return this.author &&
+ this.author.avatar_url &&
+ this.author.web_url &&
+ this.author.username;
+ },
+
+ /**
+ * If information about the author is provided will return a string
+ * to be rendered as the alt attribute of the img tag.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ return this.author &&
+ this.author.username ? `${this.author.username}'s avatar` : null;
+ },
+ },
+
+ /**
+ * In order to reuse the svg instead of copy and paste in this template
+ * we need to render it outside this component using =custom_icon partial.
+ * Make sure it has this structure:
+ * .commit-icon-svg.hidden
+ * svg
+ *
+ * TODO: Find a better way to include SVG
+ */
+ mounted() {
+ const commitIconContainer = this.$el.querySelector('.commit-icon-container');
+ const commitIcon = document.querySelector('.commit-icon-svg.hidden svg');
+
+ if (commitIconContainer && commitIcon) {
+ commitIconContainer.appendChild(commitIcon.cloneNode(true));
+ }
+ },
+
+ template: `
+ <div class="branch-commit">
+
+ <div v-if="hasRef" class="icon-container">
+ <i v-if="tag" class="fa fa-tag"></i>
+ <i v-if="!tag" class="fa fa-code-fork"></i>
+ </div>
+
+ <a v-if="hasRef"
+ class="monospace branch-name"
+ :href="ref.ref_url">
+ {{ref.name}}
+ </a>
+
+ <div class="icon-container commit-icon commit-icon-container"></div>
+
+ <a class="commit-id monospace"
+ :href="commit_url">
+ {{short_sha}}
+ </a>
+
+ <p class="commit-title">
+ <span v-if="title">
+ <a v-if="hasAuthor"
+ class="avatar-image-container"
+ :href="author.web_url">
+ <img
+ class="avatar has-tooltip s20"
+ :src="author.avatar_url"
+ :alt="userImageAltDescription"
+ :title="author.username" />
+ </a>
+
+ <a class="commit-row-message"
+ :href="commit_url">
+ {{title}}
+ </a>
+ </span>
+ <span v-else>
+ Cant find HEAD commit for this branch
+ </span>
+ </p>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index d5cca1b10fb..7c7f991dd87 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -39,3 +39,5 @@
@import "framework/typography.scss";
@import "framework/zen.scss";
@import "framework/blank";
+@import "framework/wells.scss";
+@import "framework/page-header.scss";
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 7e168092522..77ae9e9a6e7 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -254,3 +254,32 @@
.content-block-small {
padding: 10px 0;
}
+
+.empty-state {
+ margin: 100px 0 0;
+
+ .text-content {
+ max-width: 460px;
+ margin: 0 auto;
+ padding: $gl-padding;
+ }
+
+ .svg-content {
+ text-align: center;
+
+ svg {
+ max-width: 425px;
+ width: 100%;
+ padding: $gl-padding;
+ }
+ }
+
+ @media(max-width: $screen-xs-max) {
+ margin-top: 50px;
+ text-align: center;
+
+ .btn {
+ width: 100%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 9acff45de75..4a9aa0f8717 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -349,6 +349,12 @@
}
}
+.btn-inverted {
+ &-secondary {
+ @include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light);
+ }
+}
+
@media (max-width: $screen-xs-max) {
.btn-wide-on-xs {
width: 100%;
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index ad5ac589d0f..7f5583c917a 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -376,3 +376,19 @@ table {
}
.hide-bottom-border { border-bottom: none !important; }
+
+.gl-accessibility {
+ &:focus {
+ top: 1px;
+ left: 1px;
+ width: auto;
+ height: 100%;
+ line-height: 50px;
+ padding: 0 10px;
+ clip: auto;
+ text-decoration: none;
+ color: $gl-title-color;
+ background: $gray-light;
+ z-index: 1;
+ }
+}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 07c8874bf03..909a0f4afda 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -11,7 +11,7 @@
border-radius: 0;
font-family: $monospace_font;
font-size: $code_font_size;
- line-height: $code_line_height !important;
+ line-height: 19px;
margin: 0;
overflow: auto;
overflow-y: hidden;
@@ -47,7 +47,7 @@
font-family: $monospace_font;
display: block;
font-size: $code_font_size !important;
- line-height: $code_line_height !important;
+ line-height: 19px;
white-space: nowrap;
i {
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 8a93eac1b6d..42087c91530 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -64,12 +64,17 @@
a {
padding-top: 0;
- line-height: 1;
+ line-height: 19px;
border-bottom: 1px solid $border-color;
&.btn.btn-xs {
padding: 2px 5px;
}
+
+ &:focus {
+ margin-top: -10px;
+ padding-top: 10px;
+ }
}
}
}
@@ -163,4 +168,4 @@
border: 1px solid $white-light;
}
}
-} \ No newline at end of file
+}
diff --git a/app/assets/stylesheets/framework/page-header.scss b/app/assets/stylesheets/framework/page-header.scss
new file mode 100644
index 00000000000..85c1385d5d9
--- /dev/null
+++ b/app/assets/stylesheets/framework/page-header.scss
@@ -0,0 +1,67 @@
+.page-content-header {
+ line-height: 34px;
+ padding: 10px 0;
+ margin-bottom: 0;
+
+ @media (min-width: $screen-sm-min) {
+ display: flex;
+ align-items: center;
+
+ .header-main-content {
+ flex: 1;
+ }
+ }
+
+ .header-action-buttons {
+ i {
+ color: $gl-icon-color;
+ font-size: 13px;
+ margin-right: 3px;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .btn {
+ width: 100%;
+ margin-top: 10px;
+ }
+
+ .dropdown {
+ width: 100%;
+ }
+ }
+ }
+
+ .avatar {
+ @extend .avatar-inline;
+ margin-left: 0;
+
+ @media (min-width: $screen-sm-min) {
+ margin-left: 4px;
+ }
+ }
+
+ .commit-committer-link,
+ .commit-author-link {
+ color: $gl-gray;
+ font-weight: bold;
+ }
+
+ .fa-clipboard {
+ color: $dropdown-title-btn-color;
+ }
+
+ .commit-info {
+ &.branches {
+ margin-left: 8px;
+ }
+ }
+
+ .ci-status-link {
+
+ svg {
+ position: relative;
+ top: 2px;
+ margin: 0 2px 0 3px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 8bf5edfde50..750d99ebabe 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -90,8 +90,8 @@ $table-border-color: #f0f0f0;
$background-color: $gray-light;
$dark-background-color: #f5f5f5;
$table-text-gray: #8f8f8f;
-$widget-expand-item: #e8f2f7;
-$widget-inner-border: #eef0f2;
+$well-expand-item: #e8f2f7;
+$well-inner-border: #eef0f2;
/*
* Text
@@ -160,6 +160,7 @@ $settings-icon-size: 18px;
$provider-btn-group-border: #e5e5e5;
$provider-btn-not-active-color: #4688f1;
$link-underline-blue: #4a8bee;
+$active-item-blue: #4a8bee;
$layout-link-gray: #7e7c7c;
$todo-alert-blue: #428bca;
$btn-side-margin: 10px;
@@ -283,6 +284,9 @@ $calendar-unselectable-bg: $gray-light;
*/
$cycle-analytics-box-padding: 30px;
$cycle-analytics-box-text-color: #8c8c8c;
+$cycle-analytics-big-font: 19px;
+$cycle-analytics-dark-text: $gl-title-color;
+$cycle-analytics-light-gray: #bfbfbf;
/*
* Personal Access Tokens
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
new file mode 100644
index 00000000000..192939f4527
--- /dev/null
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -0,0 +1,45 @@
+.info-well {
+ background: $background-color;
+ color: $gl-gray;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+
+ .well-segment {
+ padding: $gl-padding;
+
+ &:not(:last-of-type) {
+ border-bottom: 1px solid $well-inner-border;
+ }
+
+ &.branch-info {
+ .monospace,
+ .commit-info {
+ margin-left: 4px;
+ }
+ }
+ }
+
+ .icon-container {
+ display: inline-block;
+ margin-right: 8px;
+
+ svg {
+ position: relative;
+ top: 2px;
+ height: 16px;
+ width: 16px;
+ }
+
+ &.commit-icon {
+ svg {
+ path {
+ fill: $gl-text-color;
+ }
+ }
+ }
+ }
+
+ .label.label-gray {
+ background-color: $well-expand-item;
+ }
+}
diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss
index 6cefafd8fc7..14812e171fd 100644
--- a/app/assets/stylesheets/pages/admin.scss
+++ b/app/assets/stylesheets/pages/admin.scss
@@ -160,3 +160,9 @@
}
}
}
+
+.admin-builds-table {
+ .ci-table td:last-child {
+ min-width: 120px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index f1d311cabbe..48f11eb2552 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -40,6 +40,19 @@
margin-bottom: 10px;
}
}
+
+ .environment-information {
+ background-color: $background-color;
+ border: 1px solid $border-color;
+ padding: 12px $gl-padding;
+ border-radius: $border-radius-default;
+
+ svg {
+ position: relative;
+ top: 1px;
+ margin-right: 5px;
+ }
+ }
}
.build-header {
@@ -49,10 +62,6 @@
min-height: 58px;
align-items: center;
- .btn-inverted {
- @include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light);
- }
-
@media (max-width: $screen-sm-max) {
padding-right: 40px;
@@ -63,14 +72,14 @@
.header-content {
flex: 1;
- }
- a {
- color: $gl-gray;
+ a {
+ color: $gl-gray;
- &:hover {
- color: $gl-link-color;
- text-decoration: none;
+ &:hover {
+ color: $gl-link-color;
+ text-decoration: none;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss
index 47d3e72679b..ddc9d0e2b1a 100644
--- a/app/assets/stylesheets/pages/commit.scss
+++ b/app/assets/stylesheets/pages/commit.scss
@@ -26,143 +26,12 @@
white-space: pre-wrap;
}
-.commit-info-row {
- margin-bottom: 10px;
- line-height: 24px;
- padding-top: 6px;
-
- &.commit-info-row-header {
- line-height: 34px;
- padding: 10px 0;
- margin-bottom: 0;
-
- @media (min-width: $screen-sm-min) {
- display: flex;
- align-items: center;
-
- .commit-meta {
- flex: 1;
- }
- }
-
- .commit-hash-full {
- @media (max-width: $screen-sm-max) {
- width: 80px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- display: inline-block;
- vertical-align: bottom;
- }
- }
-
- .commit-action-buttons {
- i {
- color: $gl-icon-color;
- font-size: 13px;
- margin-right: 3px;
- }
-
- @media (max-width: $screen-xs-max) {
- .dropdown {
- width: 100%;
- margin-top: 10px;
- }
-
- .dropdown-toggle {
- width: 100%;
- }
- }
- }
- }
-
- .avatar {
- @extend .avatar-inline;
- margin-left: 0;
-
- @media (min-width: $screen-sm-min) {
- margin-left: 4px;
- }
- }
-
- .commit-committer-link,
- .commit-author-link {
- color: $gl-gray;
- font-weight: bold;
- }
-
- .fa-clipboard {
- color: $dropdown-title-btn-color;
- }
-
- .commit-info {
- &.branches {
- margin-left: 8px;
- }
- }
-
- .ci-status-link {
-
- svg {
- position: relative;
- top: 2px;
- margin: 0 2px 0 3px;
- }
- }
-}
-
.js-details-expand {
&:hover {
text-decoration: none;
}
}
-.commit-info-widget {
- background: $background-color;
- color: $gl-gray;
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
-
- .widget-row {
- padding: $gl-padding;
-
- &:not(:last-of-type) {
- border-bottom: 1px solid $widget-inner-border;
- }
-
- &.branch-info {
- .monospace,
- .commit-info {
- margin-left: 4px;
- }
- }
- }
-
- .icon-container {
- display: inline-block;
- margin-right: 8px;
-
- svg {
- position: relative;
- top: 2px;
- height: 16px;
- width: 16px;
- }
-
- &.commit-icon {
- svg {
- path {
- fill: $gl-text-color;
- }
- }
- }
- }
-
- .label.label-gray {
- background-color: $widget-expand-item;
- }
-}
-
.ci-status-link {
svg {
overflow: visible;
@@ -184,6 +53,17 @@
}
}
+.commit-hash-full {
+ @media (max-width: $screen-sm-max) {
+ width: 80px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-block;
+ vertical-align: bottom;
+ }
+}
+
.file-stats {
ul {
list-style: none;
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 572e1e7d558..498a8f68e49 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -1,10 +1,53 @@
#cycle-analytics {
+ max-width: 1000px;
margin: 24px auto 0;
- max-width: 800px;
position: relative;
- .panel {
+ .col-headers {
+ ul {
+ margin: 0;
+ padding: 0;
+ @include clearfix;
+ }
+
+ li {
+ display: inline-block;
+ float: left;
+ line-height: 50px;
+ width: 20%;
+ }
+
+
+ .fa {
+ color: $cycle-analytics-light-gray;
+ }
+
+ .stage-header {
+ width: 28%;
+ padding-left: $gl-padding;
+ }
+ .median-header {
+ width: 12%;
+ }
+
+ .event-header {
+ width: 45%;
+ padding-left: $gl-padding;
+ }
+
+ .total-time-header {
+ width: 15%;
+ text-align: right;
+ padding-right: $gl-padding;
+ }
+
+ .stage-name {
+ font-weight: 600;
+ }
+ }
+
+ .panel {
.content-block {
padding: 24px 0;
border-bottom: none;
@@ -35,23 +78,20 @@
}
&:last-child {
- text-align: right;
-
@media (max-width: $screen-sm-min) {
text-align: center;
}
}
}
+ }
- .dropdown {
- top: 13px;
- }
+ .js-ca-dropdown {
+ top: $gl-padding-top;
}
.bordered-box {
border: 1px solid $border-color;
border-radius: $border-radius-default;
-
}
.content-list {
@@ -141,4 +181,302 @@
margin-top: 36px;
}
+ .stage-panel-body {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ .stage-nav,
+ .stage-entries {
+ display: flex;
+ vertical-align: top;
+ font-size: $gl-font-size;
+ }
+
+ .stage-nav {
+ width: 40%;
+ margin-bottom: 0;
+
+ ul {
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ }
+
+ li {
+ list-style-type: none;
+ @include clearfix;
+ }
+
+ .stage-nav-item {
+ display: block;
+ line-height: 65px;
+ border-top: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ border-right: 1px solid $border-color;
+ background-color: $gray-light;
+ cursor: default;
+
+ &.active {
+ background-color: transparent;
+ border-right-color: transparent;
+ border-top-color: $border-color;
+ border-bottom-color: $border-color;
+ box-shadow: inset 2px 0 0 0 $active-item-blue;
+
+ .stage-name {
+ font-weight: 600;
+ }
+ }
+
+ &:hover:not(.active) {
+ background-color: $gray-lightest;
+ box-shadow: inset 2px 0 0 0 $border-color;
+ }
+
+ &:first-child {
+ border-top: none;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .stage-nav-item-cell {
+ float: left;
+
+ &.stage-name {
+ width: 70%;
+ }
+
+ &.stage-median {
+ width: 30%;
+ }
+ }
+
+ .stage-name {
+ padding-left: 16px;
+ }
+
+ .stage-empty,
+ .not-available {
+ color: $gl-text-color-light;
+ }
+ }
+ }
+
+ .stage-panel-container {
+ width: 100%;
+ overflow: auto;
+ }
+
+ .stage-panel {
+ min-width: 968px;
+
+ .panel-heading {
+ padding: 0;
+ background-color: transparent;
+ }
+
+ .events-description {
+ line-height: 65px;
+ padding-left: $gl-padding;
+ }
+ }
+
+ .stage-events {
+ width: 60%;
+ overflow: scroll;
+ height: 467px;
+ }
+
+ .stage-event-list {
+ margin: 0;
+ padding: 0;
+ }
+
+ .stage-event-item {
+ list-style-type: none;
+ padding: 0 0 $gl-padding;
+ margin: 0 $gl-padding $gl-padding;
+ border-bottom: 1px solid $gray-darker;
+ @include clearfix;
+
+ &:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+ }
+
+ .item-details,
+ .item-time {
+ float: left;
+ }
+
+ .item-details {
+ width: 75%;
+ }
+
+ .item-title {
+ margin: 0 0 2px;
+
+ &.issue-title,
+ &.commit-title,
+ &.merge-merquest-title {
+ max-width: 100%;
+ display: block;
+ @include text-overflow();
+
+ a {
+ color: $gl-dark-link-color;
+ }
+ }
+ }
+
+ .item-time {
+ width: 25%;
+ text-align: right;
+ }
+
+ .total-time {
+ font-size: $cycle-analytics-big-font;
+ color: $cycle-analytics-dark-text;
+
+ span {
+ color: $gl-text-color;
+ font-size: $gl-font-size;
+ }
+ }
+
+ .issue-date,
+ .build-date {
+ color: $gl-text-color;
+ }
+
+ .issue-link,
+ .commit-author-link,
+ .issue-author-link {
+ color: $gl-dark-link-color;
+ }
+
+ // Custom CSS for components
+ .item-conmmit-component {
+ .commit-icon {
+ position: relative;
+ top: 3px;
+ left: 1px;
+ display: inline-block;
+
+ svg {
+ float: left;
+ }
+ }
+ }
+
+ .merge-request-branch {
+ a {
+ max-width: 180px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: inline-block;
+ vertical-align: bottom;
+ }
+ }
+ }
+
+ // Custom Styles for stage items
+ .item-build-component {
+
+ .item-title {
+ .icon-build-status {
+ float: left;
+ margin-right: 5px;
+ position: relative;
+ top: 2px;
+ }
+
+ .item-build-name {
+ color: $gl-title-color;
+ }
+
+ .pipeline-id {
+ color: $gl-title-color;
+ padding: 0 3px 0 0;
+ }
+
+ .branch-name {
+ color: $black;
+ display: inline-block;
+ max-width: 180px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ line-height: 1.3;
+ vertical-align: top;
+ }
+
+ .short-sha {
+ color: $gl-link-color;
+ line-height: 1.3;
+ vertical-align: top;
+ font-weight: normal;
+ }
+
+ .fa {
+ color: $gl-text-color-light;
+ font-size: $code_font_size;
+ }
+ }
+ }
+
+ .empty-stage,
+ .no-access-stage {
+ text-align: center;
+ width: 75%;
+ margin: 0 auto;
+ padding-top: 130px;
+ color: $gl-text-color-light;
+
+ h4 {
+ color: $gl-text-color;
+ }
+ }
+
+ .empty-stage {
+ .icon-no-data {
+ height: 36px;
+ width: 78px;
+ display: inline-block;
+ margin-bottom: 20px;
+ }
+ }
+
+ .no-access-stage {
+ .icon-lock {
+ height: 36px;
+ width: 78px;
+ display: inline-block;
+ margin-bottom: 20px;
+ }
+ }
+}
+
+.cycle-analytics-overview {
+ padding-top: 100px;
+
+ .overview-details {
+ display: flex;
+ align-items: center;
+ }
+
+ .overview-image {
+ text-align: right;
+ }
+
+ .overview-icon {
+ svg {
+ width: 365px;
+ height: 227px;
+ }
+ }
}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index fc49ff780fc..e9ff43a8adb 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -1,10 +1,23 @@
-.environments-container,
.deployments-container {
width: 100%;
overflow: auto;
}
+.environments-list-loading {
+ width: 100%;
+ font-size: 34px;
+}
+
+@media (max-width: $screen-sm-min) {
+ .environments-container {
+ width: 100%;
+ overflow: auto;
+ }
+}
+
.environments {
+ table-layout: fixed;
+
.deployment-column {
.avatar {
float: none;
@@ -15,6 +28,10 @@
margin: 0;
}
+ .avatar-image-container {
+ text-decoration: none;
+ }
+
.icon-play {
height: 13px;
width: 12px;
@@ -38,7 +55,8 @@
color: $gl-dark-link-color;
}
- .stop-env-link {
+ .stop-env-link,
+ .external-url {
color: $table-text-gray;
.stop-env-icon {
@@ -58,10 +76,29 @@
}
}
}
+
+ .children-row .environment-name {
+ margin-left: 17px;
+ margin-right: -17px;
+ }
+
+ .folder-icon {
+ padding: 0 5px 0 0;
+ }
+
+ .folder-name {
+ cursor: pointer;
+
+ .badge {
+ font-weight: normal;
+ background-color: $gray-darker;
+ color: $gl-placeholder-color;
+ vertical-align: baseline;
+ }
+ }
}
.table.ci-table.environments {
-
.icon-container {
width: 20px;
text-align: center;
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 397f89f501a..e39ce19f846 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -90,7 +90,7 @@
@media (min-width: $screen-sm-min) {
display: inline-block;
- width: 40%;
+ width: 30%;
margin-left: 10px;
margin-bottom: 0;
vertical-align: middle;
@@ -222,6 +222,14 @@
width: 100%;
}
+.label-subscription {
+ vertical-align: middle;
+
+ .dropdown-group-label a {
+ cursor: pointer;
+ }
+}
+
.label-subscribe-button {
.label-subscribe-button-icon {
&[disabled] {
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 10f67b47998..54c89d75e94 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -255,26 +255,3 @@
}
}
-// For sign in pane only, to improve tab order, the following removes the submit button from
-// normal document flow and pins it to the bottom of the form. For context, see !6867 & !6928
-
-.login-box {
- .new_user {
- position: relative;
- padding-bottom: 35px;
-
- @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- .forgot-password {
- float: none !important;
- margin-top: 5px;
- }
- }
- }
-
- .move-submit-down {
- position: absolute;
- width: 100%;
- bottom: 0;
- }
-}
-
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 6cf43713fec..b6a82460f25 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -61,7 +61,7 @@
}
.ci_widget {
- border-bottom: 1px solid $widget-inner-border;
+ border-bottom: 1px solid $well-inner-border;
svg {
margin-right: 4px;
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index 13402acd8e1..8843d1463db 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -11,6 +11,7 @@
}
.progress {
+ width: 100%;
height: 6px;
}
}
@@ -30,7 +31,6 @@
margin-right: 7px;
}
- // Issue title
span a {
color: $gl-text-color;
word-wrap: break-word;
@@ -39,15 +39,66 @@
}
.milestone-summary {
- margin-bottom: 25px;
-
.milestone-stat {
+ white-space: nowrap;
margin-right: 10px;
+
+ &.with-drilldown {
+ margin-right: 2px;
+ }
}
.remaining-days {
color: $orange-light;
}
+
+ .milestone-stats-and-buttons {
+ display: flex;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+
+ @media (min-width: $screen-xs-min) {
+ justify-content: space-between;
+ flex-wrap: nowrap;
+ }
+ }
+
+ .milestone-progress-buttons {
+ order: 1;
+ margin-top: 10px;
+
+ @media (min-width: $screen-xs-min) {
+ order: 2;
+ margin-top: 0;
+ flex-shrink: 0;
+ }
+
+ .btn {
+ float: left;
+ margin-right: $btn-side-margin;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+
+ .milestone-stats {
+ order: 2;
+ width: 100%;
+ padding: 7px 0;
+ flex-shrink: 1;
+
+ @media (min-width: $screen-xs-min) {
+ // when displayed on one line stats go first, buttons second
+ order: 1;
+ }
+ }
+
+ .progress {
+ width: 100%;
+ margin: 15px 0;
+ }
}
.issues-sortable-list,
@@ -82,3 +133,50 @@
}
}
}
+
+.milestone-page-header {
+ display: flex;
+ flex-flow: row;
+ align-items: center;
+ flex-wrap: wrap;
+
+ .status-box {
+ margin-top: 0;
+ }
+
+ .milestone-buttons {
+ margin-left: auto;
+ }
+
+ .status-box {
+ order: 1;
+ }
+
+ .milestone-buttons {
+ order: 2;
+ }
+
+ .header-text-content {
+ order: 3;
+ width: 100%;
+ }
+
+ .milestone-buttons .verbose {
+ display: none;
+ }
+
+ @media (min-width: $screen-xs-min) {
+ .milestone-buttons .verbose {
+ display: inline;
+ }
+
+ .header-text-content {
+ order: 2;
+ width: auto;
+ }
+
+ .milestone-buttons {
+ order: 3;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 9bfa1c96a5d..0dfd4ab7ec9 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -35,11 +35,84 @@ ul.notes {
.system-note {
font-size: 14px;
- padding-top: 10px;
- padding-bottom: 10px;
- background: #fdfdfd;
+ padding: 0;
+ clear: both;
+
+ &.timeline-entry::after {
+ clear: none;
+ }
+
+ .system-note-message {
+ text-transform: lowercase;
+
+ a {
+ color: $gl-link-color;
+ text-decoration: none;
+ }
+ }
+
+ .timeline-content {
+ padding: 14px 10px;
+ }
+
+ .note-body {
+ overflow: hidden;
+
+ .system-note-commit-list-toggler {
+ display: none;
+ padding: 10px 0 0;
+ cursor: pointer;
+ }
+
+ .note-text {
+ & p:first-child {
+ display: none;
+ }
+
+ &.system-note-commit-list {
+ max-height: 63px;
+ overflow: hidden;
+ display: block;
+
+ ul {
+ margin: 3px 0 3px 15px !important;
+
+ li {
+ font-family: $monospace_font;
+ font-size: 12px;
+ }
+ }
+
+ p:first-child {
+ display: none;
+ }
+
+ &::after {
+ content: '';
+ width: 100%;
+ height: 20px;
+ position: absolute;
+ left: 0;
+ bottom: 50px;
+ background: linear-gradient(rgba($gray-light, .3) 0, $white-light);
+ }
+
+ &.hide-shade {
+ max-height: 100%;
+ overflow: auto;
+
+ &::after {
+ display: none;
+ background: transparent;
+ }
+ }
+ }
+ }
+ }
.timeline-icon {
+ display: none;
+
.avatar {
visibility: hidden;
@@ -65,6 +138,12 @@ ul.notes {
position: relative;
border-bottom: 1px solid $table-border-gray;
+ &.note-discussion {
+ &.timeline-entry {
+ padding: 14px 10px;
+ }
+ }
+
&.is-editting {
.note-header,
.note-text,
@@ -88,10 +167,8 @@ ul.notes {
overflow: auto;
word-wrap: break-word;
@include md-typography;
-
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
-
ul.task-list {
ul:not(.task-list) {
padding-left: 1.3em;
@@ -111,6 +188,11 @@ ul.notes {
padding-bottom: 3px;
padding-right: 20px;
+ p {
+ display: inline;
+ margin: 0;
+ }
+
@media (min-width: $screen-sm-min) {
padding-right: 0;
}
@@ -238,6 +320,10 @@ ul.notes {
}
}
+.discussion-header {
+ font-size: 14px;
+}
+
.note-headline-light,
.discussion-headline-light {
color: $notes-light-color;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 881621a2655..a44a496c728 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -300,6 +300,8 @@
.pipeline-graph {
width: 100%;
+ background-color: $background-color;
+ padding: $gl-padding;
overflow: auto;
white-space: nowrap;
transition: max-height 0.3s, padding 0.3s;
@@ -363,6 +365,7 @@
.build {
border: 1px solid $border-color;
+ background-color: $white-light;
position: relative;
padding: 7px 10px 8px;
border-radius: 30px;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index ad46a2a9128..19a7a97ea0d 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -145,6 +145,10 @@
}
}
+.nav > .project-repo-buttons {
+ margin-top: 0;
+}
+
.project-repo-buttons,
.group-buttons {
margin-top: 15px;
@@ -184,6 +188,12 @@
margin-left: 10px;
}
+ .download-button {
+ @media (max-width: $screen-lg-min) {
+ margin-left: 0;
+ }
+ }
+
.count-buttons {
display: inline-block;
vertical-align: top;
@@ -468,6 +478,20 @@ a.deploy-project-label {
}
}
+.page-sidebar-pinned {
+ .project-stats .nav > li.right {
+ @media (min-width: $screen-lg-min) {
+ float: none;
+ }
+ }
+
+ .download-button {
+ @media (min-width: $screen-lg-min) {
+ margin-left: 0;
+ }
+ }
+}
+
.project-stats {
font-size: 0;
border-bottom: 1px solid $border-color;
@@ -485,9 +509,11 @@ a.deploy-project-label {
}
&.right {
- @media (min-width: $screen-md-min) {
+ vertical-align: top;
+ margin-top: 0;
+
+ @media (min-width: $screen-lg-min) {
float: right;
- margin-top: 0;
}
}
}
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index daa82336208..5c44637fdee 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -55,7 +55,13 @@ class AutocompleteController < ApplicationController
def find_users
@users =
if @project
- @project.team.users
+ user_ids = @project.team.users.pluck(:id)
+
+ if params[:author_id].present?
+ user_ids << params[:author_id]
+ end
+
+ User.where(id: user_ids)
elsif params[:group_id].present?
group = Group.find(params[:group_id])
return render_404 unless can?(current_user, :read_group, group)
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
new file mode 100644
index 00000000000..2aaf8f2b451
--- /dev/null
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -0,0 +1,7 @@
+module CycleAnalyticsParams
+ extend ActiveSupport::Concern
+
+ def start_date(params)
+ params[:start_date] == '30' ? 30.days.ago : 90.days.ago
+ end
+end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index be86fa106f8..0821974aa93 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -12,7 +12,7 @@ module IssuableActions
destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym
TodoService.new.public_send(destroy_method, issuable, current_user)
- name = issuable.class.name.titleize.downcase
+ name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted."
redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index b5e79099e39..6247934f81e 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -10,11 +10,11 @@ module IssuableCollections
private
def issues_collection
- issues_finder.execute
+ issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace)
end
def merge_requests_collection
- merge_requests_finder.execute
+ merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace)
end
def issues_finder
diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb
index b89fb94be6e..b46adcceb60 100644
--- a/app/controllers/concerns/issues_action.rb
+++ b/app/controllers/concerns/issues_action.rb
@@ -7,7 +7,6 @@ module IssuesAction
@issues = issues_collection
.non_archived
- .preload(:author, :project)
.page(params[:page])
respond_to do |format|
diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb
index a1b0eee37f9..6546a07b41c 100644
--- a/app/controllers/concerns/merge_requests_action.rb
+++ b/app/controllers/concerns/merge_requests_action.rb
@@ -7,7 +7,6 @@ module MergeRequestsAction
@merge_requests = merge_requests_collection
.non_archived
- .preload(:author, :target_project)
.page(params[:page])
end
end
diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb
index 9e3b9be2ff4..92cb534343e 100644
--- a/app/controllers/concerns/toggle_subscription_action.rb
+++ b/app/controllers/concerns/toggle_subscription_action.rb
@@ -4,13 +4,17 @@ module ToggleSubscriptionAction
def toggle_subscription
return unless current_user
- subscribable_resource.toggle_subscription(current_user)
+ subscribable_resource.toggle_subscription(current_user, subscribable_project)
head :ok
end
private
+ def subscribable_project
+ @project || raise(NotImplementedError)
+ end
+
def subscribable_resource
raise NotImplementedError
end
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index 29528b2cfaa..587898a8634 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -1,4 +1,6 @@
class Groups::LabelsController < Groups::ApplicationController
+ include ToggleSubscriptionAction
+
before_action :label, only: [:edit, :update, :destroy]
before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :destroy]
before_action :save_previous_label_path, only: [:edit]
@@ -69,6 +71,11 @@ class Groups::LabelsController < Groups::ApplicationController
def label
@label ||= @group.labels.find(params[:id])
end
+ alias_method :subscribable_resource, :label
+
+ def subscribable_project
+ nil
+ end
def label_params
params.require(:label).permit(:title, :description, :color)
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
new file mode 100644
index 00000000000..6a1f468ba5a
--- /dev/null
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -0,0 +1,64 @@
+class Profiles::ChatNamesController < Profiles::ApplicationController
+ before_action :chat_name_token, only: [:new]
+ before_action :chat_name_params, only: [:new, :create, :deny]
+
+ def index
+ @chat_names = current_user.chat_names
+ end
+
+ def new
+ end
+
+ def create
+ new_chat_name = current_user.chat_names.new(chat_name_params)
+
+ if new_chat_name.save
+ flash[:notice] = "Authorized #{new_chat_name.chat_name}"
+ else
+ flash[:alert] = "Could not authorize chat nickname. Try again!"
+ end
+
+ delete_chat_name_token
+ redirect_to profile_chat_names_path
+ end
+
+ def deny
+ delete_chat_name_token
+
+ flash[:notice] = "Denied authorization of chat nickname #{chat_name_params[:user_name]}."
+
+ redirect_to profile_chat_names_path
+ end
+
+ def destroy
+ @chat_name = chat_names.find(params[:id])
+
+ if @chat_name.destroy
+ flash[:notice] = "Deleted chat nickname: #{@chat_name.chat_name}!"
+ else
+ flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}."
+ end
+
+ redirect_to profile_chat_names_path
+ end
+
+ private
+
+ def delete_chat_name_token
+ chat_name_token.delete
+ end
+
+ def chat_name_params
+ @chat_name_params ||= chat_name_token.get || render_404
+ end
+
+ def chat_name_token
+ return render_404 unless params[:token] || render_404
+
+ @chat_name_token ||= Gitlab::ChatNameToken.new(params[:token])
+ end
+
+ def chat_names
+ @chat_names ||= current_user.chat_names
+ end
+end
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
new file mode 100644
index 00000000000..13b3eec761f
--- /dev/null
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -0,0 +1,65 @@
+module Projects
+ module CycleAnalytics
+ class EventsController < Projects::ApplicationController
+ include CycleAnalyticsParams
+
+ before_action :authorize_read_cycle_analytics!
+ before_action :authorize_read_build!, only: [:test, :staging]
+ before_action :authorize_read_issue!, only: [:issue, :production]
+ before_action :authorize_read_merge_request!, only: [:code, :review]
+
+ def issue
+ render_events(events.issue_events)
+ end
+
+ def plan
+ render_events(events.plan_events)
+ end
+
+ def code
+ render_events(events.code_events)
+ end
+
+ def test
+ options[:branch] = events_params[:branch_name]
+
+ render_events(events.test_events)
+ end
+
+ def review
+ render_events(events.review_events)
+ end
+
+ def staging
+ render_events(events.staging_events)
+ end
+
+ def production
+ render_events(events.production_events)
+ end
+
+ private
+
+ def render_events(events_list)
+ respond_to do |format|
+ format.html
+ format.json { render json: { events: events_list } }
+ end
+ end
+
+ def events
+ @events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options)
+ end
+
+ def options
+ @options ||= { from: start_date(events_params), current_user: current_user }
+ end
+
+ def events_params
+ return {} unless params[:events].present?
+
+ params[:events].slice(:start_date, :branch_name)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 16a7b1fc6e2..fd263960b93 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -1,11 +1,16 @@
class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::DateHelper
include ActionView::Helpers::TextHelper
+ include CycleAnalyticsParams
before_action :authorize_read_cycle_analytics!
def show
- @cycle_analytics = CycleAnalytics.new(@project, from: parse_start_date)
+ @cycle_analytics = ::CycleAnalytics.new(@project, from: start_date(cycle_analytics_params))
+
+ stats_values, cycle_analytics_json = generate_cycle_analytics_data
+
+ @cycle_analytics_no_data = stats_values.blank?
respond_to do |format|
format.html
@@ -15,37 +20,35 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
private
- def parse_start_date
- case cycle_analytics_params[:start_date]
- when '30' then 30.days.ago
- when '90' then 90.days.ago
- else 90.days.ago
- end
- end
-
def cycle_analytics_params
return {} unless params[:cycle_analytics].present?
{ start_date: params[:cycle_analytics][:start_date] }
end
- def cycle_analytics_json
- cycle_analytics_view_data = [[:issue, "Issue", "Time before an issue gets scheduled"],
- [:plan, "Plan", "Time before an issue starts implementation"],
- [:code, "Code", "Time until first merge request"],
- [:test, "Test", "Total test time for all commits/merges"],
- [:review, "Review", "Time between merge request creation and merge/close"],
- [:staging, "Staging", "From merge request merge until deploy to production"],
- [:production, "Production", "From issue creation until deploy to production"]]
+ def generate_cycle_analytics_data
+ stats_values = []
- stats = cycle_analytics_view_data.reduce([]) do |stats, (stage_method, stage_text, stage_description)|
+ cycle_analytics_view_data = [[:issue, "Issue", "Related Issues", "Time before an issue gets scheduled"],
+ [:plan, "Plan", "Related Commits", "Time before an issue starts implementation"],
+ [:code, "Code", "Related Merge Requests", "Time spent coding"],
+ [:test, "Test", "Relative Builds Trigger by Commits", "The time taken to build and test the application"],
+ [:review, "Review", "Relative Merged Requests", "The time taken to review the code"],
+ [:staging, "Staging", "Relative Deployed Builds", "The time taken in staging"],
+ [:production, "Production", "Related Issues", "The total time taken from idea to production"]]
+
+ stats = cycle_analytics_view_data.reduce([]) do |stats, (stage_method, stage_text, stage_legend, stage_description)|
value = @cycle_analytics.send(stage_method).presence
+ stats_values << value.abs if value
+
stats << {
title: stage_text,
description: stage_description,
+ legend: stage_legend,
value: value && !value.zero? ? distance_of_time_in_words(value) : nil
}
+
stats
end
@@ -59,9 +62,11 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
{ title: "Deploy".pluralize(deploys), value: deploys }
]
- {
- summary: summary,
- stats: stats
+ cycle_analytics_hash = { summary: summary,
+ stats: stats,
+ permissions: @cycle_analytics.permissions(user: current_user)
}
+
+ [stats_values, cycle_analytics_hash]
end
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index ea22b2dcc15..6bd4cb3f2f5 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -8,13 +8,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def index
@scope = params[:scope]
- @all_environments = project.environments
- @environments =
- if @scope == 'stopped'
- @all_environments.stopped
- else
- @all_environments.available
+ @environments = project.environments
+
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: EnvironmentSerializer
+ .new(project: @project)
+ .represent(@environments)
end
+ end
end
def show
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index ade01c706a7..ba46e2528e6 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -4,6 +4,7 @@ class Projects::ForksController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :authorize_download_code!
+ before_action :authenticate_user!, only: [:new, :create]
def index
base_query = project.forks.includes(:creator)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 3f1a1d1c511..4aea7bb62c4 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -69,7 +69,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: @issue.to_json(include: [:milestone, :labels])
+ render json: IssueSerializer.new.represent(@issue)
end
end
end
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 42fd09e9b7e..824ed7be73e 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -3,7 +3,7 @@ class Projects::LabelsController < Projects::ApplicationController
before_action :module_enabled
before_action :label, only: [:edit, :update, :destroy]
- before_action :find_labels, only: [:index, :set_priorities, :remove_priority]
+ before_action :find_labels, only: [:index, :set_priorities, :remove_priority, :toggle_subscription]
before_action :authorize_read_label!
before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update,
:generate, :destroy, :remove_priority,
@@ -123,7 +123,10 @@ class Projects::LabelsController < Projects::ApplicationController
def label
@label ||= @project.labels.find(params[:id])
end
- alias_method :subscribable_resource, :label
+
+ def subscribable_resource
+ @available_labels.find(params[:id])
+ end
def find_labels
@available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index dff0213411c..dbbd2ad849e 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -38,7 +38,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
def index
@merge_requests = merge_requests_collection
@merge_requests = @merge_requests.page(params[:page])
- @merge_requests = @merge_requests.preload(:target_project)
if params[:label_name].present?
labels_params = { project_id: @project.id, title: params[:label_name] }
@@ -61,7 +60,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
format.html { define_discussion_vars }
format.json do
- render json: @merge_request
+ render json: MergeRequestSerializer.new.represent(@merge_request)
end
format.patch do
@@ -83,12 +82,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@merge_request_diff =
if params[:diff_id]
- @merge_request.merge_request_diffs.find(params[:diff_id])
+ @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
else
@merge_request.merge_request_diff
end
- @merge_request_diffs = @merge_request.merge_request_diffs.select_without_diff
+ @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff
@comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
if params[:start_sha].present?
@@ -418,7 +417,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
response = {
title: merge_request.title,
- sha: merge_request.diff_head_commit.short_id,
+ sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha),
status: status,
coverage: coverage
}
@@ -565,7 +564,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
def define_pipelines_vars
@pipelines = @merge_request.all_pipelines
- if @pipelines.present?
+ if @pipelines.present? && @merge_request.commits.present?
@pipeline = @pipelines.first
@statuses = @pipeline.statuses.relevant
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 0948ad21649..f029fde2a2f 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -146,24 +146,26 @@ class Projects::NotesController < Projects::ApplicationController
end
def note_json(note)
+ attrs = {
+ award: false,
+ id: note.id
+ }
+
if note.is_a?(AwardEmoji)
- {
+ attrs.merge!(
valid: note.valid?,
award: true,
- id: note.id,
name: note.name
- }
+ )
elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user)
- attrs = {
+ attrs.merge!(
valid: true,
- id: note.id,
discussion_id: note.discussion_id,
html: note_html(note),
- award: false,
note: note.note
- }
+ )
if note.diff_note?
discussion = note.to_discussion
@@ -188,15 +190,14 @@ class Projects::NotesController < Projects::ApplicationController
attrs[:original_discussion_id] = note.original_discussion_id
end
end
-
- attrs
else
- {
+ attrs.merge!(
valid: false,
- award: false,
errors: note.errors
- }
+ )
end
+
+ attrs
end
def authorize_admin_note!
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 40a23a6f806..30c2a5d9982 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -28,6 +28,8 @@ class Projects::ServicesController < Projects::ApplicationController
end
def test
+ return render_404 unless @service.can_test?
+
data = @service.test_data(project, current_user)
outcome = @service.test(data)
diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb
index 3085ff33aba..04c36b3ebfe 100644
--- a/app/controllers/sent_notifications_controller.rb
+++ b/app/controllers/sent_notifications_controller.rb
@@ -12,7 +12,7 @@ class SentNotificationsController < ApplicationController
def unsubscribe_and_redirect
noteable = @sent_notification.noteable
- noteable.unsubscribe(@sent_notification.recipient)
+ noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project)
flash[:notice] = "You have been unsubscribed from this thread."
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index c4508ccc3b9..6e29f1e8a65 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -86,7 +86,7 @@ class UsersController < ApplicationController
end
def exists
- render json: { exists: Namespace.where(path: params[:username].downcase).any? }
+ render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) }
end
private
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index be5e0301a43..6d10fe3e9d7 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -50,14 +50,14 @@ module ApplicationSettingsHelper
def restricted_level_checkboxes(help_block_id)
Gitlab::VisibilityLevel.options.map do |name, level|
checked = restricted_visibility_levels(true).include?(level)
- css_class = 'btn'
- css_class += ' active' if checked
- checkbox_name = 'application_setting[restricted_visibility_levels][]'
+ css_class = checked ? 'active' : ''
+ checkbox_name = "application_setting[restricted_visibility_levels][]"
- label_tag(checkbox_name, class: css_class) do
+ label_tag(name, class: css_class) do
check_box_tag(checkbox_name, level, checked,
autocomplete: 'off',
- 'aria-describedby' => help_block_id) + name
+ 'aria-describedby' => help_block_id,
+ id: name) + visibility_level_icon(level) + name
end
end
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
new file mode 100644
index 00000000000..27975b7ddb7
--- /dev/null
+++ b/app/helpers/environment_helper.rb
@@ -0,0 +1,29 @@
+module EnvironmentHelper
+ def environment_for_build(project, build)
+ return unless build.environment
+
+ project.environments.find_by(name: build.expanded_environment_name)
+ end
+
+ def environment_link_for_build(project, build)
+ environment = environment_for_build(project, build)
+ if environment
+ link_to environment.name, namespace_project_environment_path(project.namespace, project, environment)
+ else
+ content_tag :span, build.expanded_environment_name
+ end
+ end
+
+ def deployment_link(deployment)
+ return unless deployment
+
+ link_to "##{deployment.iid}", [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable]
+ end
+
+ def last_deployment_link_for_environment_build(project, build)
+ environment = environment_for_build(project, build)
+ return unless environment
+
+ deployment_link(environment.last_deployment)
+ end
+end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
new file mode 100644
index 00000000000..515e802e01e
--- /dev/null
+++ b/app/helpers/environments_helper.rb
@@ -0,0 +1,7 @@
+module EnvironmentsHelper
+ def environments_list_data
+ {
+ endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json)
+ }
+ end
+end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index ab880ed6de0..75cd9eece5c 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -48,4 +48,8 @@ module GroupsHelper
"#{status.humanize} #{projects_lfs_status(group)}"
end
end
+
+ def group_issues(group)
+ IssuesFinder.new(current_user, group_id: group.id).execute
+ end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index ce2cabd7a3a..8bebda07787 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -171,9 +171,11 @@ module IssuablesHelper
def issuables_count_for_state(issuable_type, state)
issuables_finder = public_send("#{issuable_type}_finder")
- issuables_finder.params[:state] = state
+
+ params = issuables_finder.params.merge(state: state)
+ finder = issuables_finder.class.new(issuables_finder.current_user, params)
- issuables_finder.execute.page(1).total_count
+ finder.execute.page(1).total_count
end
IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page]
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 221a84b042f..4f180456b16 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -68,14 +68,6 @@ module LabelsHelper
end
end
- def toggle_subscription_data(label)
- return unless label.is_a?(ProjectLabel)
-
- {
- url: toggle_subscription_namespace_project_label_path(label.project.namespace, label.project, label)
- }
- end
-
def render_colored_label(label, label_suffix = '', tooltip: true)
label_color = label.color || Label::DEFAULT_COLOR
text_color = text_color_for_bg(label_color)
@@ -148,20 +140,24 @@ module LabelsHelper
end
end
- def label_subscription_status(label)
- case label
- when GroupLabel then 'Subscribing to group labels is currently not supported.'
- when ProjectLabel then label.subscribed?(current_user) ? 'subscribed' : 'unsubscribed'
- end
+ def label_subscription_status(label, project)
+ return 'project-level' if label.subscribed?(current_user, project)
+ return 'group-level' if label.subscribed?(current_user)
+
+ 'unsubscribed'
end
- def label_subscription_toggle_button_text(label)
- case label
- when GroupLabel then 'Subscribing to group labels is currently not supported.'
- when ProjectLabel then label.subscribed?(current_user) ? 'Unsubscribe' : 'Subscribe'
+ def group_label_unsubscribe_path(label, project)
+ case label_subscription_status(label, project)
+ when 'project-level' then toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)
+ when 'group-level' then toggle_subscription_group_label_path(label.group, label)
end
end
+ def label_subscription_toggle_button_text(label, project)
+ label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe'
+ end
+
def label_deletion_confirm_text(label)
case label
when GroupLabel then 'Remove this label? This will affect all projects within the group. Are you sure?'
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index a46f2c6e17d..6e68aad4cb7 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -50,7 +50,7 @@ module PreferencesHelper
end
def default_project_view
- return 'readme' unless current_user
+ return anonymous_project_view unless current_user
user_view = current_user.project_view
@@ -66,4 +66,8 @@ module PreferencesHelper
"customize_workflow"
end
end
+
+ def anonymous_project_view
+ @project.empty_repo? || !can?(current_user, :download_code, @project) ? 'activity' : 'readme'
+ end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 77075e49b17..704a80dd958 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -457,4 +457,8 @@ module ProjectsHelper
def project_child_container_class(view_path)
view_path == "projects/issues/issues" ? "prepend-top-default" : "project-show-#{view_path}"
end
+
+ def project_issues(project)
+ IssuesFinder.new(current_user, project_id: project.id).execute
+ end
end
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 3d4abf76419..9bab140e60a 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -17,6 +17,8 @@ module ServicesHelper
"Event will be triggered when a build status changes"
when "wiki_page"
"Event will be triggered when a wiki page is created/updated"
+ when "commit"
+ "Event will be triggered when a commit is created/updated"
end
end
diff --git a/app/helpers/triggers_helper.rb b/app/helpers/triggers_helper.rb
index c41181bab3d..b0135ea2e95 100644
--- a/app/helpers/triggers_helper.rb
+++ b/app/helpers/triggers_helper.rb
@@ -6,4 +6,8 @@ module TriggersHelper
"#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds"
end
end
+
+ def service_trigger_url(service)
+ "#{Settings.gitlab.url}/api/v3/projects/#{service.project_id}/services/#{service.to_param}/trigger"
+ end
end
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
new file mode 100644
index 00000000000..f321db75eeb
--- /dev/null
+++ b/app/models/chat_name.rb
@@ -0,0 +1,12 @@
+class ChatName < ActiveRecord::Base
+ belongs_to :service
+ belongs_to :user
+
+ validates :user, presence: true
+ validates :service, presence: true
+ validates :team_id, presence: true
+ validates :chat_id, presence: true
+
+ validates :user_id, uniqueness: { scope: [:service_id] }
+ validates :chat_id, uniqueness: { scope: [:service_id, :team_id] }
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 33612256540..e7d33bd26db 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -7,6 +7,8 @@ module Ci
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
+ has_many :deployments, as: :deployable
+
serialize :options
serialize :yaml_variables
@@ -68,7 +70,11 @@ module Ci
environment: build.environment,
status_event: 'enqueue'
)
- MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build)
+
+ MergeRequests::AddTodoWhenBuildFailsService
+ .new(build.project, nil)
+ .close(new_build)
+
build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build
end
@@ -125,6 +131,34 @@ module Ci
!self.pipeline.statuses.latest.include?(self)
end
+ def expanded_environment_name
+ ExpandVariables.expand(environment, variables) if environment
+ end
+
+ def has_environment?
+ self.environment.present?
+ end
+
+ def starts_environment?
+ has_environment? && self.environment_action == 'start'
+ end
+
+ def stops_environment?
+ has_environment? && self.environment_action == 'stop'
+ end
+
+ def environment_action
+ self.options.fetch(:environment, {}).fetch(:action, 'start')
+ end
+
+ def outdated_deployment?
+ success? && !last_deployment.try(:last?)
+ end
+
+ def last_deployment
+ deployments.last
+ end
+
def depends_on_builds
# Get builds of the same type
latest_builds = self.pipeline.builds.latest
@@ -453,6 +487,10 @@ module Ci
]
end
+ def credentials
+ Gitlab::Ci::Build::Credentials::Factory.new(self).create!
+ end
+
private
def update_artifacts_size
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 664bb594aa9..69d8afc45da 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -215,7 +215,7 @@ module Issuable
end
end
- def subscribed_without_subscriptions?(user)
+ def subscribed_without_subscriptions?(user, project)
participants(user).include?(user)
end
@@ -251,6 +251,17 @@ module Issuable
self.class.to_ability_name
end
+ # Convert this Issuable class name to a format usable by notifications.
+ #
+ # Examples:
+ #
+ # issuable.class # => MergeRequest
+ # issuable.human_class_name # => "merge request"
+
+ def human_class_name
+ @human_class_name ||= self.class.name.titleize.downcase
+ end
+
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index eb2ff0428f6..8ab0401d288 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -1,6 +1,6 @@
# == Mentionable concern
#
-# Contains functionality related to objects that can mention Users, Issues, MergeRequests, or Commits by
+# Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by
# GFM references.
#
# Used by Issue, Note, MergeRequest, and Commit.
diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb
new file mode 100644
index 00000000000..50a1d7fc3e1
--- /dev/null
+++ b/app/models/concerns/select_for_project_authorization.rb
@@ -0,0 +1,9 @@
+module SelectForProjectAuthorization
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def select_for_project_authorization
+ select("members.user_id, projects.id AS project_id, members.access_level")
+ end
+ end
+end
diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb
index 083257f1005..83daa9b1a64 100644
--- a/app/models/concerns/subscribable.rb
+++ b/app/models/concerns/subscribable.rb
@@ -12,39 +12,71 @@ module Subscribable
has_many :subscriptions, dependent: :destroy, as: :subscribable
end
- def subscribed?(user)
- if subscription = subscriptions.find_by_user_id(user.id)
+ def subscribed?(user, project = nil)
+ if subscription = subscriptions.find_by(user: user, project: project)
subscription.subscribed
else
- subscribed_without_subscriptions?(user)
+ subscribed_without_subscriptions?(user, project)
end
end
# Override this method to define custom logic to consider a subscribable as
# subscribed without an explicit subscription record.
- def subscribed_without_subscriptions?(user)
+ def subscribed_without_subscriptions?(user, project)
false
end
- def subscribers
- subscriptions.where(subscribed: true).map(&:user)
+ def subscribers(project)
+ subscriptions_available(project).
+ where(subscribed: true).
+ map(&:user)
end
- def toggle_subscription(user)
- subscriptions.
- find_or_initialize_by(user_id: user.id).
- update(subscribed: !subscribed?(user))
+ def toggle_subscription(user, project = nil)
+ unsubscribe_from_other_levels(user, project)
+
+ find_or_initialize_subscription(user, project).
+ update(subscribed: !subscribed?(user, project))
+ end
+
+ def subscribe(user, project = nil)
+ unsubscribe_from_other_levels(user, project)
+
+ find_or_initialize_subscription(user, project)
+ .update(subscribed: true)
+ end
+
+ def unsubscribe(user, project = nil)
+ unsubscribe_from_other_levels(user, project)
+
+ find_or_initialize_subscription(user, project)
+ .update(subscribed: false)
end
- def subscribe(user)
+ private
+
+ def unsubscribe_from_other_levels(user, project)
+ other_subscriptions = subscriptions.where(user: user)
+
+ other_subscriptions =
+ if project.blank?
+ other_subscriptions.where.not(project: nil)
+ else
+ other_subscriptions.where(project: nil)
+ end
+
+ other_subscriptions.update_all(subscribed: false)
+ end
+
+ def find_or_initialize_subscription(user, project)
subscriptions.
- find_or_initialize_by(user_id: user.id).
- update(subscribed: true)
+ find_or_initialize_by(user_id: user.id, project_id: project.try(:id))
end
- def unsubscribe(user)
+ def subscriptions_available(project)
+ t = Subscription.arel_table
+
subscriptions.
- find_or_initialize_by(user_id: user.id).
- update(subscribed: false)
+ where(t[:project_id].eq(nil).or(t[:project_id].eq(project.try(:id))))
end
end
diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb
index 8ed4a56b19b..cb8e088d21d 100644
--- a/app/models/cycle_analytics.rb
+++ b/app/models/cycle_analytics.rb
@@ -1,103 +1,61 @@
class CycleAnalytics
- include Gitlab::Database::Median
- include Gitlab::Database::DateTime
-
- DEPLOYMENT_METRIC_STAGES = %i[production staging]
+ STAGES = %i[issue plan code test review staging production].freeze
def initialize(project, from:)
@project = project
@from = from
+ @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil)
end
def summary
@summary ||= Summary.new(@project, from: @from)
end
+ def permissions(user:)
+ Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project)
+ end
+
def issue
- calculate_metric(:issue,
+ @fetcher.calculate_metric(:issue,
Issue.arel_table[:created_at],
[Issue::Metrics.arel_table[:first_associated_with_milestone_at],
Issue::Metrics.arel_table[:first_added_to_board_at]])
end
def plan
- calculate_metric(:plan,
+ @fetcher.calculate_metric(:plan,
[Issue::Metrics.arel_table[:first_associated_with_milestone_at],
Issue::Metrics.arel_table[:first_added_to_board_at]],
Issue::Metrics.arel_table[:first_mentioned_in_commit_at])
end
def code
- calculate_metric(:code,
+ @fetcher.calculate_metric(:code,
Issue::Metrics.arel_table[:first_mentioned_in_commit_at],
MergeRequest.arel_table[:created_at])
end
def test
- calculate_metric(:test,
+ @fetcher.calculate_metric(:test,
MergeRequest::Metrics.arel_table[:latest_build_started_at],
MergeRequest::Metrics.arel_table[:latest_build_finished_at])
end
def review
- calculate_metric(:review,
+ @fetcher.calculate_metric(:review,
MergeRequest.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:merged_at])
end
def staging
- calculate_metric(:staging,
+ @fetcher.calculate_metric(:staging,
MergeRequest::Metrics.arel_table[:merged_at],
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
end
def production
- calculate_metric(:production,
+ @fetcher.calculate_metric(:production,
Issue.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
end
-
- private
-
- def calculate_metric(name, start_time_attrs, end_time_attrs)
- cte_table = Arel::Table.new("cte_table_for_#{name}")
-
- # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time).
- # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time).
- # We compute the (end_time - start_time) interval, and give it an alias based on the current
- # cycle analytics stage.
- interval_query = Arel::Nodes::As.new(
- cte_table,
- subtract_datetimes(base_query_for(name), end_time_attrs, start_time_attrs, name.to_s))
-
- median_datetime(cte_table, interval_query, name)
- end
-
- # Join table with a row for every <issue,merge_request> pair (where the merge request
- # closes the given issue) with issue and merge request metrics included. The metrics
- # are loaded with an inner join, so issues / merge requests without metrics are
- # automatically excluded.
- def base_query_for(name)
- arel_table = MergeRequestsClosingIssues.arel_table
-
- # Load issues
- query = arel_table.join(Issue.arel_table).on(Issue.arel_table[:id].eq(arel_table[:issue_id])).
- join(Issue::Metrics.arel_table).on(Issue.arel_table[:id].eq(Issue::Metrics.arel_table[:issue_id])).
- where(Issue.arel_table[:project_id].eq(@project.id)).
- where(Issue.arel_table[:deleted_at].eq(nil)).
- where(Issue.arel_table[:created_at].gteq(@from))
-
- # Load merge_requests
- query = query.join(MergeRequest.arel_table, Arel::Nodes::OuterJoin).
- on(MergeRequest.arel_table[:id].eq(arel_table[:merge_request_id])).
- join(MergeRequest::Metrics.arel_table).
- on(MergeRequest.arel_table[:id].eq(MergeRequest::Metrics.arel_table[:merge_request_id]))
-
- if DEPLOYMENT_METRIC_STAGES.include?(name)
- # Limit to merge requests that have been deployed to production after `@from`
- query.where(MergeRequest::Metrics.arel_table[:first_deployed_to_production_at].gteq(@from))
- end
-
- query
- end
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 5278efd71d2..a7f4156fc2e 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -19,7 +19,7 @@ class Environment < ActiveRecord::Base
allow_nil: true,
addressable_url: true
- delegate :stop_action, to: :last_deployment, allow_nil: true
+ delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true
scope :available, -> { with_state(:available) }
scope :stopped, -> { with_state(:stopped) }
@@ -99,4 +99,12 @@ class Environment < ActiveRecord::Base
stop
stop_action.play(current_user)
end
+
+ def actions_for(environment)
+ return [] unless manual_actions
+
+ manual_actions.select do |action|
+ action.expanded_environment_name == environment
+ end
+ end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index d9e90cd256a..73b0f1c6572 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -5,6 +5,7 @@ class Group < Namespace
include Gitlab::VisibilityLevel
include AccessRequestable
include Referable
+ include SelectForProjectAuthorization
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source
alias_method :members, :group_members
@@ -61,6 +62,14 @@ class Group < Namespace
def visible_to_user(user)
where(id: user.authorized_groups.select(:id).reorder(nil))
end
+
+ def select_for_project_authorization
+ if current_scope.joins_values.include?(:shared_projects)
+ select("members.user_id, projects.id AS project_id, project_group_links.group_access")
+ else
+ super
+ end
+ end
end
def to_reference(_from_project = nil)
@@ -176,4 +185,8 @@ class Group < Namespace
def system_hook_service
SystemHooksService.new
end
+
+ def refresh_members_authorized_projects
+ UserProjectAccessChangedService.new(users.pluck(:id)).execute
+ end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 4a4017003d8..6e8f5d3c422 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -266,7 +266,7 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:subscribed] = subscribed?(options[:user]) if options.has_key?(:user) && options[:user]
+ json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user]
if options.has_key?(:labels)
json[:labels] = labels.as_json(
diff --git a/app/models/member.rb b/app/models/member.rb
index b89ba8ecbb8..7be2665bf48 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -113,6 +113,8 @@ class Member < ActiveRecord::Base
member.save
end
+ UserProjectAccessChangedService.new(user.id).execute if user.is_a?(User)
+
member
end
@@ -239,6 +241,7 @@ class Member < ActiveRecord::Base
end
def post_create_hook
+ UserProjectAccessChangedService.new(user.id).execute
system_hook_service.execute_hooks_for(self, :create)
end
@@ -247,9 +250,19 @@ class Member < ActiveRecord::Base
end
def post_destroy_hook
+ refresh_member_authorized_projects
system_hook_service.execute_hooks_for(self, :destroy)
end
+ def refresh_member_authorized_projects
+ # If user/source is being destroyed, project access are gonna be destroyed eventually
+ # because of DB foreign keys, so we shouldn't bother with refreshing after each
+ # member is destroyed through association
+ return if destroyed_by_association.present?
+
+ UserProjectAccessChangedService.new(user_id).execute
+ end
+
def after_accept_invite
post_create_hook
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 9d3eab52189..6c3c093d084 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -686,7 +686,7 @@ class MergeRequest < ActiveRecord::Base
def mergeable_ci_state?
return true unless project.only_allow_merge_if_build_succeeds?
- !pipeline || pipeline.success?
+ !pipeline || pipeline.success? || pipeline.skipped?
end
def environments
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 99c49a020c9..cdc408738be 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -1,5 +1,6 @@
class MergeRequest::Metrics < ActiveRecord::Base
belongs_to :merge_request
+ belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
def record!
if merge_request.merged? && self.merged_at.blank?
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index dd65a9a8b86..58a24eb84cb 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -11,6 +11,9 @@ class MergeRequestDiff < ActiveRecord::Base
belongs_to :merge_request
+ serialize :st_commits
+ serialize :st_diffs
+
state_machine :state, initial: :empty do
state :collected
state :overflow
@@ -22,8 +25,7 @@ class MergeRequestDiff < ActiveRecord::Base
state :overflow_diff_lines_limit
end
- serialize :st_commits
- serialize :st_diffs
+ scope :viewable, -> { without_state(:empty) }
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
diff --git a/app/models/project.rb b/app/models/project.rb
index f9bcc547c36..f8a54324341 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -13,6 +13,7 @@ class Project < ActiveRecord::Base
include CaseSensitivity
include TokenAuthenticatable
include ProjectFeaturesCompatibility
+ include SelectForProjectAuthorization
extend Gitlab::ConfigHelper
@@ -23,7 +24,9 @@ class Project < ActiveRecord::Base
cache_markdown_field :description, pipeline: :description
- delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, to: :project_feature, allow_nil: true
+ delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
+ :merge_requests_enabled?, :issues_enabled?, to: :project_feature,
+ allow_nil: true
default_value_for :archived, false
default_value_for :visibility_level, gitlab_config_features.visibility_level
@@ -75,6 +78,7 @@ class Project < ActiveRecord::Base
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit, dependent: :destroy
+ has_many :chat_services
# Project services
has_one :campfire_service, dependent: :destroy
@@ -89,6 +93,7 @@ class Project < ActiveRecord::Base
has_one :assembla_service, dependent: :destroy
has_one :asana_service, dependent: :destroy
has_one :gemnasium_service, dependent: :destroy
+ has_one :mattermost_slash_commands_service, dependent: :destroy
has_one :slack_service, dependent: :destroy
has_one :buildkite_service, dependent: :destroy
has_one :bamboo_service, dependent: :destroy
@@ -1081,7 +1086,7 @@ class Project < ActiveRecord::Base
"refs/heads/#{branch}",
force: true)
repository.copy_gitattributes(branch)
- repository.expire_avatar_cache(branch)
+ repository.expire_avatar_cache
reload_default_branch
end
@@ -1289,16 +1294,10 @@ class Project < ActiveRecord::Base
# Checks if `user` is authorized for this project, with at least the
# `min_access_level` (if given).
- #
- # If you change the logic of this method, please also update `User#authorized_projects`
def authorized_for_user?(user, min_access_level = nil)
return false unless user
- return true if personal? && namespace_id == user.namespace_id
-
- authorized_for_user_by_group?(user, min_access_level) ||
- authorized_for_user_by_members?(user, min_access_level) ||
- authorized_for_user_by_shared_projects?(user, min_access_level)
+ user.authorized_project?(self, min_access_level)
end
def append_or_update_attribute(name, value)
@@ -1358,30 +1357,6 @@ class Project < ActiveRecord::Base
current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end
- def authorized_for_user_by_group?(user, min_access_level)
- member = user.group_members.find_by(source_id: group)
-
- member && (!min_access_level || member.access_level >= min_access_level)
- end
-
- def authorized_for_user_by_members?(user, min_access_level)
- member = members.find_by(user_id: user)
-
- member && (!min_access_level || member.access_level >= min_access_level)
- end
-
- def authorized_for_user_by_shared_projects?(user, min_access_level)
- shared_projects = user.group_members.joins(group: :shared_projects).
- where(project_group_links: { project_id: self })
-
- if min_access_level
- members_scope = { access_level: Gitlab::Access.values.select { |access| access >= min_access_level } }
- shared_projects = shared_projects.where(members: members_scope)
- end
-
- shared_projects.any?
- end
-
# Similar to the normal callbacks that hook into the life cycle of an
# Active Record object, you can also define callbacks that get triggered
# when you add an object to an association collection. If any of these
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
new file mode 100644
index 00000000000..a00d43773d9
--- /dev/null
+++ b/app/models/project_authorization.rb
@@ -0,0 +1,8 @@
+class ProjectAuthorization < ActiveRecord::Base
+ belongs_to :user
+ belongs_to :project
+
+ validates :project, presence: true
+ validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
+ validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
+end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 5c53c8f1ee5..03194fc2141 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -60,6 +60,10 @@ class ProjectFeature < ActiveRecord::Base
merge_requests_access_level > DISABLED
end
+ def issues_enabled?
+ issues_access_level > DISABLED
+ end
+
private
# Validates builds and merge requests access level
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index db46def11eb..6149c35cc61 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -16,6 +16,9 @@ class ProjectGroupLink < ActiveRecord::Base
validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true
validate :different_group
+ after_create :refresh_group_members_authorized_projects
+ after_destroy :refresh_group_members_authorized_projects
+
def self.access_options
Gitlab::Access.options
end
@@ -35,4 +38,8 @@ class ProjectGroupLink < ActiveRecord::Base
errors.add(:base, "Project cannot be shared with the project it is in.")
end
end
+
+ def refresh_group_members_authorized_projects
+ group.refresh_members_authorized_projects
+ end
end
diff --git a/app/models/project_services/chat_service.rb b/app/models/project_services/chat_service.rb
new file mode 100644
index 00000000000..d36beff5fa6
--- /dev/null
+++ b/app/models/project_services/chat_service.rb
@@ -0,0 +1,21 @@
+# Base class for Chat services
+# This class is not meant to be used directly, but only to inherrit from.
+class ChatService < Service
+ default_value_for :category, 'chat'
+
+ has_many :chat_names, foreign_key: :service_id
+
+ def valid_token?(token)
+ self.respond_to?(:token) &&
+ self.token.present? &&
+ ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
+ end
+
+ def supported_events
+ []
+ end
+
+ def trigger(params)
+ raise NotImplementedError
+ end
+end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 2dbe0075465..aeded715893 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -1,24 +1,3 @@
-# == Schema Information
-#
-# Table name: services
-#
-# id :integer not null, primary key
-# type :string(255)
-# title :string(255)
-# project_id :integer
-# created_at :datetime
-# updated_at :datetime
-# active :boolean default(FALSE), not null
-# properties :text
-# template :boolean default(FALSE)
-# push_events :boolean default(TRUE)
-# issues_events :boolean default(TRUE)
-# merge_requests_events :boolean default(TRUE)
-# tag_push_events :boolean default(TRUE)
-# note_events :boolean default(TRUE), not null
-# build_events :boolean default(FALSE), not null
-#
-
class JiraService < IssueTrackerService
include Gitlab::Routing.url_helpers
@@ -30,6 +9,10 @@ class JiraService < IssueTrackerService
before_update :reset_password
+ def supported_events
+ %w(commit merge_request)
+ end
+
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def reference_pattern
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
@@ -70,7 +53,7 @@ class JiraService < IssueTrackerService
end
def jira_project
- @jira_project ||= client.Project.find(project_key)
+ @jira_project ||= jira_request { client.Project.find(project_key) }
end
def help
@@ -128,21 +111,26 @@ class JiraService < IssueTrackerService
# we just want to test settings
test_settings
else
- close_issue(push, issue)
+ jira_issue = jira_request { client.Issue.find(issue.iid) }
+
+ return false unless jira_issue.present?
+
+ close_issue(push, jira_issue)
end
end
def create_cross_reference_note(mentioned, noteable, author)
- issue_key = mentioned.id
- project = self.project
- noteable_name = noteable.class.name.underscore.downcase
- noteable_id = if noteable.is_a?(Commit)
- noteable.id
- else
- noteable.iid
- end
+ unless can_cross_reference?(noteable)
+ return "Events for #{noteable.model_name.plural.humanize(capitalize: false)} are disabled."
+ end
+
+ jira_issue = jira_request { client.Issue.find(mentioned.id) }
+
+ return unless jira_issue.present?
- entity_url = build_entity_url(noteable_name.to_sym, noteable_id)
+ noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id
+ noteable_type = noteable_name(noteable)
+ entity_url = build_entity_url(noteable_type, noteable_id)
data = {
user: {
@@ -150,17 +138,17 @@ class JiraService < IssueTrackerService
url: resource_url(user_path(author)),
},
project: {
- name: project.path_with_namespace,
- url: resource_url(namespace_project_path(project.namespace, project))
+ name: self.project.path_with_namespace,
+ url: resource_url(namespace_project_path(project.namespace, self.project))
},
entity: {
- name: noteable_name.humanize.downcase,
+ name: noteable_type.humanize.downcase,
url: entity_url,
title: noteable.title
}
}
- add_comment(data, issue_key)
+ add_comment(data, jira_issue)
end
# reason why service cannot be tested
@@ -181,16 +169,22 @@ class JiraService < IssueTrackerService
def test_settings
return unless url.present?
# Test settings by getting the project
- jira_project
-
- rescue Errno::ECONNREFUSED, JIRA::HTTPError => e
- Rails.logger.info "#{self.class.name} ERROR: #{e.message}. API URL: #{url}."
- false
+ jira_request { jira_project.present? }
end
private
+ def can_cross_reference?(noteable)
+ case noteable
+ when Commit then commit_events
+ when MergeRequest then merge_requests_events
+ else true
+ end
+ end
+
def close_issue(entity, issue)
+ return if issue.nil? || issue.resolution.present? || !jira_issue_transition_id.present?
+
commit_id = if entity.is_a?(Commit)
entity.id
elsif entity.is_a?(MergeRequest)
@@ -200,72 +194,117 @@ class JiraService < IssueTrackerService
commit_url = build_entity_url(:commit, commit_id)
# Depending on the JIRA project's workflow, a comment during transition
- # may or may not be allowed. Split the operation in to two calls so the
- # comment always works.
- transition_issue(issue)
- add_issue_solved_comment(issue, commit_id, commit_url)
+ # may or may not be allowed. Refresh the issue after transition and check
+ # if it is closed, so we don't have one comment for every commit.
+ issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue)
+ add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution
end
def transition_issue(issue)
- issue = client.Issue.find(issue.iid)
issue.transitions.build.save(transition: { id: jira_issue_transition_id })
end
def add_issue_solved_comment(issue, commit_id, commit_url)
- comment = "Issue solved with [#{commit_id}|#{commit_url}]."
- send_message(issue.iid, comment)
+ link_title = "GitLab: Solved by commit #{commit_id}."
+ comment = "Issue solved with [#{commit_id}|#{commit_url}]."
+ link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true)
+ send_message(issue, comment, link_props)
end
- def add_comment(data, issue_key)
- user_name = data[:user][:name]
- user_url = data[:user][:url]
- entity_name = data[:entity][:name]
- entity_url = data[:entity][:url]
+ def add_comment(data, issue)
+ user_name = data[:user][:name]
+ user_url = data[:user][:url]
+ entity_name = data[:entity][:name]
+ entity_url = data[:entity][:url]
entity_title = data[:entity][:title]
project_name = data[:project][:name]
- message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'"
+ message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'"
+ link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}"
+ link_props = build_remote_link_props(url: entity_url, title: link_title)
- unless comment_exists?(issue_key, message)
- send_message(issue_key, message)
+ unless comment_exists?(issue, message)
+ send_message(issue, message, link_props)
end
end
- def comment_exists?(issue_key, message)
- comments = client.Issue.find(issue_key).comments
- comments.map { |comment| comment.body.include?(message) }.any?
+ def comment_exists?(issue, message)
+ comments = jira_request { issue.comments }
+
+ comments.present? && comments.any? { |comment| comment.body.include?(message) }
end
- def send_message(issue_key, message)
+ def send_message(issue, message, remote_link_props)
return unless url.present?
- issue = client.Issue.find(issue_key)
+ jira_request do
+ if issue.comments.build.save!(body: message)
+ remote_link = issue.remotelink.build
+ remote_link.save!(remote_link_props)
+ result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}."
+ end
- if issue.comments.build.save!(body: message)
- result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}."
+ Rails.logger.info(result_message)
+ result_message
end
+ end
- Rails.logger.info(result_message)
- result_message
- rescue URI::InvalidURIError, Errno::ECONNREFUSED, JIRA::HTTPError => e
- Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
+ # Build remote link on JIRA properties
+ # Icons here must be available on WEB so JIRA can read the URL
+ # We are using a open word graphics icon which have LGPL license
+ def build_remote_link_props(url:, title:, resolved: false)
+ status = {
+ resolved: resolved
+ }
+
+ if resolved
+ status[:icon] = {
+ title: 'Closed',
+ url16x16: 'http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png'
+ }
+ end
+
+ {
+ GlobalID: 'GitLab',
+ object: {
+ url: url,
+ title: title,
+ status: status,
+ icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' }
+ }
+ }
end
def resource_url(resource)
"#{Settings.gitlab.base_url.chomp("/")}#{resource}"
end
- def build_entity_url(entity_name, entity_id)
- resource_url(
- polymorphic_url(
- [
- self.project.namespace.becomes(Namespace),
- self.project,
- entity_name
- ],
- id: entity_id,
- routing_type: :path
- )
+ def build_entity_url(noteable_type, entity_id)
+ polymorphic_url(
+ [
+ self.project.namespace.becomes(Namespace),
+ self.project,
+ noteable_type.to_sym
+ ],
+ id: entity_id,
+ host: Settings.gitlab.base_url
)
end
+
+ def noteable_name(noteable)
+ name = noteable.model_name.singular
+
+ # ProjectSnippet inherits from Snippet class so it causes
+ # routing error building the URL.
+ name == "project_snippet" ? "snippet" : name
+ end
+
+ # Handle errors when doing JIRA API calls
+ def jira_request
+ yield
+
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e
+ Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
+ nil
+ end
end
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
new file mode 100644
index 00000000000..67902329593
--- /dev/null
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -0,0 +1,56 @@
+class MattermostSlashCommandsService < ChatService
+ include TriggersHelper
+
+ prop_accessor :token
+
+ def can_test?
+ false
+ end
+
+ def title
+ 'Mattermost Command'
+ end
+
+ def description
+ "Perform common operations on GitLab in Mattermost"
+ end
+
+ def to_param
+ 'mattermost_slash_commands'
+ end
+
+ def help
+ "This service allows you to use slash commands with your Mattermost installation.<br/>
+ To setup this Service you need to create a new <b>Slash commands</b> in your Mattermost integration panel.<br/>
+ <br/>
+ Create integration with URL #{service_trigger_url(self)} and enter the token below."
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'token', placeholder: '' }
+ ]
+ end
+
+ def trigger(params)
+ return nil unless valid_token?(params[:token])
+
+ user = find_chat_user(params)
+ unless user
+ url = authorize_chat_name_url(params)
+ return Mattermost::Presenter.authorize_chat_name(url)
+ end
+
+ Gitlab::ChatCommands::Command.new(project, user, params).execute
+ end
+
+ private
+
+ def find_chat_user(params)
+ ChatNames::FindUserService.new(self, params).execute
+ end
+
+ def authorize_chat_name_url(params)
+ ChatNames::AuthorizeUserService.new(self, params).execute
+ end
+end
diff --git a/app/models/project_services/slack_service/pipeline_message.rb b/app/models/project_services/slack_service/pipeline_message.rb
index f06b3562965..f8d03c0e2fa 100644
--- a/app/models/project_services/slack_service/pipeline_message.rb
+++ b/app/models/project_services/slack_service/pipeline_message.rb
@@ -1,11 +1,10 @@
class SlackService
class PipelineMessage < BaseMessage
- attr_reader :sha, :ref_type, :ref, :status, :project_name, :project_url,
+ attr_reader :ref_type, :ref, :status, :project_name, :project_url,
:user_name, :duration, :pipeline_id
def initialize(data)
pipeline_attributes = data[:object_attributes]
- @sha = pipeline_attributes[:sha]
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@ref = pipeline_attributes[:ref]
@status = pipeline_attributes[:status]
@@ -14,7 +13,7 @@ class SlackService
@project_name = data[:project][:path_with_namespace]
@project_url = data[:project][:web_url]
- @user_name = data[:commit] && data[:commit][:author_name]
+ @user_name = data[:user] && data[:user][:name]
end
def pretext
@@ -73,7 +72,7 @@ class SlackService
end
def pipeline_link
- "[#{Commit.truncate_sha(sha)}](#{pipeline_url})"
+ "[##{pipeline_id}](#{pipeline_url})"
end
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 146424d2b1c..bf136ccdb6c 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1,15 +1,53 @@
require 'securerandom'
class Repository
+ include Gitlab::ShellAdapter
+
+ attr_accessor :path_with_namespace, :project
+
class CommitError < StandardError; end
- # Files to use as a project avatar in case no avatar was uploaded via the web
- # UI.
- AVATAR_FILES = %w{logo.png logo.jpg logo.gif}
+ # Methods that cache data from the Git repository.
+ #
+ # Each entry in this Array should have a corresponding method with the exact
+ # same name. The cache key used by those methods must also match method's
+ # name.
+ #
+ # For example, for entry `:readme` there's a method called `readme` which
+ # stores its data in the `readme` cache key.
+ CACHED_METHODS = %i(size commit_count readme version contribution_guide
+ changelog license_blob license_key gitignore koding_yml
+ gitlab_ci_yml branch_names tag_names branch_count
+ tag_count avatar exists? empty? root_ref)
+
+ # Certain method caches should be refreshed when certain types of files are
+ # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to
+ # the corresponding methods to call for refreshing caches.
+ METHOD_CACHES_FOR_FILE_TYPES = {
+ readme: :readme,
+ changelog: :changelog,
+ license: %i(license_blob license_key),
+ contributing: :contribution_guide,
+ version: :version,
+ gitignore: :gitignore,
+ koding: :koding_yml,
+ gitlab_ci: :gitlab_ci_yml,
+ avatar: :avatar
+ }
+
+ # Wraps around the given method and caches its output in Redis and an instance
+ # variable.
+ #
+ # This only works for methods that do not take any arguments.
+ def self.cache_method(name, fallback: nil)
+ original = :"_uncached_#{name}"
- include Gitlab::ShellAdapter
+ alias_method(original, name)
- attr_accessor :path_with_namespace, :project
+ define_method(name) do
+ cache_method_output(name, fallback: fallback) { __send__(original) }
+ end
+ end
def self.storages
Gitlab.config.repositories.storages
@@ -37,24 +75,6 @@ class Repository
)
end
- def exists?
- return @exists unless @exists.nil?
-
- @exists = cache.fetch(:exists?) do
- begin
- raw_repository && raw_repository.rugged ? true : false
- rescue Gitlab::Git::Repository::NoRepository
- false
- end
- end
- end
-
- def empty?
- return @empty unless @empty.nil?
-
- @empty = cache.fetch(:empty?) { raw_repository.empty? }
- end
-
#
# Git repository can contains some hidden refs like:
# /refs/notes/*
@@ -176,11 +196,18 @@ class Repository
options = { message: message, tagger: user_to_committer(user) } if message
- GitHooksService.new.execute(user, path_to_repo, oldrev, target, ref) do
- rugged.tags.create(tag_name, target, options)
+ rugged.tags.create(tag_name, target, options)
+ tag = find_tag(tag_name)
+
+ GitHooksService.new.execute(user, path_to_repo, oldrev, tag.target, ref) do
+ # we already created a tag, because we need tag SHA to pass correct
+ # values to hooks
end
- find_tag(tag_name)
+ tag
+ rescue GitHooksService::PreReceiveError
+ rugged.tags.delete(tag_name)
+ raise
end
def rm_branch(user, branch_name)
@@ -214,10 +241,6 @@ class Repository
branch_names + tag_names
end
- def branch_names
- @branch_names ||= cache.fetch(:branch_names) { branches.map(&:name) }
- end
-
def branch_exists?(branch_name)
branch_names.include?(branch_name)
end
@@ -267,34 +290,6 @@ class Repository
ref_exists?(keep_around_ref_name(sha))
end
- def tag_names
- cache.fetch(:tag_names) { raw_repository.tag_names }
- end
-
- def commit_count
- cache.fetch(:commit_count) do
- begin
- raw_repository.commit_count(self.root_ref)
- rescue
- 0
- end
- end
- end
-
- def branch_count
- @branch_count ||= cache.fetch(:branch_count) { branches.size }
- end
-
- def tag_count
- @tag_count ||= cache.fetch(:tag_count) { raw_repository.rugged.tags.count }
- end
-
- # Return repo size in megabytes
- # Cached in redis
- def size
- cache.fetch(:size) { raw_repository.size }
- end
-
def diverging_commit_counts(branch)
root_ref_hash = raw_repository.rev_parse_target(root_ref).oid
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
@@ -310,48 +305,55 @@ class Repository
end
end
- # Keys for data that can be affected for any commit push.
- def cache_keys
- %i(size commit_count
- readme version contribution_guide changelog
- license_blob license_key gitignore koding_yml)
+ def expire_tags_cache
+ expire_method_caches(%i(tag_names tag_count))
+ @tags = nil
end
- # Keys for data on branch/tag operations.
- def cache_keys_for_branches_and_tags
- %i(branch_names tag_names branch_count tag_count)
+ def expire_branches_cache
+ expire_method_caches(%i(branch_names branch_count))
+ @local_branches = nil
end
- def build_cache
- (cache_keys + cache_keys_for_branches_and_tags).each do |key|
- unless cache.exist?(key)
- send(key)
- end
- end
+ def expire_statistics_caches
+ expire_method_caches(%i(size commit_count))
end
- def expire_tags_cache
- cache.expire(:tag_names)
- @tags = nil
+ def expire_all_method_caches
+ expire_method_caches(CACHED_METHODS)
end
- def expire_branches_cache
- cache.expire(:branch_names)
- @branch_names = nil
- @local_branches = nil
+ # Expires the caches of a specific set of methods
+ def expire_method_caches(methods)
+ methods.each do |key|
+ cache.expire(key)
+
+ ivar = cache_instance_variable_name(key)
+
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
+ end
end
- def expire_cache(branch_name = nil, revision = nil)
- cache_keys.each do |key|
- cache.expire(key)
+ def expire_avatar_cache
+ expire_method_caches(%i(avatar))
+ end
+
+ # Refreshes the method caches of this repository.
+ #
+ # types - An Array of file types (e.g. `:readme`) used to refresh extra
+ # caches.
+ def refresh_method_caches(types)
+ to_refresh = []
+
+ types.each do |type|
+ methods = METHOD_CACHES_FOR_FILE_TYPES[type.to_sym]
+
+ to_refresh.concat(Array(methods)) if methods
end
- expire_branch_cache(branch_name)
- expire_avatar_cache(branch_name, revision)
+ expire_method_caches(to_refresh)
- # This ensures this particular cache is flushed after the first commit to a
- # new repository.
- expire_emptiness_caches if empty?
+ to_refresh.each { |method| send(method) }
end
def expire_branch_cache(branch_name = nil)
@@ -370,15 +372,14 @@ class Repository
end
def expire_root_ref_cache
- cache.expire(:root_ref)
- @root_ref = nil
+ expire_method_caches(%i(root_ref))
end
# Expires the cache(s) used to determine if a repository is empty or not.
def expire_emptiness_caches
- cache.expire(:empty?)
- @empty = nil
+ return unless empty?
+ expire_method_caches(%i(empty?))
expire_has_visible_content_cache
end
@@ -387,51 +388,22 @@ class Repository
@has_visible_content = nil
end
- def expire_branch_count_cache
- cache.expire(:branch_count)
- @branch_count = nil
- end
-
- def expire_tag_count_cache
- cache.expire(:tag_count)
- @tag_count = nil
- end
-
def lookup_cache
@lookup_cache ||= {}
end
- def expire_avatar_cache(branch_name = nil, revision = nil)
- # Avatars are pulled from the default branch, thus if somebody pushes to a
- # different branch there's no need to expire anything.
- return if branch_name && branch_name != root_ref
-
- # We don't want to flush the cache if the commit didn't actually make any
- # changes to any of the possible avatar files.
- if revision && commit = self.commit(revision)
- return unless commit.raw_diffs(deltas_only: true).
- any? { |diff| AVATAR_FILES.include?(diff.new_path) }
- end
-
- cache.expire(:avatar)
-
- @avatar = nil
- end
-
def expire_exists_cache
- cache.expire(:exists?)
- @exists = nil
+ expire_method_caches(%i(exists?))
end
# expire cache that doesn't depend on repository data (when expiring)
def expire_content_cache
expire_tags_cache
- expire_tag_count_cache
expire_branches_cache
- expire_branch_count_cache
expire_root_ref_cache
expire_emptiness_caches
expire_exists_cache
+ expire_statistics_caches
end
# Runs code after a repository has been created.
@@ -446,9 +418,8 @@ class Repository
# Runs code just before a repository is deleted.
def before_delete
expire_exists_cache
-
- expire_cache if exists?
-
+ expire_all_method_caches
+ expire_branch_cache if exists?
expire_content_cache
repository_event(:remove_repository)
@@ -465,9 +436,9 @@ class Repository
# Runs code before pushing (= creating or removing) a tag.
def before_push_tag
- expire_cache
+ expire_statistics_caches
+ expire_emptiness_caches
expire_tags_cache
- expire_tag_count_cache
repository_event(:push_tag)
end
@@ -475,7 +446,7 @@ class Repository
# Runs code before removing a tag.
def before_remove_tag
expire_tags_cache
- expire_tag_count_cache
+ expire_statistics_caches
repository_event(:remove_tag)
end
@@ -487,12 +458,14 @@ class Repository
# Runs code after a repository has been forked/imported.
def after_import
expire_content_cache
- build_cache
+ expire_tags_cache
+ expire_branches_cache
end
# Runs code after a new commit has been pushed.
- def after_push_commit(branch_name, revision)
- expire_cache(branch_name, revision)
+ def after_push_commit(branch_name)
+ expire_statistics_caches
+ expire_branch_cache(branch_name)
repository_event(:push_commit, branch: branch_name)
end
@@ -501,7 +474,6 @@ class Repository
def after_create_branch
expire_branches_cache
expire_has_visible_content_cache
- expire_branch_count_cache
repository_event(:push_branch)
end
@@ -516,7 +488,6 @@ class Repository
# Runs code after an existing branch has been removed.
def after_remove_branch
expire_has_visible_content_cache
- expire_branch_count_cache
expire_branches_cache
end
@@ -543,86 +514,127 @@ class Repository
Gitlab::Git::Blob.raw(self, oid)
end
+ def root_ref
+ if raw_repository
+ raw_repository.root_ref
+ else
+ # When the repo does not exist we raise this error so no data is cached.
+ raise Rugged::ReferenceError
+ end
+ end
+ cache_method :root_ref
+
+ def exists?
+ refs_directory_exists?
+ end
+ cache_method :exists?
+
+ def empty?
+ raw_repository.empty?
+ end
+ cache_method :empty?
+
+ # The size of this repository in megabytes.
+ def size
+ exists? ? raw_repository.size : 0.0
+ end
+ cache_method :size, fallback: 0.0
+
+ def commit_count
+ root_ref ? raw_repository.commit_count(root_ref) : 0
+ end
+ cache_method :commit_count, fallback: 0
+
+ def branch_names
+ branches.map(&:name)
+ end
+ cache_method :branch_names, fallback: []
+
+ def tag_names
+ raw_repository.tag_names
+ end
+ cache_method :tag_names, fallback: []
+
+ def branch_count
+ branches.size
+ end
+ cache_method :branch_count, fallback: 0
+
+ def tag_count
+ raw_repository.rugged.tags.count
+ end
+ cache_method :tag_count, fallback: 0
+
+ def avatar
+ if tree = file_on_head(:avatar)
+ tree.path
+ end
+ end
+ cache_method :avatar
+
def readme
- cache.fetch(:readme) { tree(:head).readme }
+ if head = tree(:head)
+ head.readme
+ end
end
+ cache_method :readme
def version
- cache.fetch(:version) do
- tree(:head).blobs.find do |file|
- file.name.casecmp('version').zero?
- end
- end
+ file_on_head(:version)
end
+ cache_method :version
def contribution_guide
- cache.fetch(:contribution_guide) do
- tree(:head).blobs.find do |file|
- file.contributing?
- end
- end
+ file_on_head(:contributing)
end
+ cache_method :contribution_guide
def changelog
- cache.fetch(:changelog) do
- file_on_head(/\A(changelog|history|changes|news)/i)
- end
+ file_on_head(:changelog)
end
+ cache_method :changelog
def license_blob
- return nil unless head_exists?
-
- cache.fetch(:license_blob) do
- file_on_head(/\A(licen[sc]e|copying)(\..+|\z)/i)
- end
+ file_on_head(:license)
end
+ cache_method :license_blob
def license_key
- return nil unless head_exists?
+ return unless exists?
- cache.fetch(:license_key) do
- Licensee.license(path).try(:key)
- end
+ Licensee.license(path).try(:key)
end
+ cache_method :license_key
def gitignore
- return nil if !exists? || empty?
-
- cache.fetch(:gitignore) do
- file_on_head(/\A\.gitignore\z/)
- end
+ file_on_head(:gitignore)
end
+ cache_method :gitignore
def koding_yml
- return nil unless head_exists?
-
- cache.fetch(:koding_yml) do
- file_on_head(/\A\.koding\.yml\z/)
- end
+ file_on_head(:koding)
end
+ cache_method :koding_yml
def gitlab_ci_yml
- return nil unless head_exists?
-
- @gitlab_ci_yml ||= tree(:head).blobs.find do |file|
- file.name == '.gitlab-ci.yml'
- end
- rescue Rugged::ReferenceError
- # For unknow reason spinach scenario "Scenario: I change project path"
- # lead to "Reference 'HEAD' not found" exception from Repository#empty?
- nil
+ file_on_head(:gitlab_ci)
end
+ cache_method :gitlab_ci_yml
def head_commit
@head_commit ||= commit(self.root_ref)
end
def head_tree
- @head_tree ||= Tree.new(self, head_commit.sha, nil)
+ if head_commit
+ @head_tree ||= Tree.new(self, head_commit.sha, nil)
+ end
end
def tree(sha = :head, path = nil, recursive: false)
if sha == :head
+ return unless head_commit
+
if path.nil?
return head_tree
else
@@ -772,10 +784,6 @@ class Repository
@tags ||= raw_repository.tags
end
- def root_ref
- @root_ref ||= cache.fetch(:root_ref) { raw_repository.root_ref }
- end
-
def commit_dir(user, path, message, branch, author_email: nil, author_name: nil)
update_branch_with_hooks(user, branch) do |ref|
options = {
@@ -1133,28 +1141,55 @@ class Repository
end
end
- def avatar
- return nil unless exists?
+ # Caches the supplied block both in a cache and in an instance variable.
+ #
+ # The cache key and instance variable are named the same way as the value of
+ # the `key` argument.
+ #
+ # This method will return `nil` if the corresponding instance variable is also
+ # set to `nil`. This ensures we don't keep yielding the block when it returns
+ # `nil`.
+ #
+ # key - The name of the key to cache the data in.
+ # fallback - A value to fall back to in the event of a Git error.
+ def cache_method_output(key, fallback: nil, &block)
+ ivar = cache_instance_variable_name(key)
- @avatar ||= cache.fetch(:avatar) do
- AVATAR_FILES.find do |file|
- blob_at_branch(root_ref, file)
+ if instance_variable_defined?(ivar)
+ instance_variable_get(ivar)
+ else
+ begin
+ instance_variable_set(ivar, cache.fetch(key, &block))
+ rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository
+ # if e.g. HEAD or the entire repository doesn't exist we want to
+ # gracefully handle this and not cache anything.
+ fallback
end
end
end
- private
+ def cache_instance_variable_name(key)
+ :"@#{key.to_s.tr('?!', '')}"
+ end
- def cache
- @cache ||= RepositoryCache.new(path_with_namespace, @project.id)
+ def file_on_head(type)
+ if head = tree(:head)
+ head.blobs.find do |file|
+ Gitlab::FileDetector.type_of(file.name) == type
+ end
+ end
end
- def head_exists?
- exists? && !empty? && !rugged.head_unborn?
+ private
+
+ def refs_directory_exists?
+ return false unless path_with_namespace
+
+ File.exist?(File.join(path_to_repo, 'refs'))
end
- def file_on_head(regex)
- tree(:head).blobs.find { |file| file.name =~ regex }
+ def cache
+ @cache ||= RepositoryCache.new(path_with_namespace, @project.id)
end
def tags_sorted_by_committed_date
diff --git a/app/models/service.rb b/app/models/service.rb
index 9d6ff190cdf..0c36acfc1b7 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -8,6 +8,7 @@ class Service < ActiveRecord::Base
default_value_for :push_events, true
default_value_for :issues_events, true
default_value_for :confidential_issues_events, true
+ default_value_for :commit_events, true
default_value_for :merge_requests_events, true
default_value_for :tag_push_events, true
default_value_for :note_events, true
@@ -202,7 +203,6 @@ class Service < ActiveRecord::Base
bamboo
buildkite
builds_email
- pipelines_email
bugzilla
campfire
custom_issue_tracker
@@ -214,6 +214,8 @@ class Service < ActiveRecord::Base
hipchat
irker
jira
+ mattermost_slash_commands
+ pipelines_email
pivotaltracker
pushover
redmine
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 2373b445009..8ff4e7ae718 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -6,6 +6,7 @@ class Snippet < ActiveRecord::Base
include Referable
include Sortable
include Awardable
+ include Mentionable
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :content
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 3b8aa1eb866..17869c8bac2 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -1,8 +1,9 @@
class Subscription < ActiveRecord::Base
belongs_to :user
+ belongs_to :project
belongs_to :subscribable, polymorphic: true
- validates :user_id,
- uniqueness: { scope: [:subscribable_id, :subscribable_type] },
- presence: true
+ validates :user, :subscribable, presence: true
+
+ validates :project_id, uniqueness: { scope: [:subscribable_id, :subscribable_type, :user_id] }
end
diff --git a/app/models/tree.rb b/app/models/tree.rb
index 2d1d68dbd81..fe148b0ec65 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -18,7 +18,9 @@ class Tree
def readme
return @readme if defined?(@readme)
- available_readmes = blobs.select(&:readme?)
+ available_readmes = blobs.select do |blob|
+ Gitlab::FileDetector.type_of(blob.name) == :readme
+ end
previewable_readmes = available_readmes.select do |blob|
previewable?(blob.name)
diff --git a/app/models/user.rb b/app/models/user.rb
index 5a2b232c4ed..29fb849940a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -56,6 +56,7 @@ class User < ActiveRecord::Base
has_many :personal_access_tokens, dependent: :destroy
has_many :identities, dependent: :destroy, autosave: true
has_many :u2f_registrations, dependent: :destroy
+ has_many :chat_names, dependent: :destroy
# Groups
has_many :members, dependent: :destroy
@@ -72,6 +73,8 @@ class User < ActiveRecord::Base
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
has_many :users_star_projects, dependent: :destroy
has_many :starred_projects, through: :users_star_projects, source: :project
+ has_many :project_authorizations, dependent: :destroy
+ has_many :authorized_projects, through: :project_authorizations, source: :project
has_many :snippets, dependent: :destroy, foreign_key: :author_id
has_many :issues, dependent: :destroy, foreign_key: :author_id
@@ -226,19 +229,19 @@ class User < ActiveRecord::Base
def filter(filter_name)
case filter_name
when 'admins'
- self.admins
+ admins
when 'blocked'
- self.blocked
+ blocked
when 'two_factor_disabled'
- self.without_two_factor
+ without_two_factor
when 'two_factor_enabled'
- self.with_two_factor
+ with_two_factor
when 'wop'
- self.without_projects
+ without_projects
when 'external'
- self.external
+ external
else
- self.active
+ active
end
end
@@ -336,7 +339,7 @@ class User < ActiveRecord::Base
end
def generate_password
- if self.force_random_password
+ if force_random_password
self.password = self.password_confirmation = Devise.friendly_token.first(Devise.password_length.min)
end
end
@@ -377,56 +380,55 @@ class User < ActiveRecord::Base
end
def two_factor_otp_enabled?
- self.otp_required_for_login?
+ otp_required_for_login?
end
def two_factor_u2f_enabled?
- self.u2f_registrations.exists?
+ u2f_registrations.exists?
end
def namespace_uniq
# Return early if username already failed the first uniqueness validation
- return if self.errors.key?(:username) &&
- self.errors[:username].include?('has already been taken')
+ return if errors.key?(:username) &&
+ errors[:username].include?('has already been taken')
- namespace_name = self.username
- existing_namespace = Namespace.by_path(namespace_name)
- if existing_namespace && existing_namespace != self.namespace
- self.errors.add(:username, 'has already been taken')
+ existing_namespace = Namespace.by_path(username)
+ if existing_namespace && existing_namespace != namespace
+ errors.add(:username, 'has already been taken')
end
end
def avatar_type
- unless self.avatar.image?
- self.errors.add :avatar, "only images allowed"
+ unless avatar.image?
+ errors.add :avatar, "only images allowed"
end
end
def unique_email
- if !self.emails.exists?(email: self.email) && Email.exists?(email: self.email)
- self.errors.add(:email, 'has already been taken')
+ if !emails.exists?(email: email) && Email.exists?(email: email)
+ errors.add(:email, 'has already been taken')
end
end
def owns_notification_email
- return if self.temp_oauth_email?
+ return if temp_oauth_email?
- self.errors.add(:notification_email, "is not an email you own") unless self.all_emails.include?(self.notification_email)
+ errors.add(:notification_email, "is not an email you own") unless all_emails.include?(notification_email)
end
def owns_public_email
- return if self.public_email.blank?
+ return if public_email.blank?
- self.errors.add(:public_email, "is not an email you own") unless self.all_emails.include?(self.public_email)
+ errors.add(:public_email, "is not an email you own") unless all_emails.include?(public_email)
end
def update_emails_with_primary_email
- primary_email_record = self.emails.find_by(email: self.email)
+ primary_email_record = emails.find_by(email: email)
if primary_email_record
primary_email_record.destroy
- self.emails.create(email: self.email_was)
+ emails.create(email: email_was)
- self.update_secondary_emails!
+ update_secondary_emails!
end
end
@@ -438,11 +440,44 @@ class User < ActiveRecord::Base
Group.where("namespaces.id IN (#{union.to_sql})")
end
- # Returns projects user is authorized to access.
- #
- # If you change the logic of this method, please also update `Project#authorized_for_user`
+ def refresh_authorized_projects
+ loop do
+ begin
+ Gitlab::Database.serialized_transaction do
+ project_authorizations.delete_all
+
+ # project_authorizations_union can return multiple records for the same project/user with
+ # different access_level so we take row with the maximum access_level
+ project_authorizations.connection.execute <<-SQL
+ INSERT INTO project_authorizations (user_id, project_id, access_level)
+ SELECT user_id, project_id, MAX(access_level) AS access_level
+ FROM (#{project_authorizations_union.to_sql}) sub
+ GROUP BY user_id, project_id
+ SQL
+
+ update_column(:authorized_projects_populated, true) unless authorized_projects_populated
+ end
+
+ break
+ # In the event of a concurrent modification Rails raises StatementInvalid.
+ # In this case we want to keep retrying until the transaction succeeds
+ rescue ActiveRecord::StatementInvalid
+ end
+ end
+ end
+
def authorized_projects(min_access_level = nil)
- Project.where("projects.id IN (#{projects_union(min_access_level).to_sql})")
+ refresh_authorized_projects unless authorized_projects_populated
+
+ # We're overriding an association, so explicitly call super with no arguments or it would be passed as `force_reload` to the association
+ projects = super()
+ projects = projects.where('project_authorizations.access_level >= ?', min_access_level) if min_access_level
+
+ projects
+ end
+
+ def authorized_project?(project, min_access_level = nil)
+ authorized_projects(min_access_level).exists?({ id: project.id })
end
# Returns the projects this user has reporter (or greater) access to, limited
@@ -456,8 +491,9 @@ class User < ActiveRecord::Base
end
def viewable_starred_projects
- starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (#{projects_union.to_sql})",
- [Project::PUBLIC, Project::INTERNAL])
+ starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)",
+ [Project::PUBLIC, Project::INTERNAL],
+ authorized_projects.select(:project_id))
end
def owned_projects
@@ -580,7 +616,7 @@ class User < ActiveRecord::Base
end
def project_deploy_keys
- DeployKey.unscoped.in_projects(self.authorized_projects.pluck(:id)).distinct(:id)
+ DeployKey.unscoped.in_projects(authorized_projects.pluck(:id)).distinct(:id)
end
def accessible_deploy_keys
@@ -596,38 +632,38 @@ class User < ActiveRecord::Base
end
def sanitize_attrs
- %w(name username skype linkedin twitter).each do |attr|
- value = self.send(attr)
- self.send("#{attr}=", Sanitize.clean(value)) if value.present?
+ %w[name username skype linkedin twitter].each do |attr|
+ value = public_send(attr)
+ public_send("#{attr}=", Sanitize.clean(value)) if value.present?
end
end
def set_notification_email
- if self.notification_email.blank? || !self.all_emails.include?(self.notification_email)
- self.notification_email = self.email
+ if notification_email.blank? || !all_emails.include?(notification_email)
+ self.notification_email = email
end
end
def set_public_email
- if self.public_email.blank? || !self.all_emails.include?(self.public_email)
+ if public_email.blank? || !all_emails.include?(public_email)
self.public_email = ''
end
end
def update_secondary_emails!
- self.set_notification_email
- self.set_public_email
- self.save if self.notification_email_changed? || self.public_email_changed?
+ set_notification_email
+ set_public_email
+ save if notification_email_changed? || public_email_changed?
end
def set_projects_limit
# `User.select(:id)` raises
# `ActiveModel::MissingAttributeError: missing attribute: projects_limit`
# without this safeguard!
- return unless self.has_attribute?(:projects_limit)
+ return unless has_attribute?(:projects_limit)
connection_default_value_defined = new_record? && !projects_limit_changed?
- return unless self.projects_limit.nil? || connection_default_value_defined
+ return unless projects_limit.nil? || connection_default_value_defined
self.projects_limit = current_application_settings.default_projects_limit
end
@@ -657,7 +693,7 @@ class User < ActiveRecord::Base
def with_defaults
User.defaults.each do |k, v|
- self.send("#{k}=", v)
+ public_send("#{k}=", v)
end
self
@@ -677,7 +713,7 @@ class User < ActiveRecord::Base
# Thus it will automatically generate a new fragment
# when the event is updated because the key changes.
def reset_events_cache
- Event.where(author_id: self.id).
+ Event.where(author_id: id).
order('id DESC').limit(1000).
update_all(updated_at: Time.now)
end
@@ -710,8 +746,8 @@ class User < ActiveRecord::Base
def all_emails
all_emails = []
- all_emails << self.email unless self.temp_oauth_email?
- all_emails.concat(self.emails.map(&:email))
+ all_emails << email unless temp_oauth_email?
+ all_emails.concat(emails.map(&:email))
all_emails
end
@@ -725,21 +761,21 @@ class User < ActiveRecord::Base
def ensure_namespace_correct
# Ensure user has namespace
- self.create_namespace!(path: self.username, name: self.username) unless self.namespace
+ create_namespace!(path: username, name: username) unless namespace
- if self.username_changed?
- self.namespace.update_attributes(path: self.username, name: self.username)
+ if username_changed?
+ namespace.update_attributes(path: username, name: username)
end
end
def post_create_hook
- log_info("User \"#{self.name}\" (#{self.email}) was created")
- notification_service.new_user(self, @reset_token) if self.created_by_id
+ log_info("User \"#{name}\" (#{email}) was created")
+ notification_service.new_user(self, @reset_token) if created_by_id
system_hook_service.execute_hooks_for(self, :create)
end
def post_destroy_hook
- log_info("User \"#{self.name}\" (#{self.email}) was removed")
+ log_info("User \"#{name}\" (#{email}) was removed")
system_hook_service.execute_hooks_for(self, :destroy)
end
@@ -783,7 +819,7 @@ class User < ActiveRecord::Base
end
def oauth_authorized_tokens
- Doorkeeper::AccessToken.where(resource_owner_id: self.id, revoked_at: nil)
+ Doorkeeper::AccessToken.where(resource_owner_id: id, revoked_at: nil)
end
# Returns the projects a user contributed to in the last year.
@@ -887,16 +923,14 @@ class User < ActiveRecord::Base
private
- def projects_union(min_access_level = nil)
- relations = [personal_projects.select(:id),
- groups_projects.select(:id),
- projects.select(:id),
- groups.joins(:shared_projects).select(:project_id)]
-
- if min_access_level
- scope = { access_level: Gitlab::Access.all_values.select { |access| access >= min_access_level } }
- relations = [relations.shift] + relations.map { |relation| relation.where(members: scope) }
- end
+ # Returns a union query of projects that the user is authorized to access
+ def project_authorizations_union
+ relations = [
+ personal_projects.select("#{id} AS user_id, projects.id AS project_id, #{Gitlab::Access::OWNER} AS access_level"),
+ groups_projects.select_for_project_authorization,
+ projects.select_for_project_authorization,
+ groups.joins(:shared_projects).select_for_project_authorization
+ ]
Gitlab::SQL::Union.new(relations)
end
@@ -916,7 +950,7 @@ class User < ActiveRecord::Base
end
def ensure_external_user_rights
- return unless self.external?
+ return unless external?
self.can_create_group = false
self.projects_limit = 0
@@ -928,7 +962,7 @@ class User < ActiveRecord::Base
if current_application_settings.domain_blacklist_enabled?
blocked_domains = current_application_settings.domain_blacklist
- if domain_matches?(blocked_domains, self.email)
+ if domain_matches?(blocked_domains, email)
error = 'is not from an allowed domain.'
valid = false
end
@@ -936,7 +970,7 @@ class User < ActiveRecord::Base
allowed_domains = current_application_settings.domain_whitelist
unless allowed_domains.blank?
- if domain_matches?(allowed_domains, self.email)
+ if domain_matches?(allowed_domains, email)
valid = true
else
error = "domain is not authorized for sign-up"
@@ -944,7 +978,7 @@ class User < ActiveRecord::Base
end
end
- self.errors.add(:email, error) unless valid
+ errors.add(:email, error) unless valid
valid
end
diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb
new file mode 100644
index 00000000000..5fdf2bbf7c3
--- /dev/null
+++ b/app/serializers/analytics_build_entity.rb
@@ -0,0 +1,40 @@
+class AnalyticsBuildEntity < Grape::Entity
+ include RequestAwareEntity
+ include EntityDateHelper
+
+ expose :name
+ expose :id
+ expose :ref, as: :branch
+ expose :short_sha
+ expose :author, using: UserEntity
+
+ expose :started_at, as: :date do |build|
+ interval_in_words(build[:started_at])
+ end
+
+ expose :duration, as: :total_time do |build|
+ distance_of_time_as_hash(build[:duration].to_f)
+ end
+
+ expose :branch do
+ expose :ref, as: :name
+
+ expose :url do |build|
+ url_to(:namespace_project_tree, build, build.ref)
+ end
+ end
+
+ expose :url do |build|
+ url_to(:namespace_project_build, build)
+ end
+
+ expose :commit_url do |build|
+ url_to(:namespace_project_commit, build, build.sha)
+ end
+
+ private
+
+ def url_to(route, build, id = nil)
+ public_send("#{route}_url", build.project.namespace, build.project, id || build)
+ end
+end
diff --git a/app/serializers/analytics_build_serializer.rb b/app/serializers/analytics_build_serializer.rb
new file mode 100644
index 00000000000..f172d67d356
--- /dev/null
+++ b/app/serializers/analytics_build_serializer.rb
@@ -0,0 +1,3 @@
+class AnalyticsBuildSerializer < BaseSerializer
+ entity AnalyticsBuildEntity
+end
diff --git a/app/serializers/analytics_commit_entity.rb b/app/serializers/analytics_commit_entity.rb
new file mode 100644
index 00000000000..402cecbfd08
--- /dev/null
+++ b/app/serializers/analytics_commit_entity.rb
@@ -0,0 +1,13 @@
+class AnalyticsCommitEntity < CommitEntity
+ include EntityDateHelper
+
+ expose :short_id, as: :short_sha
+
+ expose :total_time do |commit|
+ distance_of_time_as_hash(request.total_time.to_f)
+ end
+
+ unexpose :author_name
+ unexpose :author_email
+ unexpose :message
+end
diff --git a/app/serializers/analytics_commit_serializer.rb b/app/serializers/analytics_commit_serializer.rb
new file mode 100644
index 00000000000..cdbfecf2b70
--- /dev/null
+++ b/app/serializers/analytics_commit_serializer.rb
@@ -0,0 +1,3 @@
+class AnalyticsCommitSerializer < BaseSerializer
+ entity AnalyticsCommitEntity
+end
diff --git a/app/serializers/analytics_generic_serializer.rb b/app/serializers/analytics_generic_serializer.rb
new file mode 100644
index 00000000000..9f4859e8410
--- /dev/null
+++ b/app/serializers/analytics_generic_serializer.rb
@@ -0,0 +1,7 @@
+class AnalyticsGenericSerializer < BaseSerializer
+ def represent(resource, opts = {})
+ resource.symbolize_keys!
+
+ super(resource, opts)
+ end
+end
diff --git a/app/serializers/analytics_issue_entity.rb b/app/serializers/analytics_issue_entity.rb
new file mode 100644
index 00000000000..44c50f18613
--- /dev/null
+++ b/app/serializers/analytics_issue_entity.rb
@@ -0,0 +1,29 @@
+class AnalyticsIssueEntity < Grape::Entity
+ include RequestAwareEntity
+ include EntityDateHelper
+
+ expose :title
+ expose :author, using: UserEntity
+
+ expose :iid do |object|
+ object[:iid].to_s
+ end
+
+ expose :total_time do |object|
+ distance_of_time_as_hash(object[:total_time].to_f)
+ end
+
+ expose(:created_at) do |object|
+ interval_in_words(object[:created_at])
+ end
+
+ expose :url do |object|
+ url_to(:namespace_project_issue, id: object[:iid].to_s)
+ end
+
+ private
+
+ def url_to(route, id)
+ public_send("#{route}_url", request.project.namespace, request.project, id)
+ end
+end
diff --git a/app/serializers/analytics_issue_serializer.rb b/app/serializers/analytics_issue_serializer.rb
new file mode 100644
index 00000000000..4fb3e8f1bb4
--- /dev/null
+++ b/app/serializers/analytics_issue_serializer.rb
@@ -0,0 +1,3 @@
+class AnalyticsIssueSerializer < AnalyticsGenericSerializer
+ entity AnalyticsIssueEntity
+end
diff --git a/app/serializers/analytics_merge_request_entity.rb b/app/serializers/analytics_merge_request_entity.rb
new file mode 100644
index 00000000000..888265eaa38
--- /dev/null
+++ b/app/serializers/analytics_merge_request_entity.rb
@@ -0,0 +1,7 @@
+class AnalyticsMergeRequestEntity < AnalyticsIssueEntity
+ expose :state
+
+ expose :url do |object|
+ url_to(:namespace_project_merge_request, id: object[:iid].to_s)
+ end
+end
diff --git a/app/serializers/analytics_merge_request_serializer.rb b/app/serializers/analytics_merge_request_serializer.rb
new file mode 100644
index 00000000000..4622a1dd855
--- /dev/null
+++ b/app/serializers/analytics_merge_request_serializer.rb
@@ -0,0 +1,3 @@
+class AnalyticsMergeRequestSerializer < AnalyticsGenericSerializer
+ entity AnalyticsMergeRequestEntity
+end
diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb
index 3d9ac66de0e..cf1c418a88e 100644
--- a/app/serializers/build_entity.rb
+++ b/app/serializers/build_entity.rb
@@ -4,21 +4,21 @@ class BuildEntity < Grape::Entity
expose :id
expose :name
- expose :build_url do |build|
- url_to(:namespace_project_build, build)
+ expose :build_path do |build|
+ path_to(:namespace_project_build, build)
end
- expose :retry_url do |build|
- url_to(:retry_namespace_project_build, build)
+ expose :retry_path do |build|
+ path_to(:retry_namespace_project_build, build)
end
- expose :play_url, if: ->(build, _) { build.manual? } do |build|
- url_to(:play_namespace_project_build, build)
+ expose :play_path, if: ->(build, _) { build.manual? } do |build|
+ path_to(:play_namespace_project_build, build)
end
private
- def url_to(route, build)
- send("#{route}_url", build.project.namespace, build.project, build)
+ def path_to(route, build)
+ send("#{route}_path", build.project.namespace, build.project, build)
end
end
diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb
index f7eba6fc1e3..acc20f6dc52 100644
--- a/app/serializers/commit_entity.rb
+++ b/app/serializers/commit_entity.rb
@@ -9,4 +9,11 @@ class CommitEntity < API::Entities::RepoCommit
request.project,
id: commit.id)
end
+
+ expose :commit_path do |commit|
+ namespace_project_tree_path(
+ request.project.namespace,
+ request.project,
+ id: commit.id)
+ end
end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index ad6fc8d665b..d610fbe0c8a 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -10,8 +10,8 @@ class DeploymentEntity < Grape::Entity
deployment.ref
end
- expose :ref_url do |deployment|
- namespace_project_tree_url(
+ expose :ref_path do |deployment|
+ namespace_project_tree_path(
deployment.project.namespace,
deployment.project,
id: deployment.ref)
diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb
new file mode 100644
index 00000000000..b333b3344c3
--- /dev/null
+++ b/app/serializers/entity_date_helper.rb
@@ -0,0 +1,35 @@
+module EntityDateHelper
+ include ActionView::Helpers::DateHelper
+
+ def interval_in_words(diff)
+ "#{distance_of_time_in_words(diff.to_f)} ago"
+ end
+
+ # Converts seconds into a hash such as:
+ # { days: 1, hours: 3, mins: 42, seconds: 40 }
+ #
+ # It returns 0 seconds for zero or negative numbers
+ # It rounds to nearest time unit and does not return zero
+ # i.e { min: 1 } instead of { mins: 1, seconds: 0 }
+ def distance_of_time_as_hash(diff)
+ diff = diff.abs.floor
+
+ return { seconds: 0 } if diff == 0
+
+ mins = (diff / 60).floor
+ seconds = diff % 60
+ hours = (mins / 60).floor
+ mins = mins % 60
+ days = (hours / 24).floor
+ hours = hours % 24
+
+ duration_hash = {}
+
+ duration_hash[:days] = days if days > 0
+ duration_hash[:hours] = hours if hours > 0
+ duration_hash[:mins] = mins if mins > 0
+ duration_hash[:seconds] = seconds if seconds > 0
+
+ duration_hash
+ end
+end
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index ee4392cc46d..7e0fc9c071e 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -9,8 +9,15 @@ class EnvironmentEntity < Grape::Entity
expose :last_deployment, using: DeploymentEntity
expose :stoppable?
- expose :environment_url do |environment|
- namespace_project_environment_url(
+ expose :environment_path do |environment|
+ namespace_project_environment_path(
+ environment.project.namespace,
+ environment.project,
+ environment)
+ end
+
+ expose :stop_path do |environment|
+ stop_namespace_project_environment_path(
environment.project.namespace,
environment.project,
environment)
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
new file mode 100644
index 00000000000..17c9160cb19
--- /dev/null
+++ b/app/serializers/issuable_entity.rb
@@ -0,0 +1,16 @@
+class IssuableEntity < Grape::Entity
+ expose :id
+ expose :iid
+ expose :assignee_id
+ expose :author_id
+ expose :description
+ expose :lock_version
+ expose :milestone_id
+ expose :position
+ expose :state
+ expose :title
+ expose :updated_by_id
+ expose :created_at
+ expose :updated_at
+ expose :deleted_at
+end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
new file mode 100644
index 00000000000..6429159ebe1
--- /dev/null
+++ b/app/serializers/issue_entity.rb
@@ -0,0 +1,9 @@
+class IssueEntity < IssuableEntity
+ expose :branch_name
+ expose :confidential
+ expose :due_date
+ expose :moved_to_id
+ expose :project_id
+ expose :milestone, using: API::Entities::Milestone
+ expose :labels, using: LabelEntity
+end
diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb
new file mode 100644
index 00000000000..4fff54a9126
--- /dev/null
+++ b/app/serializers/issue_serializer.rb
@@ -0,0 +1,3 @@
+class IssueSerializer < BaseSerializer
+ entity IssueEntity
+end
diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb
new file mode 100644
index 00000000000..304fd9de08f
--- /dev/null
+++ b/app/serializers/label_entity.rb
@@ -0,0 +1,11 @@
+class LabelEntity < Grape::Entity
+ expose :id
+ expose :title
+ expose :color
+ expose :description
+ expose :group_id
+ expose :project_id
+ expose :template
+ expose :created_at
+ expose :updated_at
+end
diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
new file mode 100644
index 00000000000..7445298c714
--- /dev/null
+++ b/app/serializers/merge_request_entity.rb
@@ -0,0 +1,14 @@
+class MergeRequestEntity < IssuableEntity
+ expose :in_progress_merge_commit_sha
+ expose :locked_at
+ expose :merge_commit_sha
+ expose :merge_error
+ expose :merge_params
+ expose :merge_status
+ expose :merge_user_id
+ expose :merge_when_build_succeeds
+ expose :source_branch
+ expose :source_project_id
+ expose :target_branch
+ expose :target_project_id
+end
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
new file mode 100644
index 00000000000..aa6e00dfcb4
--- /dev/null
+++ b/app/serializers/merge_request_serializer.rb
@@ -0,0 +1,3 @@
+class MergeRequestSerializer < BaseSerializer
+ entity MergeRequestEntity
+end
diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb
new file mode 100644
index 00000000000..321bf3a9205
--- /dev/null
+++ b/app/services/chat_names/authorize_user_service.rb
@@ -0,0 +1,38 @@
+module ChatNames
+ class AuthorizeUserService
+ include Gitlab::Routing.url_helpers
+
+ def initialize(service, params)
+ @service = service
+ @params = params
+ end
+
+ def execute
+ return unless chat_name_params.values.all?(&:present?)
+
+ token = request_token
+
+ new_profile_chat_name_url(token: token) if token
+ end
+
+ private
+
+ def request_token
+ chat_name_token.store!(chat_name_params)
+ end
+
+ def chat_name_token
+ Gitlab::ChatNameToken.new
+ end
+
+ def chat_name_params
+ {
+ service_id: @service.id,
+ team_id: @params[:team_id],
+ team_domain: @params[:team_domain],
+ chat_id: @params[:user_id],
+ chat_name: @params[:user_name]
+ }
+ end
+ end
+end
diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb
new file mode 100644
index 00000000000..4f5c5567b42
--- /dev/null
+++ b/app/services/chat_names/find_user_service.rb
@@ -0,0 +1,26 @@
+module ChatNames
+ class FindUserService
+ def initialize(service, params)
+ @service = service
+ @params = params
+ end
+
+ def execute
+ chat_name = find_chat_name
+ return unless chat_name
+
+ chat_name.touch(:last_used_at)
+ chat_name.user
+ end
+
+ private
+
+ def find_chat_name
+ ChatName.find_by(
+ service: @service,
+ team_id: @params[:team_id],
+ chat_id: @params[:user_id]
+ )
+ end
+ end
+end
diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb
index 0081364b8aa..a880952e274 100644
--- a/app/services/destroy_group_service.rb
+++ b/app/services/destroy_group_service.rb
@@ -6,12 +6,10 @@ class DestroyGroupService
end
def async_execute
- group.transaction do
- # Soft delete via paranoia gem
- group.destroy
- job_id = GroupDestroyWorker.perform_async(group.id, current_user.id)
- Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}")
- end
+ # Soft delete via paranoia gem
+ group.destroy
+ job_id = GroupDestroyWorker.perform_async(group.id, current_user.id)
+ Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}")
end
def execute
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index 77c6c81cc1b..647930d555c 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -18,7 +18,7 @@ class GitPushService < BaseService
#
def execute
@project.repository.after_create if @project.empty_repo?
- @project.repository.after_push_commit(branch_name, params[:newrev])
+ @project.repository.after_push_commit(branch_name)
if push_remove_branch?
@project.repository.after_remove_branch
@@ -51,12 +51,32 @@ class GitPushService < BaseService
execute_related_hooks
perform_housekeeping
+
+ update_caches
end
def update_gitattributes
@project.repository.copy_gitattributes(params[:ref])
end
+ def update_caches
+ if is_default_branch?
+ paths = Set.new
+
+ @push_commits.each do |commit|
+ commit.raw_diffs(deltas_only: true).each do |diff|
+ paths << diff.new_path
+ end
+ end
+
+ types = Gitlab::FileDetector.types_in_paths(paths.to_a)
+ else
+ types = []
+ end
+
+ ProjectCacheWorker.perform_async(@project.id, types)
+ end
+
protected
def execute_related_hooks
@@ -70,7 +90,6 @@ class GitPushService < BaseService
@project.execute_hooks(build_push_data.dup, :push_hooks)
@project.execute_services(build_push_data.dup, :push_hooks)
Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute
- ProjectCacheWorker.perform_async(@project.id)
if push_remove_branch?
AfterBranchDeleteService
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index bb92cd80cc9..575795788de 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -212,9 +212,9 @@ class IssuableBaseService < BaseService
def change_subscription(issuable)
case params.delete(:subscription_event)
when 'subscribe'
- issuable.subscribe(current_user)
+ issuable.subscribe(current_user, project)
when 'unsubscribe'
- issuable.unsubscribe(current_user)
+ issuable.unsubscribe(current_user, project)
end
end
diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb
index d572a928a42..12a8415d9a5 100644
--- a/app/services/merge_requests/add_todo_when_build_fails_service.rb
+++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb
@@ -1,13 +1,18 @@
module MergeRequests
class AddTodoWhenBuildFailsService < MergeRequests::BaseService
# Adds a todo to the parent merge_request when a CI build fails
+ #
def execute(commit_status)
+ return if commit_status.allow_failure?
+
commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
end
end
- # Closes any pending build failed todos for the parent MRs when a build is retried
+ # Closes any pending build failed todos for the parent MRs when a
+ # build is retried
+ #
def close(commit_status)
commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_retried(merge_request)
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index f415244068b..dd0d738674e 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -48,11 +48,11 @@ module MergeRequests
end
# See if source and target branches exist
- unless merge_request.source_project.commit(merge_request.source_branch)
+ if merge_request.source_branch.present? && !merge_request.source_project.commit(merge_request.source_branch)
messages << "Source branch \"#{merge_request.source_branch}\" does not exist"
end
- unless merge_request.target_project.commit(merge_request.target_branch)
+ if merge_request.target_branch.present? && !merge_request.target_project.commit(merge_request.target_branch)
messages << "Target branch \"#{merge_request.target_branch}\" does not exist"
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 4a7e6930842..22596b4014a 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -60,7 +60,15 @@ module MergeRequests
merge_requests = filter_merge_requests(merge_requests)
merge_requests.each do |merge_request|
- reload_diff(merge_request) unless branch_removed?
+ if merge_request.source_branch == @branch_name || force_push?
+ merge_request.reload_diff
+ else
+ mr_commit_ids = merge_request.commits.map(&:id)
+ push_commit_ids = @commits.map(&:id)
+ matches = mr_commit_ids & push_commit_ids
+ merge_request.reload_diff if matches.any?
+ end
+
merge_request.mark_as_unchecked
end
end
@@ -165,16 +173,5 @@ module MergeRequests
def branch_removed?
Gitlab::Git.blank_ref?(@newrev)
end
-
- def reload_diff(merge_request)
- if merge_request.source_branch == @branch_name || force_push?
- merge_request.reload_diff
- else
- mr_commit_ids = merge_request.commits.map(&:id)
- push_commit_ids = @commits.map(&:id)
- matches = mr_commit_ids & push_commit_ids
- merge_request.reload_diff if matches.any?
- end
- end
end
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index e338792412b..7935fabe2da 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -35,7 +35,7 @@ module Notes
todo_service.new_note(note, current_user)
end
- if command_params && command_params.any?
+ if command_params.present?
slash_commands_service.execute(command_params, note)
# We must add the error after we call #save because errors are reset
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 6697840cc26..ecdcbf08ee1 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -75,7 +75,7 @@ class NotificationService
# * watchers of the issue's labels
#
def relabeled_issue(issue, added_labels, current_user)
- relabeled_resource_email(issue, added_labels, current_user, :relabeled_issue_email)
+ relabeled_resource_email(issue, issue.project, added_labels, current_user, :relabeled_issue_email)
end
# When create a merge request we should send an email to:
@@ -118,7 +118,7 @@ class NotificationService
# * watchers of the mr's labels
#
def relabeled_merge_request(merge_request, added_labels, current_user)
- relabeled_resource_email(merge_request, added_labels, current_user, :relabeled_merge_request_email)
+ relabeled_resource_email(merge_request, merge_request.target_project, added_labels, current_user, :relabeled_merge_request_email)
end
def close_mr(merge_request, current_user)
@@ -205,7 +205,7 @@ class NotificationService
recipients = reject_muted_users(recipients, note.project)
- recipients = add_subscribed_users(recipients, note.noteable)
+ recipients = add_subscribed_users(recipients, note.project, note.noteable)
recipients = reject_unsubscribed_users(recipients, note.noteable)
recipients = reject_users_without_access(recipients, note.noteable)
@@ -393,7 +393,7 @@ class NotificationService
)
end
- # Build a list of users based on project notifcation settings
+ # Build a list of users based on project notification settings
def select_project_member_setting(project, global_setting, users_global_level_watch)
users = notification_settings_for(project, :watch)
@@ -505,17 +505,17 @@ class NotificationService
end
end
- def add_subscribed_users(recipients, target)
+ def add_subscribed_users(recipients, project, target)
return recipients unless target.respond_to? :subscribers
- recipients + target.subscribers
+ recipients + target.subscribers(project)
end
- def add_labels_subscribers(recipients, target, labels: nil)
+ def add_labels_subscribers(recipients, project, target, labels: nil)
return recipients unless target.respond_to? :labels
(labels || target.labels).each do |label|
- recipients += label.subscribers
+ recipients += label.subscribers(project)
end
recipients
@@ -571,8 +571,8 @@ class NotificationService
end
end
- def relabeled_resource_email(target, labels, current_user, method)
- recipients = build_relabeled_recipients(target, current_user, labels: labels)
+ def relabeled_resource_email(target, project, labels, current_user, method)
+ recipients = build_relabeled_recipients(target, project, current_user, labels: labels)
label_names = labels.map(&:name)
recipients.each do |recipient|
@@ -608,10 +608,10 @@ class NotificationService
end
recipients = reject_muted_users(recipients, project)
- recipients = add_subscribed_users(recipients, target)
+ recipients = add_subscribed_users(recipients, project, target)
if [:new_issue, :new_merge_request].include?(custom_action)
- recipients = add_labels_subscribers(recipients, target)
+ recipients = add_labels_subscribers(recipients, project, target)
end
recipients = reject_unsubscribed_users(recipients, target)
@@ -622,8 +622,8 @@ class NotificationService
recipients.uniq
end
- def build_relabeled_recipients(target, current_user, labels:)
- recipients = add_labels_subscribers([], target, labels: labels)
+ def build_relabeled_recipients(target, project, current_user, labels:)
+ recipients = add_labels_subscribers([], project, target, labels: labels)
recipients = reject_unsubscribed_users(recipients, target)
recipients = reject_users_without_access(recipients, target)
recipients.delete(current_user)
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 28db145a1f4..159f46cd465 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -106,6 +106,8 @@ module Projects
unless @project.group || @project.gitlab_project_import?
@project.team << [current_user, :master, current_user]
end
+
+ @project.group.refresh_members_authorized_projects if @project.group
end
def skip_wiki?
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index 5a81194a5f4..d75c5b1800e 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -193,7 +193,7 @@ module SlashCommands
desc 'Subscribe'
condition do
issuable.persisted? &&
- !issuable.subscribed?(current_user)
+ !issuable.subscribed?(current_user, project)
end
command :subscribe do
@updates[:subscription_event] = 'subscribe'
@@ -202,7 +202,7 @@ module SlashCommands
desc 'Unsubscribe'
condition do
issuable.persisted? &&
- issuable.subscribed?(current_user)
+ issuable.subscribed?(current_user, project)
end
command :unsubscribe do
@updates[:subscription_event] = 'unsubscribe'
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
new file mode 100644
index 00000000000..2469b4f0d7c
--- /dev/null
+++ b/app/services/user_project_access_changed_service.rb
@@ -0,0 +1,9 @@
+class UserProjectAccessChangedService
+ def initialize(user_ids)
+ @user_ids = Array.wrap(user_ids)
+ end
+
+ def execute
+ AuthorizedProjectsWorker.bulk_perform_async(@user_ids.map { |id| [id] })
+ end
+end
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index a236335131a..95cae5ea24b 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -22,9 +22,8 @@
.form-group
= f.label :restricted_visibility_levels, class: 'control-label col-sm-2'
.col-sm-10
- - data_attrs = { toggle: 'buttons' }
- .btn-group{ data: data_attrs }
- - restricted_level_checkboxes('restricted-visibility-help').each do |level|
+ - restricted_level_checkboxes('restricted-visibility-help').each do |level|
+ .checkbox
= level
%span.help-block#restricted-visibility-help
Selected levels cannot be used by non-admin users for projects or snippets.
diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml
index 26a8846b609..5e3f105d41f 100644
--- a/app/views/admin/builds/index.html.haml
+++ b/app/views/admin/builds/index.html.haml
@@ -14,5 +14,5 @@
.row-content-block.second-block
#{(@scope || 'all').capitalize} builds
- %ul.content-list.builds-content-list
+ %ul.content-list.builds-content-list.admin-builds-table
= render "projects/builds/table", builds: @builds, admin: true
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 817910f7ddf..589f4557b52 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -7,7 +7,7 @@
.col-sm-10
= render 'shared/choose_group_avatar_button', f: f
- = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
+ = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
.form-group
.col-sm-offset-2.col-sm-10
diff --git a/app/views/admin/groups/edit.html.haml b/app/views/admin/groups/edit.html.haml
index eb09a6328ed..c2b9807015d 100644
--- a/app/views/admin/groups/edit.html.haml
+++ b/app/views/admin/groups/edit.html.haml
@@ -1,4 +1,4 @@
- page_title "Edit", @group.name, "Groups"
%h3.page-title Edit group: #{@group.name}
%hr
-= render 'form'
+= render 'form', visibility_level: @group.visibility_level
diff --git a/app/views/admin/groups/new.html.haml b/app/views/admin/groups/new.html.haml
index c81ee552ac3..8f9fe96249f 100644
--- a/app/views/admin/groups/new.html.haml
+++ b/app/views/admin/groups/new.html.haml
@@ -1,4 +1,4 @@
- page_title "New Group"
%h3.page-title New group
%hr
-= render 'form'
+= render 'form', visibility_level: default_group_visibility
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 21b89580818..84e13693dfd 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -5,8 +5,6 @@
%div.form-group
= f.label :password
= f.password_field :password, class: "form-control bottom", required: true, title: "This field is required."
- %div.submit-container.move-submit-down
- = f.submit "Sign in", class: "btn btn-save"
- if devise_mapping.rememberable?
.remember-me.checkbox
%label{for: "user_remember_me"}
@@ -14,3 +12,5 @@
%span Remember me
.pull-right.forgot-password
= link_to "Forgot your password?", new_password_path(resource_name)
+ %div.submit-container.move-submit-down
+ = f.submit "Sign in", class: "btn btn-save"
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 7c68e3266e5..3133f6de2e8 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -8,7 +8,7 @@
= f.text_field :name, class: "form-control top", required: true, title: "This field is required."
%div.username.form-group
= f.label :username
- = f.text_field :username, class: "form-control middle", pattern: "[a-zA-Z0-9]+", required: true, title: 'Please create a username with only alphanumeric characters.'
+ = f.text_field :username, class: "form-control middle", pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_SIMPLE, required: true, title: 'Please create a username with only alphanumeric characters.'
%p.validation-error.hide Username is already taken.
%p.validation-success.hide Username is available.
%p.validation-pending.hide Checking username availability...
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 077e8e64e5f..e4b4ea675d2 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -1,9 +1,6 @@
- expanded = discussion.expanded?
%li.note.note-discussion.timeline-entry
.timeline-entry-inner
- .timeline-icon
- = link_to user_path(discussion.author) do
- = image_tag avatar_icon(discussion.author), class: "avatar s40"
.timeline-content
.discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } }
.discussion-header
@@ -13,9 +10,7 @@
= icon("chevron-up")
- else
= icon("chevron-down")
-
Toggle discussion
-
= link_to_member(@project, discussion.author, avatar: false)
.inline.discussion-headline-light
@@ -38,8 +33,6 @@
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
- = render "discussions/headline", discussion: discussion
-
.discussion-body.js-toggle-content{ class: ("hide" unless expanded) }
- if discussion.diff_discussion? && discussion.diff_file
= render "discussions/diff_with_notes", discussion: discussion
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index dc6c1bb69de..324a116a50e 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -3,24 +3,27 @@
- if current_user
= auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@group.name} issues")
-.top-area
- = render 'shared/issuable/nav', type: :issues
- .nav-controls
- - if current_user
- = link_to url_for(params.merge(format: :atom, private_token: current_user.private_token)), class: 'btn' do
- = icon('rss')
- %span.icon-label
- Subscribe
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
+- if group_issues(@group).exists?
+ .top-area
+ = render 'shared/issuable/nav', type: :issues
+ .nav-controls
+ - if current_user
+ = link_to url_for(params.merge(format: :atom, private_token: current_user.private_token)), class: 'btn' do
+ = icon('rss')
+ %span.icon-label
+ Subscribe
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
-= render 'shared/issuable/filter', type: :issues
+ = render 'shared/issuable/filter', type: :issues
-.row-content-block.second-block
- Only issues from
- %strong #{@group.name}
- group are listed here.
- - if current_user
- To see all issues you should visit #{link_to 'dashboard', issues_dashboard_path} page.
+ .row-content-block.second-block
+ Only issues from the
+ %strong #{@group.name}
+ group are listed here.
+ - if current_user
+ To see all issues you should visit #{link_to 'dashboard', issues_dashboard_path} page.
-.prepend-top-default
- = render 'shared/issues'
+ .prepend-top-default
+ = render 'shared/issues'
+- else
+ = render 'shared/empty_states/issues', project_select_button: true
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 8aefdcb3d9b..a9a0b149049 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -26,5 +26,5 @@
= render "layouts/flash"
= yield :flash_message
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
- .content
+ .content{ id: "content-body" }
= yield
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 7a9859262f7..5456be77aab 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,4 +1,5 @@
%header.navbar.navbar-fixed-top.navbar-gitlab{ class: nav_header_class }
+ %a{ href: "#content-body", tabindex: "1", class: "sr-only gl-accessibility" } Skip to content
%div{ class: "container-fluid" }
.header-content
%button.side-nav-toggle{ type: 'button', "aria-label" => "Toggle global navigation" }
diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml
index 6d514f669db..e06301bda14 100644
--- a/app/views/layouts/nav/_profile.html.haml
+++ b/app/views/layouts/nav/_profile.html.haml
@@ -17,6 +17,10 @@
= link_to applications_profile_path, title: 'Applications' do
%span
Applications
+ = nav_link(controller: :chat_names) do
+ = link_to profile_chat_names_path, title: 'Chat' do
+ %span
+ Chat
= nav_link(controller: :personal_access_tokens) do
= link_to profile_personal_access_tokens_path, title: 'Access Tokens' do
%span
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
new file mode 100644
index 00000000000..1ec1e7c70e4
--- /dev/null
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -0,0 +1,27 @@
+- service = chat_name.service
+- project = service.project
+%tr
+ %td
+ %strong
+ - if can?(current_user, :read_project, project)
+ = link_to project.name_with_namespace, project_path(project)
+ - else
+ .light N/A
+ %td
+ %strong
+ - if can?(current_user, :admin_project, project)
+ = link_to service.title, edit_namespace_project_service_path(project.namespace, project, service)
+ - else
+ = service.title
+ %td
+ = chat_name.team_domain
+ %td
+ = chat_name.chat_name
+ %td
+ - if chat_name.last_used_at
+ = time_ago_with_tooltip(chat_name.last_used_at)
+ - else
+ Never
+
+ %td
+ = link_to 'Remove', profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger pull-right', data: { confirm: 'Are you sure you want to revoke this nickname?' }
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
new file mode 100644
index 00000000000..20cc636b2da
--- /dev/null
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -0,0 +1,30 @@
+- page_title 'Chat'
+= render 'profiles/head'
+
+.row.prepend-top-default
+ .col-lg-3.profile-settings-sidebar
+ %h4.prepend-top-0
+ = page_title
+ %p
+ You can see your Chat accounts.
+
+ .col-lg-9
+ %h5 Active chat names (#{@chat_names.size})
+
+ - if @chat_names.present?
+ .table-responsive
+ %table.table.chat-names
+ %thead
+ %tr
+ %th Project
+ %th Service
+ %th Team domain
+ %th Nickname
+ %th Last used
+ %th
+ %tbody
+ = render @chat_names
+
+ - else
+ .settings-message.text-center
+ You don't have any active chat names.
diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml
new file mode 100644
index 00000000000..f635acf96e2
--- /dev/null
+++ b/app/views/profiles/chat_names/new.html.haml
@@ -0,0 +1,15 @@
+%h3.page-title Authorization required
+%main{:role => "main"}
+ %p.h4
+ Authorize
+ %strong.text-info= @chat_name_params[:chat_name]
+ to use your account?
+
+ %hr
+ .actions
+ = form_tag profile_chat_names_path, method: :post do
+ = hidden_field_tag :token, @chat_name_token.token
+ = submit_tag "Authorize", class: "btn btn-success wide pull-left"
+ = form_tag deny_profile_chat_names_path, method: :delete do
+ = hidden_field_tag :token, @chat_name_token.token
+ = submit_tag "Deny", class: "btn btn-danger prepend-left-10"
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index d011e51e696..4f15f2997fb 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -13,7 +13,7 @@
= spinner
:javascript
- var activity = new Activities();
+ var activity = new gl.Activities();
$(document).on('page:restore', function (event) {
activity.reloadActivities()
})
diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml
index 9f69bd64f71..f6aa20c4579 100644
--- a/app/views/projects/builds/_header.html.haml
+++ b/app/views/projects/builds/_header.html.haml
@@ -17,6 +17,6 @@
= render "user"
= time_ago_with_tooltip(@build.created_at)
- if can?(current_user, :update_build, @build) && @build.retryable?
- = link_to "Retry build", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted pull-right', method: :post
+ = link_to "Retry build", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary pull-right', method: :post
%button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" }
= icon('angle-double-left')
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
index f533eec642e..d8cbfd7173a 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/builds/show.html.haml
@@ -26,6 +26,30 @@
= link_to namespace_project_runners_path(@build.project.namespace, @build.project) do
Runners page
+ - if @build.starts_environment?
+ .prepend-top-default
+ .environment-information
+ - if @build.outdated_deployment?
+ = ci_icon_for_status('success_with_warnings')
+ - else
+ = ci_icon_for_status(@build.status)
+
+ - environment = environment_for_build(@build.project, @build)
+ - if @build.success? && @build.last_deployment.present?
+ - if @build.last_deployment.last?
+ This build is the most recent deployment to #{environment_link_for_build(@build.project, @build)}.
+ - else
+ This build is an out-of-date deployment to #{environment_link_for_build(@build.project, @build)}.
+ - if environment.last_deployment
+ View the most recent deployment #{deployment_link(environment.last_deployment)}.
+ - elsif @build.complete? && !@build.success?
+ The deployment of this build to #{environment_link_for_build(@build.project, @build)} did not succeed.
+ - else
+ This build is creating a deployment to #{environment_link_for_build(@build.project, @build)}
+ - if environment.last_deployment
+ and will overwrite the
+ = link_to 'latest deployment', deployment_link(environment.last_deployment)
+
.prepend-top-default
- if @build.erased?
.erased.alert.alert-warning
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 0ebc38d16cf..503cbd13b5e 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,5 +1,5 @@
-.commit-info-row.commit-info-row-header
- .commit-meta
+.page-content-header
+ .header-main-content
%strong Commit
%strong.monospace.js-details-short= @commit.short_id
= link_to("#", class: "js-details-expand hidden-xs hidden-sm") do
@@ -19,7 +19,8 @@
%strong
= commit_committer_link(@commit, avatar: true, size: 24)
#{time_ago_with_tooltip(@commit.committed_date)}
- .commit-action-buttons
+
+ .header-action-buttons
- if defined?(@notes_count) && @notes_count > 0
%span.btn.disabled.btn-grouped.hidden-xs.append-right-10
= icon('comment')
@@ -55,8 +56,8 @@
%pre.commit-description
= preserve(markdown(@commit.description, pipeline: :single_line, author: @commit.author))
-.commit-info-widget
- .widget-row.branch-info
+.info-well
+ .well-segment.branch-info
.icon-container.commit-icon
= custom_icon("icon_commit")
%span.cgray= pluralize(@commit.parents.count, "parent")
@@ -66,7 +67,7 @@
%i.fa.fa-spinner.fa-spin
- if @commit.status
- .widget-row.pipeline-info
+ .well-segment.pipeline-info
.icon-container
= ci_icon_for_status(@commit.status)
Pipeline
diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml
index 062a8905a19..1174158eb65 100644
--- a/app/views/projects/commit/_pipeline.html.haml
+++ b/app/views/projects/commit/_pipeline.html.haml
@@ -1,10 +1,6 @@
.pipeline-graph-container
.row-content-block.build-content.middle-block.pipeline-actions
.pull-right
- %button.btn.btn-grouped.btn-white.toggle-pipeline-btn
- %span.toggle-btn-text Hide
- %span pipeline graph
- %span.caret
- if can?(current_user, :update_pipeline, pipeline.project)
- if pipeline.builds.latest.failed.any?(&:retryable?)
= link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 34855c54176..12096941209 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -36,7 +36,6 @@
%pre.commit-row-description.js-toggle-content
= preserve(markdown(commit.description, pipeline: :single_line, author: commit.author))
- .commit-row-info
- = commit_author_link(commit, avatar: false, size: 24)
- authored
- #{time_ago_with_tooltip(commit.committed_date)}
+ = commit_author_link(commit, avatar: false, size: 24)
+ authored
+ #{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml
new file mode 100644
index 00000000000..b200ce22970
--- /dev/null
+++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml
@@ -0,0 +1,7 @@
+.empty-stage-container
+ .empty-stage
+ .icon-no-data
+ = custom_icon ('icon_no_data')
+ %h4 We don’t have enough data to show this stage.
+ %p
+ {{currentStage.emptyStageText}}
diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml
new file mode 100644
index 00000000000..0ffc79b3181
--- /dev/null
+++ b/app/views/projects/cycle_analytics/_no_access.html.haml
@@ -0,0 +1,7 @@
+.no-access-stage-container
+ .no-access-stage
+ .icon-lock
+ = custom_icon ('icon_lock')
+ %h4 You need permission.
+ %p
+ Want to see the data? Please ask administrator for access.
diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml
new file mode 100644
index 00000000000..c8f0b547f80
--- /dev/null
+++ b/app/views/projects/cycle_analytics/_overview.html.haml
@@ -0,0 +1,15 @@
+.cycle-analytics-overview
+ .container
+ .row
+ .col-md-10.col-md-offset-1
+ .row.overview-details
+ .col-md-6.overview-text
+ %h4 Introducing Cycle Analytics
+ %p
+ Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
+ To set up CA, you must first define a production environment by setting up your CI and then deploy to production.
+ %p
+ %a.btn{ href: help_page_path('user/project/cycle_analytics'), target: "_blank" } Read more
+ .col-md-6.overview-image
+ %span.overview-icon
+ = custom_icon ('icon_cycle_analytics_overview')
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 247d612ba6f..ef1b38d5e21 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,40 +1,35 @@
- @no_container = true
- page_title "Cycle Analytics"
-
- content_for :page_specific_javascripts do
- = page_specific_javascript_tag('cycle_analytics/cycle_analytics_bundle.js')
+ = page_specific_javascript_tag("cycle_analytics/cycle_analytics_bundle.js")
= render "projects/pipelines/head"
-#cycle-analytics{class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) }}
-
- .bordered-box.landing.content-block{"v-if" => "!isHelpDismissed"}
- = icon('times', class: 'dismiss-icon', "@click" => "dismissLanding()")
- .row
- .col-sm-3.col-xs-12.svg-container
- = custom_icon('icon_cycle_analytics_splash')
- .col-sm-8.col-xs-12.inner-content
- %h4
- Introducing Cycle Analytics
- %p
- Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
-
- = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
+#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
+ - if @cycle_analytics_no_data
+ .bordered-box.landing.content-block{"v-if" => "!isOverviewDialogDismissed"}
+ = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()")
+ .row
+ .col-sm-3.col-xs-12.svg-container
+ = custom_icon('icon_cycle_analytics_splash')
+ .col-sm-8.col-xs-12.inner-content
+ %h4
+ Introducing Cycle Analytics
+ %p
+ Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
+ = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
= icon("spinner spin", "v-show" => "isLoading")
-
.wrapper{"v-show" => "!isLoading && !hasError"}
.panel.panel-default
.panel-heading
Pipeline Health
-
.content-block
.container-fluid
.row
- .col-sm-3.col-xs-12.column{"v-for" => "item in analytics.summary"}
+ .col-sm-3.col-xs-12.column{"v-for" => "item in state.summary"}
%h3.header {{item.value}}
%p.text {{item.title}}
-
.col-sm-3.col-xs-12.column
.dropdown.inline.js-ca-dropdown
%button.dropdown-menu-toggle{"data-toggle" => "dropdown", :type => "button"}
@@ -42,22 +37,54 @@
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-menu-align-right
%li
- %a{'href' => "#", 'data-value' => '30'}
+ %a{ "href" => "#", "data-value" => "30" }
Last 30 days
%li
- %a{'href' => "#", 'data-value' => '90'}
+ %a{ "href" => "#", "data-value" => "90" }
Last 90 days
-
- .bordered-box
- %ul.content-list
- %li{"v-for" => "item in analytics.stats"}
- .container-fluid
- .row
- .col-xs-8.title-col
- %p.title
- {{item.title}}
- %p.text
- {{item.description}}
- .col-xs-4.value-col
- %span
- {{item.value}}
+ .stage-panel-container
+ .panel.panel-default.stage-panel
+ .panel-heading
+ %nav.col-headers
+ %ul
+ %li.stage-header
+ %span.stage-name
+ Stage
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" }
+ %li.median-header
+ %span.stage-name
+ Median
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "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.", "aria-hidden" => "true" }
+ %li.event-header
+ %span.stage-name
+ {{ currentStage ? currentStage.legend : 'Related Issues' }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" }
+ %li.total-time-header
+ %span.stage-name
+ Total Time
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" }
+ .stage-panel-body
+ %nav.stage-nav
+ %ul
+ %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" }
+ .stage-nav-item-cell.stage-name
+ {{ stage.title }}
+ .stage-nav-item-cell.stage-median
+ %template{ "v-if" => "stage.isUserAllowed" }
+ %span{ "v-if" => "stage.value" }
+ {{ stage.value }}
+ %span.stage-empty{ "v-else" => true }
+ Not enough data
+ %template{ "v-else" => true }
+ %span.not-available
+ Not available
+ .section.stage-events
+ %template{ "v-if" => "isLoadingStage" }
+ = icon("spinner spin")
+ %template{ "v-if" => "currentStage && !currentStage.isUserAllowed" }
+ = render partial: "no_access"
+ %template{ "v-else" => true }
+ %template{ "v-if" => "isEmptyStage && !isLoadingStage" }
+ = render partial: "empty_stage"
+ %template{ "v-if" => "state.events.length && !isLoadingStage && !isEmptyStage" }
+ %component{ ":is" => "currentStage.component", ":stage" => "currentStage", ":items" => "state.events" }
diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml
deleted file mode 100644
index b75d5df4150..00000000000
--- a/app/views/projects/environments/_environment.html.haml
+++ /dev/null
@@ -1,35 +0,0 @@
-- last_deployment = environment.last_deployment
-
-%tr.environment
- %td
- = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment)
-
- %td.deployment-column
- - if last_deployment
- %span ##{last_deployment.iid}
- - if last_deployment.user
- by
- = user_avatar(user: last_deployment.user, size: 20)
-
- %td
- - if last_deployment && last_deployment.deployable
- = link_to [@project.namespace.becomes(Namespace), @project, last_deployment.deployable], class: 'build-link' do
- = "#{last_deployment.deployable.name} (##{last_deployment.deployable.id})"
-
- %td
- - if last_deployment
- = render 'projects/deployments/commit', deployment: last_deployment
- - else
- %p.commit-title
- No deployments yet
-
- %td
- - if last_deployment
- #{time_ago_with_tooltip(last_deployment.created_at)}
-
- %td.hidden-xs
- .pull-right
- = render 'projects/environments/external_url', environment: environment
- = render 'projects/deployments/actions', deployment: last_deployment
- = render 'projects/environments/stop', environment: environment
- = render 'projects/deployments/rollback', deployment: last_deployment
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 8f555afcf11..a9235d6af35 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -2,47 +2,19 @@
- page_title "Environments"
= render "projects/pipelines/head"
-%div{ class: container_class }
- .top-area
- %ul.nav-links
- %li{class: ('active' if @scope.nil?)}
- = link_to project_environments_path(@project) do
- Available
- %span.badge.js-available-environments-count
- = number_with_delimiter(@all_environments.available.count)
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag("environments/environments_bundle.js")
+.commit-icon-svg.hidden
+ = custom_icon("icon_commit")
+.play-icon-svg.hidden
+ = custom_icon("icon_play")
- %li{class: ('active' if @scope == 'stopped')}
- = link_to project_environments_path(@project, scope: :stopped) do
- Stopped
- %span.badge.js-stopped-environments-count
- = number_with_delimiter(@all_environments.stopped.count)
-
- - if can?(current_user, :create_environment, @project) && !@all_environments.blank?
- .nav-controls
- = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do
- New environment
-
- .environments-container
- - if @all_environments.blank?
- .blank-state.blank-state-no-icon
- %h2.blank-state-title
- You don't have any environments right now.
- %p.blank-state-text
- Environments are places where code gets deployed, such as staging or production.
- %br
- = succeed "." do
- = link_to "Read more about environments", help_page_path("ci/environments")
- - if can?(current_user, :create_environment, @project)
- = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do
- New environment
- - else
- .table-holder
- %table.table.ci-table.environments
- %tbody
- %th Environment
- %th Last Deployment
- %th Build
- %th Commit
- %th
- %th.hidden-xs
- = render @environments
+#environments-list-view{ data: { environments_data: environments_list_data,
+ "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
+ "can-read-environment" => can?(current_user, :read_environment, @project).to_s,
+ "can-create-environment" => can?(current_user, :create_environment, @project).to_s,
+ "project-environments-path" => project_environments_path(@project),
+ "project-stopped-environments-path" => project_environments_path(@project, scope: :stopped),
+ "new-environment-path" => new_namespace_project_environment_path(@project.namespace, @project),
+ "help-page-path" => help_page_path("ci/environments"),
+ "css-class" => container_class}}
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index a4b752ad86d..34d5a3e1831 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -1,8 +1,7 @@
%ul.content-list.issues-list.issuable-list
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
- %li
- .nothing-here-block No issues to show
+ = render 'shared/empty_states/issues'
- if @issues.present?
= paginate @issues, theme: "gitlab"
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index c493ff3585b..26f3f0ac292 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -10,8 +10,8 @@
- if current_user
= auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@project.name} issues")
-%div{ class: (container_class) }
- - if @project.issues.any?
+- if project_issues(@project).exists?
+ %div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
@@ -36,21 +36,5 @@
= render 'issues'
- if new_issue_email
= render 'issue_by_email', email: new_issue_email
- - else
- .blank-state.blank-state-welcome
- %h2.blank-state-title.blank-state-welcome-title
- Welcome to GitLab Issues
- %p.blank-state-text
- Code, test, and deploy together
- .blank-state
- .blank-state-icon
- = custom_icon("issues", size: 50)
- %h3.blank-state-title
- You don't have any issues right now.
- %p.blank-state-text
- Issues are the best way to track your project progress
- - if can? current_user, :create_issue, @project
- = link_to new_namespace_project_issue_path(@project.namespace, @project), class: "btn btn-new", title: "New Issue", id: "new_issue_link" do
- New Issue
- - if new_issue_email
- = render 'issue_by_email', email: new_issue_email
+- else
+ = render 'shared/empty_states/issues', button_path: new_namespace_project_issue_path(@project.namespace, @project)
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 12408068834..9ffcc48eb80 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -54,15 +54,18 @@
= link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do
= icon('code-fork')
= merge_request.target_branch
+
- if merge_request.milestone
&nbsp;
= link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do
= icon('clock-o')
= merge_request.milestone.title
+
- if merge_request.labels.any?
&nbsp;
- merge_request.labels.each do |label|
= link_to_label(label, subject: merge_request.project, type: :merge_request)
+
- if merge_request.tasks?
&nbsp;
%span.task-status
diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml
index 01314eb37d0..20c93930abc 100644
--- a/app/views/projects/merge_requests/widget/_open.html.haml
+++ b/app/views/projects/merge_requests/widget/_open.html.haml
@@ -9,10 +9,10 @@
- if @project.archived?
= render 'projects/merge_requests/widget/open/archived'
- - elsif @merge_request.commits.blank?
- = render 'projects/merge_requests/widget/open/nothing'
- elsif @merge_request.branch_missing?
= render 'projects/merge_requests/widget/open/missing_branch'
+ - elsif @merge_request.commits.blank?
+ = render 'projects/merge_requests/widget/open/nothing'
- elsif @merge_request.unchecked?
= render 'projects/merge_requests/widget/open/check'
- elsif @merge_request.cannot_be_merged? && !resolved_conflicts
@@ -23,7 +23,7 @@
= render 'projects/merge_requests/widget/open/merge_when_build_succeeds'
- elsif !@merge_request.can_be_merged_by?(current_user)
= render 'projects/merge_requests/widget/open/not_allowed'
- - elsif !@merge_request.mergeable_ci_state?
+ - elsif !@merge_request.mergeable_ci_state? && (@pipeline.failed? || @pipeline.canceled?)
= render 'projects/merge_requests/widget/open/build_failed'
- elsif !@merge_request.mergeable_discussions_state?
= render 'projects/merge_requests/widget/open/unresolved_discussions'
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index f9ba77e87b5..e01aca3dda6 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -4,7 +4,7 @@
= render "projects/issues/head"
%div{ class: container_class }
- .detail-page-header
+ .detail-page-header.milestone-page-header
.status-box{ class: status_box_class(@milestone) }
- if @milestone.closed?
Closed
@@ -12,13 +12,14 @@
Past due
- else
Open
- %span.identifier
- Milestone ##{@milestone.iid}
- - if @milestone.expires_at
- %span.creator
- &middot;
- = @milestone.expires_at
- .pull-right
+ .header-text-content
+ %span.identifier
+ Milestone ##{@milestone.iid}
+ - if @milestone.expires_at
+ %span.creator
+ &middot;
+ = @milestone.expires_at
+ .milestone-buttons
- if can?(current_user, :admin_milestone, @project)
- if @milestone.active?
= link_to 'Close Milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
index afff15228c1..89ae64554c0 100644
--- a/app/views/projects/notes/_note.html.haml
+++ b/app/views/projects/notes/_note.html.haml
@@ -14,6 +14,9 @@
= note.author.to_reference
- unless note.system
commented
+ - if note.system
+ %span{class: 'system-note-message'}
+ = h(note.note_html.downcase.html_safe)
%a{ href: "##{dom_id(note)}" }
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- unless note.system?
@@ -67,7 +70,9 @@
= render 'projects/notes/edit_form', note: note
.note-awards
= render 'award_emoji/awards_block', awardable: note, inline: false
-
+ - if note.system
+ .system-note-commit-list-toggler
+ Toggle commit list
- if note.attachment.url
.note-attachment
- if note.attachment.image?
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index d288efc546f..a0de125d765 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,39 +1,49 @@
-%p
-.commit-info-row
- Pipeline
- = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @pipeline.id), class: "monospace"
- with
- = pluralize @pipeline.statuses.count(:id), "build"
- - if @pipeline.ref
- for
- = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace"
- - if @pipeline.duration
- in
- = time_interval_in_words(@pipeline.duration)
- - if @pipeline.queued_duration
- = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
-
- .pull-right
+.page-content-header
+ .header-main-content
= link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), class: "ci-status ci-#{@pipeline.status}" do
= ci_icon_for_status(@pipeline.status)
= ci_label_for_status(@pipeline.status)
+ %strong Pipeline ##{@commit.pipelines.last.id}
+ triggered #{time_ago_with_tooltip(@commit.authored_date)} by
+ = author_avatar(@commit, size: 24)
+ = commit_author_link(@commit)
+ .header-action-buttons
+ - if can?(current_user, :update_pipeline, @pipeline.project)
+ - if @pipeline.builds.latest.failed.any?(&:retryable?)
+ = link_to "Retry failed", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'btn btn-inverted-secondary', method: :post
+ - if @pipeline.builds.running_or_pending.any?
+ = link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
- if @commit
- .commit-info-row
- %span.light Authored by
- %strong
- = commit_author_link(@commit, avatar: true, size: 24)
- #{time_ago_with_tooltip(@commit.authored_date)}
-
-.commit-info-row
- %span.light Commit
- = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace"
- = clipboard_button(clipboard_text: @pipeline.sha)
-
-- if @commit
- .commit-box.content-block
+ .commit-box
%h3.commit-title
= markdown(@commit.title, pipeline: :single_line)
- if @commit.description.present?
%pre.commit-description
= preserve(markdown(@commit.description, pipeline: :single_line))
+
+.info-well
+ - if @commit.status
+ .well-segment.pipeline-info
+ .icon-container
+ = ci_icon_for_status(@commit.status)
+ = pluralize @pipeline.statuses.count(:id), "build"
+ - if @pipeline.ref
+ from
+ = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace"
+ - if @pipeline.duration
+ in
+ = time_interval_in_words(@pipeline.duration)
+ - if @pipeline.queued_duration
+ = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
+
+ .well-segment.branch-info
+ .icon-container.commit-icon
+ = custom_icon("icon_commit")
+ = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short"
+ = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do
+ %span.text-expander
+ \...
+ %span.js-details-content.hide
+ = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full"
+ = clipboard_button(clipboard_text: @pipeline.sha, title: "Copy commit SHA to clipboard")
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
new file mode 100644
index 00000000000..718314701f9
--- /dev/null
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -0,0 +1,51 @@
+.tabs-holder
+ %ul.nav-links.no-top.no-bottom
+ %li.active
+ = link_to "Pipeline", "#js-tab-pipeline", data: { target: '#js-tab-pipeline', action: 'pipeline', toggle: 'tab' }, class: 'pipeline-tab'
+ %li
+ = link_to "#js-tab-builds", data: { target: '#js-tab-builds', action: 'build', toggle: 'tab' }, class: 'builds-tab' do
+ Builds
+ %span.badge= pipeline.statuses.count
+
+.tab-content
+ #js-tab-pipeline.tab-pane.active
+ .build-content.middle-block.pipeline-graph
+ .pipeline-visualization
+ %ul.stage-column-list
+ - stages = pipeline.stages_with_latest_statuses
+ - stages.each do |stage, statuses|
+ %li.stage-column
+ .stage-name
+ %a{name: stage}
+ - if stage
+ = stage.titleize
+ .builds-container
+ %ul
+ = render "projects/commit/pipeline_stage", statuses: statuses
+
+ #js-tab-builds.tab-pane
+ - if pipeline.yaml_errors.present?
+ .bs-callout.bs-callout-danger
+ %h4 Found errors in your .gitlab-ci.yml:
+ %ul
+ - pipeline.yaml_errors.split(",").each do |error|
+ %li= error
+ You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path}
+
+ - if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file
+ .bs-callout.bs-callout-warning
+ \.gitlab-ci.yml not found in this commit
+
+ .table-holder.pipeline-holder
+ %table.table.ci-table.pipeline
+ %thead
+ %tr
+ %th Status
+ %th Build ID
+ %th Name
+ %th
+ - if pipeline.project.build_coverage_enabled?
+ %th Coverage
+ %th
+ - pipeline.statuses.relevant.stages.each do |stage|
+ = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.relevant.where(stage: stage)
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 688535ad764..8c6652a5f90 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -3,9 +3,7 @@
= render "projects/pipelines/head"
%div{ class: container_class }
- .prepend-top-default
- - if @commit
- = render "projects/pipelines/info"
- %div.block-connector
+ - if @commit
+ = render "projects/pipelines/info"
- = render "projects/commit/pipeline", pipeline: @pipeline
+ = render "projects/pipelines/with_tabs", pipeline: @pipeline
diff --git a/app/views/projects/refs/logs_tree.js.haml b/app/views/projects/refs/logs_tree.js.haml
index 44fa4b60343..d07bb661615 100644
--- a/app/views/projects/refs/logs_tree.js.haml
+++ b/app/views/projects/refs/logs_tree.js.haml
@@ -14,8 +14,8 @@
// Load more commit logs for each file in tree
// if we still on the same page
var url = "#{escape_javascript(@more_log_url)}";
- ajaxGet(url);
+ gl.utils.ajaxGet(url);
}
:plain
- gl.utils.localTimeAgo($('.js-timeago', 'table.table_#{@hex_path} tbody')); \ No newline at end of file
+ gl.utils.localTimeAgo($('.js-timeago', 'table.table_#{@hex_path} tbody'));
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index a5df502d7b5..baa6d5f8206 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -13,4 +13,4 @@
= render 'projects/issues/issue', issue: issue
= paginate @issues, theme: "gitlab"
- else
- .nothing-here-block No issues to show
+ = render 'shared/empty_states/issues'
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 6ccdef0df46..db324d8868e 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -1,6 +1,7 @@
- label_css_id = dom_id(label)
- open_issues_count = label.open_issues_count(current_user)
- open_merge_requests_count = label.open_merge_requests_count(current_user)
+- status = label_subscription_status(label, @project).inquiry if current_user
- subject = local_assigns[:subject]
%li{id: label_css_id, data: { id: label.id } }
@@ -18,10 +19,19 @@
%li
= link_to_label(label, subject: subject) do
= pluralize open_issues_count, 'open issue'
- - if current_user
- %li.label-subscription{ data: toggle_subscription_data(label) }
- %a.js-subscribe-button.label-subscribe-button.subscription-status{ role: "button", href: "#", data: { toggle: "tooltip", status: label_subscription_status(label) } }
- %span= label_subscription_toggle_button_text(label)
+ - if current_user && defined?(@project)
+ %li.label-subscription
+ - if label.is_a?(ProjectLabel)
+ %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', data: { status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ %span= label_subscription_toggle_button_text(label, @project)
+ - else
+ %a.js-unsubscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' if status.unsubscribed?), data: { url: group_label_unsubscribe_path(label, @project) } }
+ %span Unsubscribe
+ %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ %span Subscribe at project level
+ %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } }
+ %span Subscribe at group level
+
- if can?(current_user, :admin_label, label)
%li
= link_to 'Edit', edit_label_path(label)
@@ -34,12 +44,27 @@
= link_to_label(label, subject: subject, css_class: 'btn btn-transparent btn-action') do
= pluralize open_issues_count, 'open issue'
- - if current_user
- .label-subscription.inline{ data: toggle_subscription_data(label) }
- %button.js-subscribe-button.label-subscribe-button.btn.btn-transparent.btn-action.subscription-status{ type: "button", title: label_subscription_toggle_button_text(label), data: { toggle: "tooltip", status: label_subscription_status(label) } }
- %span.sr-only= label_subscription_toggle_button_text(label)
- = icon('eye', class: 'label-subscribe-button-icon', disabled: label.is_a?(GroupLabel))
- = icon('spinner spin', class: 'label-subscribe-button-loading')
+ - if current_user && defined?(@project)
+ .label-subscription.inline
+ - if label.is_a?(ProjectLabel)
+ %button.js-subscribe-button.label-subscribe-button.btn.btn-default.btn-action{ type: 'button', title: label_subscription_toggle_button_text(label, @project), data: { toggle: 'tooltip', status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ %span= label_subscription_toggle_button_text(label, @project)
+ = icon('spinner spin', class: 'label-subscribe-button-loading')
+ - else
+ %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default.btn-action{ type: 'button', class: ('hidden' if status.unsubscribed?), title: 'Unsubscribe', data: { toggle: 'tooltip', url: group_label_unsubscribe_path(label, @project) } }
+ %span Unsubscribe
+ = icon('spinner spin', class: 'label-subscribe-button-loading')
+
+ .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) }
+ %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'}
+ %span Subscribe
+ = icon('chevron-down')
+ %ul.dropdown-menu
+ %li
+ %a.js-subscribe-button{ data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ Project level
+ %a.js-subscribe-button{ data: { url: toggle_subscription_group_label_path(label.group, label) } }
+ Group level
- if can?(current_user, :admin_label, label)
= link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do
@@ -49,6 +74,10 @@
%span.sr-only Delete
= icon('trash-o')
- - if current_user && label.is_a?(ProjectLabel)
- :javascript
- new Subscription('##{dom_id(label)} .label-subscription');
+ - if current_user && defined?(@project)
+ - if label.is_a?(ProjectLabel)
+ :javascript
+ new gl.ProjectLabelSubscription('##{dom_id(label)} .label-subscription');
+ - else
+ :javascript
+ new gl.GroupLabelSubscription('##{dom_id(label)} .label-subscription');
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 5254d265918..601ef51737a 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -10,26 +10,27 @@
.col-sm-10
= form.check_box :active
-.form-group
- = form.label :url, "Trigger", class: 'control-label'
-
- .col-sm-10
- - @service.supported_events.each do |event|
- %div
- = form.check_box service_event_field_name(event), class: 'pull-left'
- .prepend-left-20
- = form.label service_event_field_name(event), class: 'list-label' do
- %strong
- = event.humanize
-
- - field = @service.event_field(event)
-
- - if field
- %p
- = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder]
-
- %p.light
- = service_event_description(event)
+- if @service.supported_events.present?
+ .form-group
+ = form.label :url, "Trigger", class: 'control-label'
+
+ .col-sm-10
+ - @service.supported_events.each do |event|
+ %div
+ = form.check_box service_event_field_name(event), class: 'pull-left'
+ .prepend-left-20
+ = form.label service_event_field_name(event), class: 'list-label' do
+ %strong
+ = event.humanize
+
+ - field = @service.event_field(event)
+
+ - if field
+ %p
+ = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder]
+
+ %p.light
+ = service_event_description(event)
- @service.global_fields.each do |field|
- type = field[:type]
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
new file mode 100644
index 00000000000..e939278bc07
--- /dev/null
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -0,0 +1,22 @@
+- button_path = local_assigns.fetch(:button_path, false)
+- project_select_button = local_assigns.fetch(:project_select_button, false)
+- has_button = button_path || project_select_button
+
+.row.empty-state
+ .pull-right.col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .svg-content
+ = render 'shared/empty_states/icons/issues.svg'
+ .col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .text-content
+ - if has_button
+ %h4
+ The Issue Tracker is a good place to add things that need to be improved or solved in a project!
+ %p
+ An issue can be a bug, a todo or a feature request that needs to be discussed in a project.
+ Besides, issues are searchable and filterable.
+ - if project_select_button
+ = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue'
+ - else
+ = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link'
+ - else
+ %h4.text-center There are no issues to show.
diff --git a/app/views/shared/empty_states/icons/_issues.svg b/app/views/shared/empty_states/icons/_issues.svg
new file mode 100644
index 00000000000..2e92bf19579
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_issues.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="790 253 425 254" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="25" height="8.9423" x="25" y="88.4231" rx="2"/><mask id="h" width="25" height="8.9423" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M16 29.8013h43V91.404H16z"/><mask id="i" width="43" height="61.6026" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><path id="c" d="M57 60.6026l13.1868 9.3587c.449.3188.876 1.0142.9556 1.5673l3.5747 24.863c.1564 1.0866-.253 1.2572-.912.384L66 86.436l-9-6.9552"/><mask id="j" width="17.7504" height="36.7306" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><path id="d" d="M.2496 60.6026l13.1868 9.3587c.449.3188.876 1.0142.9556 1.5673l3.5748 24.863c.1562 1.0866-.2532 1.2572-.9123.384L9.2495 86.436l-9-6.9552"/><mask id="k" width="17.7504" height="36.7306" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><path id="e" d="M16 29.8013L35.786 1.4556c.9466-1.3562 2.4792-1.3594 3.428 0L59 29.8013"/><mask id="l" width="43" height="29.364" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><rect id="f" width="26.2653" height="35.5088" x="6.3673" rx="13.1327"/><mask id="m" width="26.2653" height="35.5088" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><rect id="g" width="16.8367" height="22.386" x="4.0816" rx="8.4184"/><mask id="n" width="16.8367" height="22.386" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(792.000000, 255.000000)"><g fill="#FDE5D8"><path d="M225.4372 59.5866c-.059.5897-.1323 1.2698-.2203 2.0305-.252 2.1764-.5717 4.559-.9653 7.07-.1283.8185.4312 1.586 1.2496 1.7143.8185.1283 1.586-.4312 1.7142-1.2497.4-2.5528.7253-4.975.9815-7.1898.0898-.7762.1646-1.4715.2252-2.0762.0366-.365.0604-.62.0722-.7557.0717-.8254-.539-1.5526-1.3645-1.6244-.8254-.0717-1.5526.539-1.6244 1.3645-.0106.1228-.0332.365-.0684.7166zM219.8738 87.9413c-.2563.7878.1745 1.6342.9622 1.8906.7878.2562 1.6342-.1745 1.8906-.9623.975-2.9962 1.849-6.2827 2.6287-9.797.1794-.8086-.3308-1.6097-1.1395-1.789-.8088-.1795-1.61.3306-1.7893 1.1394-.76 3.4256-1.6096 6.6206-2.5527 9.5183zM209.9266 103.166c-.781.2766-1.1897 1.134-.913 1.9148.2765.781 1.1338 1.1897 1.9147.913 2.9792-1.0552 5.5414-3.679 7.7796-7.6272.4084-.7207.1554-1.636-.5653-2.0447-.7207-.4086-1.636-.1556-2.0446.565-1.9152 3.3786-3.9945 5.508-6.1714 6.279zM190.439 107.5834c-.7636.3214-1.122 1.201-.8005 1.9645.3215.7634 1.201 1.1217 1.9645.8003 3.1204-1.314 6.2717-2.3243 9.258-2.9816.809-.178 1.3205-.9783 1.1424-1.7874-.178-.809-.9783-1.3205-1.7874-1.1424-3.1666.697-6.4914 1.763-9.777 3.1464zM173.231 118.6257c-.6005.5706-.6248 1.52-.0542 2.1206s1.52.625 2.1206.0543c2.282-2.1682 4.8656-4.162 7.6758-5.946.6994-.444.9064-1.371.4624-2.0704-.444-.6994-1.371-.9064-2.0704-.4624-2.9698 1.8854-5.707 3.998-8.1342 6.304zM162.4543 136.2492c-.2022.8034.2852 1.6185 1.0885 1.8207.8034.202 1.6186-.2853 1.8208-1.0886.7688-3.0547 2.0416-5.9768 3.781-8.7486.4403-.7018.2284-1.6276-.4733-2.068-.7017-.4402-1.6275-.2283-2.068.4734-1.9026 3.0322-3.3016 6.2438-4.149 9.611zM162.1894 156.693c.1036.822.854 1.4042 1.676 1.3006.8218-.1037 1.404-.854 1.3004-1.676-.367-2.9097-.5796-6.1364-.6444-9.8167-.0146-.8284-.698-1.488-1.5262-1.4734-.8283.0146-1.488.698-1.4733 1.5262.0665 3.783.286 7.1162.6674 10.1393zM168.408 176.1653c.3876.7322 1.2953 1.0117 2.0275.6242.7322-.3875 1.0117-1.2952.6242-2.0274-1.6733-3.162-2.9028-5.9954-3.8477-8.943-.2528-.789-1.0973-1.2235-1.8862-.9706-.789.2528-1.2234 1.0974-.9706 1.8863 1.0025 3.1275 2.3014 6.121 4.053 9.4306zM175.9738 188.9357c1.056 1.7165 1.8892 3.0806 2.7307 4.474.4283.709 1.3503.9368 2.0595.5085.709-.4283.9368-1.3503.5085-2.0595-.8464-1.4014-1.6836-2.772-2.7434-4.4948.0808.131-1.9545-3.1733-2.486-4.0405-.4328-.7063-1.3563-.928-2.0627-.495-.7063.4327-.928 1.3563-.495 2.0626.5334.8707 2.5708 4.1785 2.4885 4.0447zM184.83 211.3822c.011.8284.6912 1.491 1.5196 1.4803.8283-.0108 1.491-.691 1.4803-1.5194-.046-3.519-.6604-6.996-1.8367-10.3262-.276-.7812-1.1328-1.1908-1.914-.915-.781.276-1.1906 1.133-.9147 1.914 1.0668 3.0206 1.624 6.1733 1.6655 9.3664zM179.3467 229.4095c-.459.6896-.2723 1.6208.4173 2.08.6896.459 1.6208.272 2.08-.4175 1.966-2.9533 3.4756-6.124 4.4877-9.4165.2434-.7918-.2012-1.631-.993-1.8745-.792-.2434-1.6312.2012-1.8746.993-.9264 3.014-2.3108 5.922-4.1173 8.6355z"/></g><g transform="translate(336.866969, 147.225953) rotate(-300.000000) translate(-336.866969, -147.225953) translate(299.366969, 69.725953)"><path stroke="#FDE5D8" stroke-width="3" d="M19 154l10-52.6603m16 0L55 154" stroke-linecap="round"/><rect width="3" height="38.75" x="35" y="99.3526" fill="#FDE5D8" rx="1.5"/><use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#h)" xlink:href="#a"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#i)" xlink:href="#b"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#j)" xlink:href="#c"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#k)" transform="translate(9.124810, 78.967887) scale(-1, 1) translate(-9.124810, -78.967887)" xlink:href="#d"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#l)" xlink:href="#e"/><ellipse cx="28.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="34.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="40.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="46.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="37.5" cy="55.1378" stroke="#FDE5D8" stroke-width="3" rx="10.5" ry="10.4327"/><ellipse cx="37.5" cy="55.1378" stroke="#FDE5D8" stroke-width="3" rx="5.5" ry="5.4647"/></g><path fill="#EEE" d="M96.0426 37.2106c-.1512 1.6874.0814 3.815.997 6.146.2046.5207.7936.7774 1.3155.5733.522-.2043.7793-.792.5747-1.313-.7912-2.0142-.99-3.832-.865-5.226.0102-.1143.0195-.186.0238-.2113.092-.552-.2814-1.0738-.8344-1.1658-.553-.092-1.076.2808-1.168.8326-.0126.075-.0285.1975-.0434.364zM107.5302 52.8934c.4913.239 1.098.0626 1.355-.394.2572-.4566.0674-1.0205-.4238-1.2595-1.8668-.9083-3.4584-1.9152-4.7943-3.0075-.4162-.3404-1.0506-.3026-1.4168.0843-.3663.387-.3256.9766.0907 1.317 1.4583 1.1925 3.1828 2.2835 5.1893 3.2596zM120.661 58.9533c.5467.171 1.1257-.1425 1.2933-.7003.1675-.5577-.1397-1.1484-.6864-1.3194-3.0283-.9472-4.1984-1.3178-5.915-1.8824-.544-.179-1.1274.126-1.3028.6813-.1754.5552.1235 1.1504.6677 1.3294 1.729.5686 2.9053.941 5.943 1.8913zM132.5954 62.881c.449.246 1.022.0983 1.2798-.33.258-.4282.103-.975-.3458-1.221-1.4942-.819-3.1928-1.545-5.2675-2.2746-.486-.1708-1.025.0664-1.204.53-.179.4634.0697.9776.5555 1.1484 1.9832.6973 3.5892 1.3838 4.982 2.1472zM141.9774 73.383c.205.4938.809.742 1.3485.5543.5395-.1878.8106-.7404.6055-1.2344-.8504-2.0482-1.853-3.7962-3.0375-5.3046-.337-.429-.99-.527-1.4588-.2184-.4687.3085-.5755.9064-.2386 1.3354 1.0743 1.368 1.9926 2.9692 2.7808 4.8675zM144.609 87.025c.0183.5535.5682.99 1.2283.9746.66-.0153 1.1805-.4764 1.1622-1.03-.0725-2.2033-.2693-4.206-.622-6.1198-.1008-.5473-.7115-.9225-1.3642-.838-.6526.0846-1.1.597-.999 1.1442.336 1.8248.5248 3.745.5947 5.869z"/><path fill="#E5E5E5" d="M144.1423 95.7297c-.0863 2.5442-.1214 3.769-.1422 5.2548-.0076.5523.3963 1.007.9022 1.0154.506.0083.9223-.4326.93-.985.0205-1.4668.0554-2.6812.1412-5.2113l.026-.7667c.0185-.552-.3764-1.016-.882-1.0363-.5056-.0203-.9306.411-.949.963l-.026.766zM144.939 115.201c.1196.5447.6727.8925 1.2355.7768.5628-.1157.922-.651.8026-1.1957-.417-1.9-.7104-3.84-.8976-5.8637-.0513-.5545-.5574-.964-1.1305-.9142-.573.0497-.996.5396-.9448 1.0942.1944 2.1015.4998 4.121.9348 6.103zM149.995 127.5248c.296.454.9528.61 1.4668.3485.514-.2614.6907-.8413.3947-1.2952-1.0787-1.6535-2.0046-3.3145-2.7896-4.9916-.2266-.484-.8547-.7143-1.403-.5142-.548.2-.809.7546-.5823 1.2387.8208 1.7534 1.788 3.4886 2.9134 5.2138zM154.8088 135.226c1.0587 1.232 2.242 2.4097 3.543 3.531.404.3482 1.0276.3186 1.393-.066.3657-.3843.3346-.978-.0692-1.3262-1.2296-1.0597-2.345-2.17-3.3402-3.328-.195-.227-.3872-.4542-.5764-.6813-.3385-.4063-.9588-.4744-1.3856-.1522-.4267.3223-.4983.913-.1598 1.3192.1954.2346.3938.469.5952.7034zM170.634 146.9026c.4806.242 1.0517.0176 1.2758-.501.224-.5188.0162-1.1354-.4642-1.3773-1.7563-.8842-3.422-1.8432-4.9857-2.8726-.4527-.298-1.0434-.1435-1.3195.3452-.276.4885-.133 1.126.3198 1.424 1.6256 1.0704 3.354 2.0655 5.1738 2.9816z"/><path fill="#EEE" d="M184.7334 151.9698c.5527.1412 1.1072-.2262 1.2385-.8206.1312-.5944-.2104-1.1908-.763-1.332-2.001-.5114-3.9602-1.1002-5.8632-1.763-.5405-.1883-1.1205.1303-1.2955.7115-.175.5813.1212 1.205.6616 1.3934 1.9557.6813 3.9676 1.286 6.0214 1.8108zM197.9337 153.9977c.5532.04 1.0297-.445 1.0643-1.083.0346-.6383-.3857-1.188-.939-1.228-1.973-.1424-3.952-.3682-5.9206-.676-.5492-.086-1.0547.358-1.1292.9917-.0744.6336.3105 1.2168.8597 1.3027 2.0164.3154 4.0433.5467 6.0647.6927zM212.1213 152.6062c.5493-.055.9392-.4576.871-.8994-.0684-.442-.569-.7555-1.1184-.7006-1.9168.1917-3.893.3194-5.9104.382-.553.0173-.9842.392-.9628.8368.0213.445.487.7916 1.0402.7744 2.0737-.0645 4.1064-.1957 6.0803-.3932zM226.3665 149.949c.5293-.22.7755-.8162.5497-1.332-.2257-.5155-.838-.7553-1.3672-.5354-1.7815.74-3.7143 1.3827-5.7772 1.923-.5558.1454-.8852.7023-.7358 1.2436.1494.5414.721.8623 1.2768.7168 2.1547-.5643 4.1797-1.2376 6.0537-2.016zM237.8486 140.4168c.292-.4344.1488-1.006-.3202-1.2766-.469-.2706-1.086-.1378-1.3782.2967-.9575 1.4237-2.225 2.7337-3.7847 3.9202-.427.3248-.4888.9087-.138 1.3042.3505.3955.981.4528 1.408.128 1.723-1.3107 3.1363-2.7714 4.213-4.3726zM245.6725 130.6874c.3987-.3503.439-.9587.09-1.3588-.3492-.4-.9554-.4405-1.3542-.0902-1.5048 1.3222-2.8978 2.7094-4.1698 4.1635-.3497.3995-.3102 1.008.088 1.3587.3983.3508 1.0046.3113 1.3542-.0884 1.2153-1.389 2.5487-2.717 3.9918-3.985zM257.4814 122.8697c.476-.2568.657-.8577.4047-1.342-.2523-.4843-.8428-.6687-1.3188-.4118-1.7682.9542-3.4795 1.973-5.1228 3.0587-.4518.2985-.5803.9133-.287 1.373.2934.46.8975.5906 1.3494.292 1.5938-1.0528 3.2557-2.0423 4.9746-2.97zM270.276 116.9216c.5503-.1682.8513-.724.6723-1.241-.179-.5173-.77-.8003-1.3204-.632-1.9296.5898-3.932 1.2728-5.975 2.054-.536.205-.7936.7797-.5754 1.2835.218.504.8294.746 1.3654.541 1.9947-.7628 3.95-1.4298 5.833-2.0054z"/><circle cx="145" cy="90" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><circle cx="238" cy="138" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><path stroke="#B5A7DD" stroke-width="3" d="M20.0605 56s-17.4698 33-12 53c5.4697 20 17 32 38 44S78.5 148 107 159s29 43 29 43" stroke-linecap="round" stroke-dasharray="8 10"/><g stroke="#EEE" stroke-width="3" transform="translate(108.000000, 173.000000)"><path fill="#FFF" d="M154 77c0-42.526-34.474-77-77-77S0 34.474 0 77" stroke-linecap="round"/><circle cx="108" cy="41" r="16"/><circle cx="42.5" cy="30.5" r="8.5"/><circle cx="22" cy="58" r="5"/></g><g><g fill="#FC8A51" transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(19.897959, -0.000000)"><path d="M.398 11.2982h2.3877c0-4.234 3.3853-7.6666 7.5612-7.6666v-2.421C4.8522 1.2105.398 5.727.398 11.298z"/><ellipse cx="10.7449" cy="2.0175" rx="1.9898" ry="2.0175"/></g><g fill="#FC8A51" transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(12.602041, 6.000000) scale(-1, 1) translate(-12.602041, -6.000000) translate(6.102041, -0.000000)"><path d="M.398 11.2982h2.3877c0-4.234 3.3853-7.6666 7.5612-7.6666v-2.421C4.8522 1.2105.398 5.727.398 11.298z"/><ellipse cx="10.7449" cy="2.0175" rx="1.9898" ry="2.0175"/></g><g transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(0.000000, 10.491228)"><g fill="#FC8A51" transform="translate(29.448980, 11.298246)"><rect width="7.9592" height="2" x=".7959" y="8.8772" rx="1"/><rect width="7.9592" height="2" x=".7959" y="16.1404" transform="translate(4.775510, 17.140351) rotate(-345.000000) translate(-4.775510, -17.140351)" rx="1"/><rect width="7.9592" height="2" x=".9151" y="1.8072" transform="translate(4.894667, 2.807217) rotate(-15.000000) translate(-4.894667, -2.807217)" rx="1"/></g><g fill="#FC8A51" transform="translate(5.051020, 21.298246) scale(-1, 1) translate(-5.051020, -21.298246) translate(0.551020, 11.298246)"><rect width="7.9592" height="2" x=".7959" y="8.8772" rx="1"/><rect width="7.9592" height="2" x=".7959" y="16.1404" transform="translate(4.775510, 17.140351) rotate(-345.000000) translate(-4.775510, -17.140351)" rx="1"/><rect width="7.9592" height="2" x=".9151" y="1.8072" transform="translate(4.894667, 2.807217) rotate(-15.000000) translate(-4.894667, -2.807217)" rx="1"/></g><use stroke="#FC8A51" stroke-width="6" mask="url(#m)" xlink:href="#f"/><path fill="#FC8A51" d="M7.1633 12.9123H31.041v3H7.1632z"/></g></g><g><g fill="#EEE" transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(12.755102, 0.000000)"><path d="M.255 7.1228h1.5307c0-2.6694 2.17-4.8333 4.847-4.8333V.7632C3.1104.7632.255 3.6105.255 7.1228z"/><ellipse cx="6.8878" cy="1.2719" rx="1.2755" ry="1.2719"/></g><g fill="#EEE" transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(7.744898, 4.000000) scale(-1, 1) translate(-7.744898, -4.000000) translate(3.244898, 0.000000)"><path d="M.255 7.1228h1.5307c0-2.6694 2.17-4.8333 4.847-4.8333V.7632C3.1104.7632.255 3.6105.255 7.1228z"/><ellipse cx="6.8878" cy="1.2719" rx="1.2755" ry="1.2719"/></g><g transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(0.000000, 6.614035)"><g fill="#EEE" transform="translate(18.877551, 7.122807)"><rect width="5.102" height="2" x=".5102" y="5.5965" rx="1"/><rect width="5.102" height="2" x=".5102" y="10.1754" transform="translate(3.061224, 11.175439) rotate(-345.000000) translate(-3.061224, -11.175439)" rx="1"/><rect width="5.102" height="2" x=".5866" y="1.1393" transform="translate(3.137607, 2.139333) rotate(-15.000000) translate(-3.137607, -2.139333)" rx="1"/></g><g fill="#EEE" transform="translate(3.122449, 13.622807) scale(-1, 1) translate(-3.122449, -13.622807) translate(0.122449, 7.122807)"><rect width="5.102" height="2" x=".5102" y="5.5965" rx="1"/><rect width="5.102" height="2" x=".5102" y="10.1754" transform="translate(3.061224, 11.175439) rotate(-345.000000) translate(-3.061224, -11.175439)" rx="1"/><rect width="5.102" height="2" x=".5866" y="1.1393" transform="translate(3.137607, 2.139333) rotate(-15.000000) translate(-3.137607, -2.139333)" rx="1"/></g><use stroke="#EEE" stroke-width="4" mask="url(#n)" xlink:href="#g"/><path fill="#EEE" d="M4.5918 8.1404h15.306v2H4.592z"/></g></g><g fill="#FFF" transform="translate(0.000000, 103.000000)"><circle cx="8.5" cy="8.5" r="8.5" stroke="#B5A7DD" stroke-width="4"/><circle cx="171.5" cy="20.5" r="6.5"/></g><g><g transform="translate(39.000000, 142.000000)"><ellipse cx="12.5" cy="12.5" fill="#FFF" stroke="#6B4FBB" stroke-width="4" rx="12.5" ry="12.5"/><path fill="#FC8A51" d="M10.7322 13.475l-1.7665-1.7667c-.5873-.5873-1.5368-.587-2.1226-.0012-.5897.59-.585 1.5362.0013 2.1226l2.826 2.826.0007.0007.0006.0006c.5898.5897 1.534.587 2.118.003l6.3704-6.3703c.577-.577.5826-1.5323-.003-2.118-.59-.59-1.5343-.5873-2.1183-.0033l-5.3065 5.3065z"/></g></g><circle cx="171.5" cy="122.5" r="6.5" fill="#FFF" stroke="#FC8A51" stroke-width="3"/><circle cx="22" cy="52" r="6" fill="#FFF" stroke="#B5A7DD" stroke-width="3"/><path fill="#FFF" stroke="#B5A7DD" stroke-width="3.6" d="M188.151 141.596c8.7045-7.7456 11.0126-20.9255 4.8625-31.5777-7.0208-12.1604-22.4055-16.422-34.363-9.5183-11.9572 6.9036-15.959 22.358-8.9382 34.5183 6.2353 10.8 19.068 15.3695 30.2375 11.4206l10.8992 18.8778c1.3167 2.2807 4.2302 3.063 6.5078 1.748 2.273-1.3122 3.0567-4.2295 1.74-6.51l-10.9458-18.9587zm-8.4343-4.6086c7.8576-4.5366 10.4874-14.6923 5.8738-22.6834-4.6137-7.991-14.7237-10.7915-22.5814-6.255-7.8575 4.5368-10.4873 14.6925-5.8737 22.6836 4.6137 7.991 14.7237 10.7915 22.5814 6.2548z"/></g></svg>
diff --git a/app/views/shared/icons/_delta.svg b/app/views/shared/icons/_delta.svg
new file mode 100644
index 00000000000..7c0c0d3999c
--- /dev/null
+++ b/app/views/shared/icons/_delta.svg
@@ -0,0 +1,3 @@
+<svg width="14px" height="10px" viewBox="322 21 14 10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <path d="M330.078605,22.8166945 L335.259532,29.6235062 C335.615145,30.0907182 335.412062,30.4694683 334.822641,30.4694683 L331.657805,30.4694683 L324.04678,30.4694683 C323.449879,30.4694683 323.260751,30.0822112 323.609889,29.6235062 L328.790816,22.8166945 C329.146429,22.3494825 329.729467,22.3579895 330.078605,22.8166945 Z" id="delta" stroke="#5C5C5C" stroke-width="1" fill="none"></path>
+</svg>
diff --git a/app/views/shared/icons/_icon_cycle_analytics_overview.svg b/app/views/shared/icons/_icon_cycle_analytics_overview.svg
new file mode 100644
index 00000000000..eea9c975c35
--- /dev/null
+++ b/app/views/shared/icons/_icon_cycle_analytics_overview.svg
@@ -0,0 +1,81 @@
+<svg width="366px" height="229px" viewBox="784 258 366 229" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 41 (35326) - http://www.bohemiancoding.com/sketch -->
+ <desc>Created with Sketch.</desc>
+ <defs>
+ <rect id="path-1" x="35" y="39" width="24" height="21" rx="10"></rect>
+ <mask id="mask-2" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="24" height="21" fill="white">
+ <use xlink:href="#path-1"></use>
+ </mask>
+ <rect id="path-3" x="64.8662386" y="58.3882666" width="10" height="71" rx="5"></rect>
+ <mask id="mask-4" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="10" height="71" fill="white">
+ <use xlink:href="#path-3"></use>
+ </mask>
+ <rect id="path-5" x="18.1550472" y="58.3882666" width="10" height="71" rx="5"></rect>
+ <mask id="mask-6" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="10" height="71" fill="white">
+ <use xlink:href="#path-5"></use>
+ </mask>
+ <rect id="path-7" x="24" y="56" width="46" height="10" rx="5"></rect>
+ <mask id="mask-8" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="46" height="10" fill="white">
+ <use xlink:href="#path-7"></use>
+ </mask>
+ <rect id="path-9" x="42" y="60" width="10" height="68" rx="5"></rect>
+ <mask id="mask-10" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="10" height="68" fill="white">
+ <use xlink:href="#path-9"></use>
+ </mask>
+ <rect id="path-11" x="69" y="12" width="12" height="12" rx="3"></rect>
+ <mask id="mask-12" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="12" height="12" fill="white">
+ <use xlink:href="#path-11"></use>
+ </mask>
+ <rect id="path-13" x="40" y="18" width="14" height="22" rx="6"></rect>
+ <mask id="mask-14" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="14" height="22" fill="white">
+ <use xlink:href="#path-13"></use>
+ </mask>
+ <rect id="path-15" x="41" y="8" width="34" height="20" rx="3"></rect>
+ <mask id="mask-16" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="34" height="20" fill="white">
+ <use xlink:href="#path-15"></use>
+ </mask>
+ <path d="M8,8.00793008 C8,6.34669617 9.34984627,5.0321392 11.0036812,5.07151622 L46.9963188,5.92848378 C48.6552061,5.9679811 50,7.34177063 50,8.99109042 L50,27.0089096 C50,28.6608432 48.6501537,30.0321392 46.9963188,30.0715162 L11.0036812,30.9284838 C9.34479389,30.9679811 8,29.6568766 8,27.9920699 L8,8.00793008 Z" id="path-17"></path>
+ <mask id="mask-18" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="42" height="25.858699" fill="white">
+ <use xlink:href="#path-17"></use>
+ </mask>
+ <rect id="path-19" x="-7.10542736e-15" y="1.77635684e-14" width="16" height="36" rx="3"></rect>
+ <mask id="mask-20" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="16" height="36" fill="white">
+ <use xlink:href="#path-19"></use>
+ </mask>
+ </defs>
+ <g id="Group-7" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(786.000000, 259.000000)">
+ <g id="Group-5" transform="translate(132.727922, 71.000000)">
+ <use id="Rectangle-21" stroke="#EEEEEE" mask="url(#mask-2)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-1"></use>
+ <use id="Rectangle-16-Copy" stroke="#EEEEEE" mask="url(#mask-4)" stroke-width="8" fill="#FFFFFF" transform="translate(69.866239, 93.888267) rotate(-20.000000) translate(-69.866239, -93.888267) " xlink:href="#path-3"></use>
+ <use id="Rectangle-16-Copy-2" stroke="#EEEEEE" mask="url(#mask-6)" stroke-width="8" fill="#FFFFFF" transform="translate(23.155047, 93.888267) scale(-1, 1) rotate(-20.000000) translate(-23.155047, -93.888267) " xlink:href="#path-5"></use>
+ <use id="Rectangle-15" stroke="#EEEEEE" mask="url(#mask-8)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-7"></use>
+ <use id="Rectangle-16" stroke="#EEEEEE" mask="url(#mask-10)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-9"></use>
+ <g id="Group" transform="translate(45.500000, 33.000000) rotate(20.000000) translate(-45.500000, -33.000000) translate(5.000000, 13.000000)">
+ <use id="Rectangle-4" stroke="#EEEEEE" mask="url(#mask-12)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-11"></use>
+ <use id="Rectangle-20" stroke="#EEEEEE" mask="url(#mask-14)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-13"></use>
+ <use id="Rectangle-2" stroke="#EEEEEE" mask="url(#mask-16)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-15"></use>
+ <use id="Rectangle" stroke="#EEEEEE" mask="url(#mask-18)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-17"></use>
+ <rect id="Rectangle-17" fill="#EEEEEE" x="21" y="7" width="3" height="22"></rect>
+ <rect id="Rectangle-17-Copy" fill="#EEEEEE" x="64" y="8" width="3" height="17"></rect>
+ <circle id="Oval-9" fill="#B5A7DD" cx="40" cy="18" r="2"></circle>
+ <circle id="Oval-9-Copy-4" fill="#EEEEEE" cx="47" cy="33" r="2"></circle>
+ <use id="Rectangle-19" stroke="#EEEEEE" mask="url(#mask-20)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-19"></use>
+ </g>
+ </g>
+ <path d="M265.128496,225.286991 C247.289192,194.617726 214.068171,174 176.031622,174 C137.847583,174 104.51649,194.77793 86.7279221,225.644211" id="Oval-10" stroke="#EEEEEE" stroke-width="4" stroke-linecap="round" fill="#FFFFFF"></path>
+ <circle id="Oval-11" stroke="#FDE5D8" stroke-width="4" fill="#FFFFFF" cx="24.5" cy="25.5" r="24.5"></circle>
+ <path d="M24,1.00292933 C24,0.449026756 24.4438648,0 25,0 C25.5522847,0 26,0.437881351 26,1.00292933 L26,5.99707067 C26,6.55097324 25.5561352,7 25,7 C24.4477153,7 24,6.56211865 24,5.99707067 L24,1.00292933 Z M48.46461,17.3244238 C48.9914026,17.1532585 49.5556142,17.4366422 49.7274694,17.9655581 C49.8981348,18.4908122 49.6200365,19.0519274 49.0826439,19.2265369 L44.3329333,20.7698114 C43.8061406,20.9409767 43.241929,20.6575931 43.0700738,20.1286771 C42.8994084,19.6034231 43.1775067,19.0423078 43.7148993,18.8676984 L48.46461,17.3244238 Z M40.5019265,45.6352697 C40.8275022,46.0833863 40.7323394,46.7075538 40.2824166,47.0344419 C39.8356088,47.3590667 39.2160194,47.2679737 38.8838925,46.8108402 L35.9484099,42.770495 C35.6228341,42.3223784 35.717997,41.6982109 36.1679198,41.3713229 C36.6147275,41.0466981 37.234317,41.1377911 37.5664439,41.5949245 L40.5019265,45.6352697 Z M11.1161075,46.8108402 C10.7905317,47.2589568 10.1675063,47.3613299 9.71758344,47.0344419 C9.27077569,46.709817 9.16594665,46.0924031 9.49807352,45.6352697 L12.4335561,41.5949245 C12.7591319,41.1468079 13.3821574,41.0444348 13.8320802,41.3713229 C14.278888,41.6959477 14.383717,42.3133616 14.0515901,42.770495 L11.1161075,46.8108402 Z M0.917356057,19.2265369 C0.390563404,19.0553716 0.100675355,18.4944741 0.272530576,17.9655581 C0.44319595,17.4403041 0.997997482,17.1498144 1.53539005,17.3244238 L6.28510071,18.8676984 C6.81189336,19.0388637 7.10178141,19.5997611 6.92992619,20.1286771 C6.75926082,20.6539311 6.20445928,20.9444208 5.66706672,20.7698114 L0.917356057,19.2265369 Z" id="Rectangle-23" fill="#FDE5D8"></path>
+ <rect id="Rectangle-18" fill="#FC6D26" x="24" y="14" width="3" height="12" rx="1.5"></rect>
+ <rect id="Rectangle-22" fill="#FC6D26" x="24" y="24" width="12" height="3" rx="1.5"></rect>
+ <circle id="Oval-11" fill="#6B4FBB" cx="25.5" cy="25.5" r="2.5"></circle>
+ <path d="M358.949747,6.87474747 L357.453009,7.20729654 C356.9128,7.32732164 356.570654,6.9935311 356.692198,6.44648557 L357.024747,4.94974747 L356.692198,3.45300937 C356.572173,2.91279997 356.905964,2.57065443 357.453009,2.69219839 L358.949747,3.02474747 L360.446486,2.69219839 C360.986695,2.5721733 361.328841,2.90596384 361.207297,3.45300937 L360.874747,4.94974747 L361.207297,6.44648557 C361.327322,6.98669496 360.993531,7.32884051 360.446486,7.20729654 L358.949747,6.87474747 Z" id="Star-Copy-5" fill="#6B4FBB" transform="translate(358.949747, 4.949747) rotate(-315.000000) translate(-358.949747, -4.949747) "></path>
+ <path d="M113.949747,32.8747475 L112.453009,33.2072965 C111.9128,33.3273216 111.570654,32.9935311 111.692198,32.4464856 L112.024747,30.9497475 L111.692198,29.4530094 C111.572173,28.9128 111.905964,28.5706544 112.453009,28.6921984 L113.949747,29.0247475 L115.446486,28.6921984 C115.986695,28.5721733 116.328841,28.9059638 116.207297,29.4530094 L115.874747,30.9497475 L116.207297,32.4464856 C116.327322,32.986695 115.993531,33.3288405 115.446486,33.2072965 L113.949747,32.8747475 Z" id="Star-Copy-7" fill="#B5A7DD" transform="translate(113.949747, 30.949747) rotate(-315.000000) translate(-113.949747, -30.949747) "></path>
+ <path d="M329.949747,211.874747 L328.453009,212.207297 C327.9128,212.327322 327.570654,211.993531 327.692198,211.446486 L328.024747,209.949747 L327.692198,208.453009 C327.572173,207.9128 327.905964,207.570654 328.453009,207.692198 L329.949747,208.024747 L331.446486,207.692198 C331.986695,207.572173 332.328841,207.905964 332.207297,208.453009 L331.874747,209.949747 L332.207297,211.446486 C332.327322,211.986695 331.993531,212.328841 331.446486,212.207297 L329.949747,211.874747 Z" id="Star-Copy-6" fill="#B5A7DD" opacity="0.5" transform="translate(329.949747, 209.949747) rotate(-315.000000) translate(-329.949747, -209.949747) "></path>
+ <path d="M265.363961,54.838961 L263.153969,55.3299826 C262.617155,55.4492534 262.280283,55.1035008 262.397939,54.5739526 L262.888961,52.363961 L262.397939,50.1539694 C262.278669,49.6171548 262.624421,49.2802831 263.153969,49.3979395 L265.363961,49.888961 L267.573953,49.3979395 C268.110767,49.2786686 268.447639,49.6244213 268.329983,50.1539694 L267.838961,52.363961 L268.329983,54.5739526 C268.449253,55.1107673 268.103501,55.4476389 267.573953,55.3299826 L265.363961,54.838961 Z" id="Star-Copy-9" fill="#FC6D26" transform="translate(265.363961, 52.363961) rotate(-315.000000) translate(-265.363961, -52.363961) "></path>
+ <path d="M56.363961,142.838961 L54.1539694,143.329983 C53.6171548,143.449253 53.2802831,143.103501 53.3979395,142.573953 L53.888961,140.363961 L53.3979395,138.153969 C53.2786686,137.617155 53.6244213,137.280283 54.1539694,137.397939 L56.363961,137.888961 L58.5739526,137.397939 C59.1107673,137.278669 59.4476389,137.624421 59.3299826,138.153969 L58.838961,140.363961 L59.3299826,142.573953 C59.4492534,143.110767 59.1035008,143.447639 58.5739526,143.329983 L56.363961,142.838961 Z" id="Star-Copy-8" fill="#6B4FBB" transform="translate(56.363961, 140.363961) rotate(-315.000000) translate(-56.363961, -140.363961) "></path>
+ <g id="Group-6" transform="translate(311.872633, 125.094458) rotate(-345.000000) translate(-311.872633, -125.094458) translate(290.872633, 115.094458)">
+ <circle id="Oval-12" stroke="#FDE5D8" stroke-width="4" fill="#FFFFFF" cx="21" cy="10" r="10"></circle>
+ <ellipse id="Oval-13" fill="#FDE5D8" cx="21" cy="10" rx="21" ry="2"></ellipse>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_lock.svg b/app/views/shared/icons/_icon_lock.svg
new file mode 100644
index 00000000000..6ec671a76ed
--- /dev/null
+++ b/app/views/shared/icons/_icon_lock.svg
@@ -0,0 +1,25 @@
+<svg width="46px" height="54px" viewBox="227 0 46 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 41 (35326) - http://www.bohemiancoding.com/sketch -->
+ <desc>Created with Sketch.</desc>
+ <defs>
+ <rect id="path-1" x="0" y="20" width="46" height="34" rx="8"></rect>
+ <mask id="mask-2" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="46" height="34" fill="white">
+ <use xlink:href="#path-1"></use>
+ </mask>
+ <path d="M29,16 C29,8.2680135 22.7319865,2 15,2 C7.2680135,2 1,8.2680135 1,16" id="path-3"></path>
+ <mask id="mask-4" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="28" height="14" fill="white">
+ <use xlink:href="#path-3"></use>
+ </mask>
+ </defs>
+ <g id="locker" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(227.000000, 0.000000)">
+ <g id="Group-8">
+ <use id="Rectangle-14" stroke="#B5A7DD" mask="url(#mask-2)" stroke-width="6" xlink:href="#path-1"></use>
+ <g id="Group-7" transform="translate(8.000000, 0.000000)">
+ <use id="Oval-3" stroke="#B5A7DD" mask="url(#mask-4)" stroke-width="6" xlink:href="#path-3"></use>
+ <rect id="Rectangle-13" fill="#B5A7DD" x="1" y="16" width="3" height="6"></rect>
+ <rect id="Rectangle-13-Copy" fill="#B5A7DD" x="26" y="16" width="3" height="6"></rect>
+ </g>
+ <path d="M25,37.4648712 C26.1956027,36.7732524 27,35.4805647 27,34 C27,31.790861 25.209139,30 23,30 C20.790861,30 19,31.790861 19,34 C19,35.4805647 19.8043973,36.7732524 21,37.4648712 L21,41.0026083 C21,42.1041422 21.8954305,43 23,43 C24.1122704,43 25,42.1057373 25,41.0026083 L25,37.4648712 Z" id="Combined-Shape" fill="#6B4FBB"></path>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_no_data.svg b/app/views/shared/icons/_icon_no_data.svg
new file mode 100644
index 00000000000..ced8653b88c
--- /dev/null
+++ b/app/views/shared/icons/_icon_no_data.svg
@@ -0,0 +1,27 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="211 0 78 36" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <circle id="a" cx="5" cy="31" r="5"/>
+ <mask id="e" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#a"/>
+ </mask>
+ <circle id="b" cx="29" cy="14" r="5"/>
+ <mask id="f" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#b"/>
+ </mask>
+ <circle id="c" cx="53" cy="24" r="5"/>
+ <mask id="g" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#c"/>
+ </mask>
+ <circle id="d" cx="73" cy="5" r="5"/>
+ <mask id="h" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#d"/>
+ </mask>
+ </defs>
+ <g fill="none" fill-rule="evenodd" transform="translate(211)">
+ <path stroke="#B5A7DD" stroke-width="2" d="M5 31l24-17 26 10L73 5" stroke-linecap="round" stroke-dasharray="3 6"/>
+ <use fill="#FFF" stroke="#6B4FBB" stroke-width="6" mask="url(#e)" xlink:href="#a"/>
+ <use fill="#FFF" stroke="#6B4FBB" stroke-width="6" mask="url(#f)" xlink:href="#b"/>
+ <use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#g)" xlink:href="#c"/>
+ <use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#h)" xlink:href="#d"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg
index 3420af411f6..e0a2d4282f0 100644
--- a/app/views/shared/icons/_icon_status_skipped.svg
+++ b/app/views/shared/icons/_icon_status_skipped.svg
@@ -1 +1 @@
-<svg width="14" height="14" class="ci-status-icon-skipped" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><title>Group Copy 31</title><g fill="#5C5C5C" fill-rule="evenodd"><path d="M10 17.857c4.286 0 7.857-3.571 7.857-7.857S14.286 2.143 10 2.143 2.143 5.714 2.143 10 5.714 17.857 10 17.857M10 0c5.571 0 10 4.429 10 10s-4.429 10-10 10S0 15.571 0 10 4.429 0 10 0"/><path d="M10.986 11l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.644-2.644a1.505 1.505 0 0 0 0-2.126l-2.644-2.644a1 1 0 0 0-1.414 1.414L10.986 9H6.4a1 1 0 0 0 0 2h4.586z"/></g></svg>
+<svg width="14" height="14" class="ci-status-icon-skipped" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#5C5C5C" fill-rule="evenodd"><path d="M10 17.857c4.286 0 7.857-3.571 7.857-7.857S14.286 2.143 10 2.143 2.143 5.714 2.143 10 5.714 17.857 10 17.857M10 0c5.571 0 10 4.429 10 10s-4.429 10-10 10S0 15.571 0 10 4.429 0 10 0"/><path d="M10.986 11l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.644-2.644a1.505 1.505 0 0 0 0-2.126l-2.644-2.644a1 1 0 0 0-1.414 1.414L10.986 9H6.4a1 1 0 0 0 0 2h4.586z"/></g></svg>
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 2fe9e82194b..9b9ad510444 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -125,7 +125,7 @@
- else
.pull-right
- if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
- = link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.class.name.titleize} will be removed! Are you sure?" },
+ = link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" },
method: :delete, class: 'btn btn-danger btn-grouped'
= link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel'
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index 3bc57d3d2ac..bd66f39fa59 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -9,7 +9,7 @@
&nbsp
.dropdown-label-color-input
.dropdown-label-color-preview.js-dropdown-label-color-preview
- %input#new_label_color.default-dropdown-input{ type: "text" }
+ %input#new_label_color.default-dropdown-input{ type: "text", placeholder: "Assign custom color like #FF0000" }
.clearfix
%button.btn.btn-primary.pull-left.js-new-label-btn{ type: "button" }
Create
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 7363ead09ff..f166fac105d 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -140,7 +140,7 @@
= render "shared/issuable/participants", participants: issuable.participants(current_user)
- if current_user
- - subscribed = issuable.subscribed?(current_user)
+ - subscribed = issuable.subscribed?(current_user, @project)
.block.light.subscription{data: {url: toggle_subscription_path(issuable)}}
.sidebar-collapsed-icon
= icon('rss')
diff --git a/app/views/shared/milestones/_summary.html.haml b/app/views/shared/milestones/_summary.html.haml
index dee2472fa79..0a237136959 100644
--- a/app/views/shared/milestones/_summary.html.haml
+++ b/app/views/shared/milestones/_summary.html.haml
@@ -3,32 +3,38 @@
.context.prepend-top-default
.milestone-summary
%h4 Progress
- %strong= milestone.issues_visible_to_user(current_user).size
- issues:
- %span.milestone-stat
- %strong= milestone.issues_visible_to_user(current_user).opened.size
- open and
- %strong= milestone.issues_visible_to_user(current_user).closed.size
- closed
- %strong= milestone.merge_requests.size
- merge requests:
- %span.milestone-stat
- %strong= milestone.merge_requests.opened.size
- open and
- %strong= milestone.merge_requests.merged.size
- merged
- %span.milestone-stat
- %strong== #{milestone.percent_complete(current_user)}%
- complete
- %span.milestone-stat
- %span.remaining-days= milestone_remaining_days(milestone)
- %span.pull-right.tab-issues-buttons
- - if project && can?(current_user, :create_issue, project)
- = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "btn btn-grouped", title: "New Issue" do
- New Issue
- = link_to 'Browse Issues', milestones_browse_issuables_path(milestone, type: :issues), class: "btn btn-grouped"
- %span.pull-right.tab-merge-requests-buttons.hidden
- = link_to 'Browse Merge Requests', milestones_browse_issuables_path(milestone, type: :merge_requests), class: "btn btn-grouped"
+ .milestone-stats-and-buttons
+ .milestone-stats
+ %span.milestone-stat.with-drilldown
+ %strong= milestone.issues_visible_to_user(current_user).size
+ issues:
+ %span.milestone-stat
+ %strong= milestone.issues_visible_to_user(current_user).opened.size
+ open and
+ %strong= milestone.issues_visible_to_user(current_user).closed.size
+ closed
+ %span.milestone-stat.with-drilldown
+ %strong= milestone.merge_requests.size
+ merge requests:
+ %span.milestone-stat
+ %strong= milestone.merge_requests.opened.size
+ open and
+ %strong= milestone.merge_requests.merged.size
+ merged
+ %span.milestone-stat
+ %strong== #{milestone.percent_complete(current_user)}%
+ complete
+ %span.milestone-stat
+ %span.remaining-days= milestone_remaining_days(milestone)
- = milestone_progress_bar(milestone)
+ .milestone-progress-buttons
+ %span.tab-issues-buttons
+ - if project && can?(current_user, :create_issue, project)
+ = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "btn", title: "New Issue" do
+ New Issue
+ = link_to 'Browse Issues', milestones_browse_issuables_path(milestone, type: :issues), class: "btn"
+ %span.tab-merge-requests-buttons.hidden
+ = link_to 'Browse Merge Requests', milestones_browse_issuables_path(milestone, type: :merge_requests), class: "btn"
+
+ = milestone_progress_bar(milestone)
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
new file mode 100644
index 00000000000..331727ba9d8
--- /dev/null
+++ b/app/workers/authorized_projects_worker.rb
@@ -0,0 +1,15 @@
+class AuthorizedProjectsWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ def self.bulk_perform_async(args_list)
+ Sidekiq::Client.push_bulk('class' => self, 'args' => args_list)
+ end
+
+ def perform(user_id)
+ user = User.find_by(id: user_id)
+ return unless user
+
+ user.refresh_authorized_projects
+ end
+end
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index e0ad5268664..e17add7421f 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -4,15 +4,13 @@ class BuildSuccessWorker
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
- create_deployment(build)
+ create_deployment(build) if build.has_environment?
end
end
private
def create_deployment(build)
- return if build.environment.blank?
-
service = CreateDeploymentService.new(
build.project, build.user,
environment: build.environment,
diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb
index 34f6ef161fb..070943f1ecc 100644
--- a/app/workers/pipeline_metrics_worker.rb
+++ b/app/workers/pipeline_metrics_worker.rb
@@ -12,11 +12,11 @@ class PipelineMetricsWorker
private
def update_metrics_for_active_pipeline(pipeline)
- metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: nil)
+ metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: nil, pipeline_id: pipeline.id)
end
def update_metrics_for_succeeded_pipeline(pipeline)
- metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: pipeline.finished_at)
+ metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: pipeline.finished_at, pipeline_id: pipeline.id)
end
def metrics(pipeline)
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index 4dfa745fb50..27d7e652721 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -1,54 +1,38 @@
# Worker for updating any project specific caches.
-#
-# This worker runs at most once every 15 minutes per project. This is to ensure
-# that multiple instances of jobs for this worker don't hammer the underlying
-# storage engine as much.
class ProjectCacheWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
LEASE_TIMEOUT = 15.minutes.to_i
- def self.lease_for(project_id)
- Gitlab::ExclusiveLease.
- new("project_cache_worker:#{project_id}", timeout: LEASE_TIMEOUT)
- end
+ # project_id - The ID of the project for which to flush the cache.
+ # refresh - An Array containing extra types of data to refresh such as
+ # `:readme` to flush the README and `:changelog` to flush the
+ # CHANGELOG.
+ def perform(project_id, refresh = [])
+ project = Project.find_by(id: project_id)
- # Overwrite Sidekiq's implementation so we only schedule when actually needed.
- def self.perform_async(project_id)
- # If a lease for this project is still being held there's no point in
- # scheduling a new job.
- super unless lease_for(project_id).exists?
- end
+ return unless project && project.repository.exists?
- def perform(project_id)
- if try_obtain_lease_for(project_id)
- Rails.logger.
- info("Obtained ProjectCacheWorker lease for project #{project_id}")
- else
- Rails.logger.
- info("Could not obtain ProjectCacheWorker lease for project #{project_id}")
-
- return
- end
+ update_repository_size(project)
+ project.update_commit_count
- update_caches(project_id)
+ project.repository.refresh_method_caches(refresh.map(&:to_sym))
end
- def update_caches(project_id)
- project = Project.find(project_id)
+ def update_repository_size(project)
+ return unless try_obtain_lease_for(project.id, :update_repository_size)
- return unless project.repository.exists?
+ Rails.logger.info("Updating repository size for project #{project.id}")
project.update_repository_size
- project.update_commit_count
-
- if project.repository.root_ref
- project.repository.build_cache
- end
end
- def try_obtain_lease_for(project_id)
- self.class.lease_for(project_id).try_obtain
+ private
+
+ def try_obtain_lease_for(project_id, section)
+ Gitlab::ExclusiveLease.
+ new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT).
+ try_obtain
end
end
diff --git a/changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml b/changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml
new file mode 100644
index 00000000000..b8b8810ecfa
--- /dev/null
+++ b/changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml
@@ -0,0 +1,4 @@
+---
+title: Changed restricted visibility admin buttons to checkboxes
+merge_request: 7463
+author:
diff --git a/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml b/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml
new file mode 100644
index 00000000000..18fb8a6ad45
--- /dev/null
+++ b/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml
@@ -0,0 +1,4 @@
+---
+title: Make New Group form respect default visibility application setting
+merge_request: 7454
+author: Jacopo Beschi @jacopo-beschi
diff --git a/changelogs/unreleased/22539-display-folders.yml b/changelogs/unreleased/22539-display-folders.yml
new file mode 100644
index 00000000000..d46cdedf7a7
--- /dev/null
+++ b/changelogs/unreleased/22539-display-folders.yml
@@ -0,0 +1,4 @@
+---
+title: Display "folders" for environments
+merge_request: 7015
+author:
diff --git a/changelogs/unreleased/23223-group-deletion-race-condition.yml b/changelogs/unreleased/23223-group-deletion-race-condition.yml
new file mode 100644
index 00000000000..6f22e85fb4b
--- /dev/null
+++ b/changelogs/unreleased/23223-group-deletion-race-condition.yml
@@ -0,0 +1,4 @@
+---
+title: Fix race condition during group deletion and remove stale records present due to this bug
+merge_request: 7528
+author: Timothy Andrew
diff --git a/changelogs/unreleased/23449-cycle-analytics-2-frontend.yml b/changelogs/unreleased/23449-cycle-analytics-2-frontend.yml
new file mode 100644
index 00000000000..5140c09be8a
--- /dev/null
+++ b/changelogs/unreleased/23449-cycle-analytics-2-frontend.yml
@@ -0,0 +1,4 @@
+---
+title: Show events per stage on Cycle Analytics page
+merge_request: 23449
+author:
diff --git a/changelogs/unreleased/23637-title-bar-pipelines.yml b/changelogs/unreleased/23637-title-bar-pipelines.yml
new file mode 100644
index 00000000000..3d4cf88c54c
--- /dev/null
+++ b/changelogs/unreleased/23637-title-bar-pipelines.yml
@@ -0,0 +1,4 @@
+---
+title: Redesign pipelines page
+merge_request:
+author:
diff --git a/changelogs/unreleased/23990-project-show-error-when-empty-repo.yml b/changelogs/unreleased/23990-project-show-error-when-empty-repo.yml
new file mode 100644
index 00000000000..8d4593d4df7
--- /dev/null
+++ b/changelogs/unreleased/23990-project-show-error-when-empty-repo.yml
@@ -0,0 +1,4 @@
+---
+title: fixes 500 error on project show when user is not logged in and project is still empty
+merge_request: 7376
+author:
diff --git a/changelogs/unreleased/24070-project-margins.yml b/changelogs/unreleased/24070-project-margins.yml
new file mode 100644
index 00000000000..cbc2b0269f0
--- /dev/null
+++ b/changelogs/unreleased/24070-project-margins.yml
@@ -0,0 +1,4 @@
+---
+title: Fix Margins look weird in Project page with pinned sidebar in project stats bar
+merge_request: 7580
+author:
diff --git a/changelogs/unreleased/24072-improve-importing-of-github-pull-requests.yml b/changelogs/unreleased/24072-improve-importing-of-github-pull-requests.yml
new file mode 100644
index 00000000000..2c265960d67
--- /dev/null
+++ b/changelogs/unreleased/24072-improve-importing-of-github-pull-requests.yml
@@ -0,0 +1,4 @@
+---
+title: Reduce API calls needed when importing issues and pull requests from GitHub
+merge_request: 7241
+author: Andrew Smith (EspadaV8)
diff --git a/changelogs/unreleased/24276-usernames-with-dots.yml b/changelogs/unreleased/24276-usernames-with-dots.yml
new file mode 100644
index 00000000000..9aeeb33e9ef
--- /dev/null
+++ b/changelogs/unreleased/24276-usernames-with-dots.yml
@@ -0,0 +1,4 @@
+---
+title: Allow registering users whose username contains dots
+merge_request: 7500
+author: Timothy Andrew
diff --git a/changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml b/changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml
new file mode 100644
index 00000000000..53dcc2a82f1
--- /dev/null
+++ b/changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml
@@ -0,0 +1,4 @@
+---
+title: Fix activity page endless scroll on large viewports
+merge_request: 7608
+author:
diff --git a/changelogs/unreleased/24616-mr-shows-the-build-for-this-merge-request-failed-although-builds-still-running.yml b/changelogs/unreleased/24616-mr-shows-the-build-for-this-merge-request-failed-although-builds-still-running.yml
new file mode 100644
index 00000000000..3773459550f
--- /dev/null
+++ b/changelogs/unreleased/24616-mr-shows-the-build-for-this-merge-request-failed-although-builds-still-running.yml
@@ -0,0 +1,4 @@
+---
+title: Fix a wrong "The build for this merge request failed" message
+merge_request: 7579
+author:
diff --git a/changelogs/unreleased/24627-fix-bad-mr-error-message.yml b/changelogs/unreleased/24627-fix-bad-mr-error-message.yml
new file mode 100644
index 00000000000..d6a9818b2ce
--- /dev/null
+++ b/changelogs/unreleased/24627-fix-bad-mr-error-message.yml
@@ -0,0 +1,4 @@
+---
+title: Fix regression causing bad error message to appear on Merge Request form
+merge_request: 7599
+author: Alex Sanford
diff --git a/changelogs/unreleased/add-chat-names.yml b/changelogs/unreleased/add-chat-names.yml
new file mode 100644
index 00000000000..6a1e05783a3
--- /dev/null
+++ b/changelogs/unreleased/add-chat-names.yml
@@ -0,0 +1,4 @@
+---
+title: Allow to connect Chat account with GitLab
+merge_request: 7450
+author:
diff --git a/changelogs/unreleased/assignee-dropdown-autocomplete.yml b/changelogs/unreleased/assignee-dropdown-autocomplete.yml
new file mode 100644
index 00000000000..9d046b726b7
--- /dev/null
+++ b/changelogs/unreleased/assignee-dropdown-autocomplete.yml
@@ -0,0 +1,4 @@
+---
+title: Assignee dropdown now searches author of issue or merge request
+merge_request:
+author:
diff --git a/changelogs/unreleased/bugfix-html-only-mail.yml b/changelogs/unreleased/bugfix-html-only-mail.yml
new file mode 100644
index 00000000000..ea0d4e7396f
--- /dev/null
+++ b/changelogs/unreleased/bugfix-html-only-mail.yml
@@ -0,0 +1,4 @@
+---
+title: Add support for reply-by-email when the email only contains HTML
+merge_request: 7397
+author:
diff --git a/changelogs/unreleased/changelog-update.yml b/changelogs/unreleased/changelog-update.yml
new file mode 100644
index 00000000000..24fa34f4121
--- /dev/null
+++ b/changelogs/unreleased/changelog-update.yml
@@ -0,0 +1,4 @@
+---
+title: Add environment info to builds page
+merge_request:
+author:
diff --git a/changelogs/unreleased/chatops-deploy-command.yml b/changelogs/unreleased/chatops-deploy-command.yml
new file mode 100644
index 00000000000..1e5a3e8df15
--- /dev/null
+++ b/changelogs/unreleased/chatops-deploy-command.yml
@@ -0,0 +1,4 @@
+---
+title: Add deployment command to ChatOps
+merge_request: 7619
+author:
diff --git a/changelogs/unreleased/create-pipeline-endpoint.yml b/changelogs/unreleased/create-pipeline-endpoint.yml
new file mode 100644
index 00000000000..c7638b7b7aa
--- /dev/null
+++ b/changelogs/unreleased/create-pipeline-endpoint.yml
@@ -0,0 +1,4 @@
+---
+title: Add api endpoint for creating a pipeline
+merge_request: 7209
+author: Ido Leibovich
diff --git a/changelogs/unreleased/dz-fix-500-group-git.yml b/changelogs/unreleased/dz-fix-500-group-git.yml
new file mode 100644
index 00000000000..38e80ad8bde
--- /dev/null
+++ b/changelogs/unreleased/dz-fix-500-group-git.yml
@@ -0,0 +1,4 @@
+---
+title: Fix 500 error when group name ends with git
+merge_request: 7630
+author:
diff --git a/changelogs/unreleased/dz-fix-group-name-dot.yml b/changelogs/unreleased/dz-fix-group-name-dot.yml
new file mode 100644
index 00000000000..439cb229e6b
--- /dev/null
+++ b/changelogs/unreleased/dz-fix-group-name-dot.yml
@@ -0,0 +1,4 @@
+---
+title: Fix 404 on some group pages when name contains dot
+merge_request: 7614
+author:
diff --git a/changelogs/unreleased/feature-cycle-analytics-events.yml b/changelogs/unreleased/feature-cycle-analytics-events.yml
new file mode 100644
index 00000000000..e1211a3c774
--- /dev/null
+++ b/changelogs/unreleased/feature-cycle-analytics-events.yml
@@ -0,0 +1,4 @@
+---
+title: Add events per stage to cycle analytics
+merge_request:
+author:
diff --git a/changelogs/unreleased/feature-precalculate-authorized-projects.yml b/changelogs/unreleased/feature-precalculate-authorized-projects.yml
new file mode 100644
index 00000000000..6e48b710fec
--- /dev/null
+++ b/changelogs/unreleased/feature-precalculate-authorized-projects.yml
@@ -0,0 +1,4 @@
+---
+title: Precalculate user's authorized projects in database
+merge_request: 6839
+author:
diff --git a/changelogs/unreleased/feature-send-registry-address-with-build-payload.yml b/changelogs/unreleased/feature-send-registry-address-with-build-payload.yml
new file mode 100644
index 00000000000..db9bb2bc31f
--- /dev/null
+++ b/changelogs/unreleased/feature-send-registry-address-with-build-payload.yml
@@ -0,0 +1,4 @@
+---
+title: Send credentials (currently for registry only) with build data to GitLab Runner
+merge_request: 7474
+author:
diff --git a/changelogs/unreleased/feature-subscribe-to-group-level-labels.yml b/changelogs/unreleased/feature-subscribe-to-group-level-labels.yml
new file mode 100644
index 00000000000..ea336716dce
--- /dev/null
+++ b/changelogs/unreleased/feature-subscribe-to-group-level-labels.yml
@@ -0,0 +1,4 @@
+---
+title: Allow users to subscribe to group labels
+merge_request: 7215
+author:
diff --git a/changelogs/unreleased/fix-Build-timeFor.yml b/changelogs/unreleased/fix-Build-timeFor.yml
new file mode 100644
index 00000000000..ea115f7ee67
--- /dev/null
+++ b/changelogs/unreleased/fix-Build-timeFor.yml
@@ -0,0 +1,4 @@
+---
+title: Fix typo in Build page JavaScript
+merge_request: 7563
+author: winniehell
diff --git a/changelogs/unreleased/fix-admin-ci-table.yml b/changelogs/unreleased/fix-admin-ci-table.yml
new file mode 100644
index 00000000000..9a9e39ee94a
--- /dev/null
+++ b/changelogs/unreleased/fix-admin-ci-table.yml
@@ -0,0 +1,4 @@
+---
+title: Fix misaligned buttons on admin builds page
+merge_request: 7424
+author: Didem Acet
diff --git a/changelogs/unreleased/fix-cycle-analytics-permissions.yml b/changelogs/unreleased/fix-cycle-analytics-permissions.yml
new file mode 100644
index 00000000000..ddcf78d705f
--- /dev/null
+++ b/changelogs/unreleased/fix-cycle-analytics-permissions.yml
@@ -0,0 +1,4 @@
+---
+title: Added permissions per stage to cycle analytics endpoint
+merge_request:
+author:
diff --git a/changelogs/unreleased/fix-do-not-add-todo-when-build-allowed-to-fail.yml b/changelogs/unreleased/fix-do-not-add-todo-when-build-allowed-to-fail.yml
new file mode 100644
index 00000000000..6402d0c7ece
--- /dev/null
+++ b/changelogs/unreleased/fix-do-not-add-todo-when-build-allowed-to-fail.yml
@@ -0,0 +1,4 @@
+---
+title: Do not create a new TODO when failed build is allowed to fail
+merge_request: 7618
+author:
diff --git a/changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml b/changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml
deleted file mode 100644
index a6bee989f6d..00000000000
--- a/changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Do not create a MergeRequestDiff record when source branch is deleted
-merge_request: 7481
-author:
diff --git a/changelogs/unreleased/fix-require-build-script-configuration-entry.yml b/changelogs/unreleased/fix-require-build-script-configuration-entry.yml
new file mode 100644
index 00000000000..00b3fd2681f
--- /dev/null
+++ b/changelogs/unreleased/fix-require-build-script-configuration-entry.yml
@@ -0,0 +1,4 @@
+---
+title: Make job script a required configuration entry
+merge_request: 7566
+author:
diff --git a/changelogs/unreleased/fix-singin-redirect-for-fork-new.yml b/changelogs/unreleased/fix-singin-redirect-for-fork-new.yml
new file mode 100644
index 00000000000..e4cf8de8699
--- /dev/null
+++ b/changelogs/unreleased/fix-singin-redirect-for-fork-new.yml
@@ -0,0 +1,5 @@
+---
+title: Fixing the issue of the project fork url giving 500 when not signed instead
+ of being redirected to sign in page
+merge_request:
+author: Cagdas Gerede
diff --git a/changelogs/unreleased/fix-slack-pipeline-event.yml b/changelogs/unreleased/fix-slack-pipeline-event.yml
new file mode 100644
index 00000000000..fec864eeb3d
--- /dev/null
+++ b/changelogs/unreleased/fix-slack-pipeline-event.yml
@@ -0,0 +1,4 @@
+---
+title: Fix pipeline author for Slack and use pipeline id for pipeline link
+merge_request: 7506
+author:
diff --git a/changelogs/unreleased/hide-empty-merge-request-diffs.yml b/changelogs/unreleased/hide-empty-merge-request-diffs.yml
new file mode 100644
index 00000000000..e32a51b555a
--- /dev/null
+++ b/changelogs/unreleased/hide-empty-merge-request-diffs.yml
@@ -0,0 +1,4 @@
+---
+title: Fix errors happening when source branch of merge request is removed and then restored
+merge_request: 7568
+author:
diff --git a/changelogs/unreleased/issue-24512.yml b/changelogs/unreleased/issue-24512.yml
new file mode 100644
index 00000000000..a3a9bd9c3d1
--- /dev/null
+++ b/changelogs/unreleased/issue-24512.yml
@@ -0,0 +1,4 @@
+---
+title: Add placeholder for the example text for custom hex color on label creation popup
+merge_request:
+author: Luis Alonso Chavez Armendariz
diff --git a/changelogs/unreleased/issue_13232.yml b/changelogs/unreleased/issue_13232.yml
new file mode 100644
index 00000000000..6dc2de5afe4
--- /dev/null
+++ b/changelogs/unreleased/issue_13232.yml
@@ -0,0 +1,4 @@
+---
+title: Add JIRA remotelinks and prevent duplicated closing messages
+merge_request:
+author:
diff --git a/changelogs/unreleased/issue_24303.yml b/changelogs/unreleased/issue_24303.yml
new file mode 100644
index 00000000000..1f007712732
--- /dev/null
+++ b/changelogs/unreleased/issue_24303.yml
@@ -0,0 +1,4 @@
+---
+title: Fix JIRA references for project snippets
+merge_request:
+author:
diff --git a/changelogs/unreleased/issue_5541.yml b/changelogs/unreleased/issue_5541.yml
new file mode 100644
index 00000000000..cf553cf8d80
--- /dev/null
+++ b/changelogs/unreleased/issue_5541.yml
@@ -0,0 +1,4 @@
+---
+title: Allow enabling and disabling commit and MR events for JIRA
+merge_request:
+author:
diff --git a/changelogs/unreleased/jira_service_simplify.yml b/changelogs/unreleased/jira_service_simplify.yml
new file mode 100644
index 00000000000..51cedd8ce5e
--- /dev/null
+++ b/changelogs/unreleased/jira_service_simplify.yml
@@ -0,0 +1,4 @@
+---
+title: simplify url generation
+merge_request:
+author: Jarka Kadlecova
diff --git a/changelogs/unreleased/less-intrusive-system-note.yml b/changelogs/unreleased/less-intrusive-system-note.yml
new file mode 100644
index 00000000000..87a5a3be246
--- /dev/null
+++ b/changelogs/unreleased/less-intrusive-system-note.yml
@@ -0,0 +1,4 @@
+---
+title: Make system notes less intrusive
+merge_request: 6755
+author:
diff --git a/changelogs/unreleased/namespace-validation.yml b/changelogs/unreleased/namespace-validation.yml
new file mode 100644
index 00000000000..6ac461bf82e
--- /dev/null
+++ b/changelogs/unreleased/namespace-validation.yml
@@ -0,0 +1,4 @@
+---
+title: Check all namespaces on validation of new username.
+merge_request: 7537
+author:
diff --git a/changelogs/unreleased/optimize-mr-index.yml b/changelogs/unreleased/optimize-mr-index.yml
new file mode 100644
index 00000000000..1090b6d4528
--- /dev/null
+++ b/changelogs/unreleased/optimize-mr-index.yml
@@ -0,0 +1,4 @@
+---
+title: More aggressively preload on merge request and issue index pages
+merge_request:
+author:
diff --git a/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml b/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml
new file mode 100644
index 00000000000..5e868027ed6
--- /dev/null
+++ b/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml
@@ -0,0 +1,4 @@
+---
+title: Pass correct tag target to post-receive hook when creating tag via UI
+merge_request: 7556
+author:
diff --git a/changelogs/unreleased/rack_attack_logging.yml b/changelogs/unreleased/rack_attack_logging.yml
new file mode 100644
index 00000000000..c0d6c1fd12e
--- /dev/null
+++ b/changelogs/unreleased/rack_attack_logging.yml
@@ -0,0 +1,4 @@
+---
+title: Add logging for rack attack events to production.log
+merge_request:
+author:
diff --git a/changelogs/unreleased/remove-unnecessary-self-from-user-model.yml b/changelogs/unreleased/remove-unnecessary-self-from-user-model.yml
new file mode 100644
index 00000000000..bef11c63675
--- /dev/null
+++ b/changelogs/unreleased/remove-unnecessary-self-from-user-model.yml
@@ -0,0 +1,4 @@
+---
+title: Remove unnecessary self from user model
+merge_request: 7551
+author: Semyon Pupkov
diff --git a/changelogs/unreleased/smarter-cache-invalidation.yml b/changelogs/unreleased/smarter-cache-invalidation.yml
new file mode 100644
index 00000000000..14a93e26ac4
--- /dev/null
+++ b/changelogs/unreleased/smarter-cache-invalidation.yml
@@ -0,0 +1,4 @@
+---
+title: Rework cache invalidation so only changed data is refreshed
+merge_request: 7360
+author:
diff --git a/changelogs/unreleased/sort-api-groups.yml b/changelogs/unreleased/sort-api-groups.yml
new file mode 100644
index 00000000000..e3eead8c04f
--- /dev/null
+++ b/changelogs/unreleased/sort-api-groups.yml
@@ -0,0 +1,4 @@
+---
+title: Allow sorting groups in the API
+merge_request:
+author:
diff --git a/changelogs/unreleased/zj-slash-commands-mattermost.yml b/changelogs/unreleased/zj-slash-commands-mattermost.yml
new file mode 100644
index 00000000000..996ffe954f3
--- /dev/null
+++ b/changelogs/unreleased/zj-slash-commands-mattermost.yml
@@ -0,0 +1,4 @@
+---
+title: Added Mattermost slash command
+merge_request: 7438
+author:
diff --git a/config/application.rb b/config/application.rb
index 946b632b0e8..fb84870dfbd 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -94,6 +94,7 @@ module Gitlab
config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js"
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
config.assets.precompile << "boards/test_utils/simulate_drag.js"
+ config.assets.precompile << "environments/environments_bundle.js"
config.assets.precompile << "blob_edit/blob_edit_bundle.js"
config.assets.precompile << "snippet/snippet_bundle.js"
config.assets.precompile << "lib/utils/*.js"
diff --git a/config/initializers/rack_attack_logging.rb b/config/initializers/rack_attack_logging.rb
new file mode 100644
index 00000000000..8bb9ea29c33
--- /dev/null
+++ b/config/initializers/rack_attack_logging.rb
@@ -0,0 +1,7 @@
+# Adds logging for all Rack Attack blocks and throttling events.
+
+ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req|
+ if [:throttle, :blacklist].include? req.env['rack.attack.match_type']
+ Rails.logger.info("Rack_Attack: #{req.env['rack.attack.match_type']} #{req.ip} #{req.request_method} #{req.fullpath}")
+ end
+end
diff --git a/config/no_todos_messages.yml b/config/no_todos_messages.yml
index 8372fb4ebe9..264a975b614 100644
--- a/config/no_todos_messages.yml
+++ b/config/no_todos_messages.yml
@@ -1,13 +1,11 @@
-# When the Todos list on the user's dashboard becomes empty, one of the messages below shows up randomly.
+# When the todo list on the user's dashboard becomes empty, a random message
+# from the list below will be shown.
#
# If you come up with a fun one, please feel free to contribute it to GitLab!
# https://about.gitlab.com/contributing/
-
---
- Good job! Looks like you don't have any todos left.
-- Coffee really tastes better without any todos left.
-- Isn't an empty To Do list beautiful?
-- Time for a rewarding coffee break
+- Isn't an empty todo list beautiful?
- Give yourself a pat on the back!
-- High five!
-- Hence forth you shall be known as 'Todo Destroyer' \ No newline at end of file
+- Nothing left to do, high five!
+- Henceforth you shall be known as "Todo Destroyer".
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 3c392f77ef6..a3a001178b4 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -14,7 +14,9 @@ end
resources :groups, only: [:index, :new, :create]
-scope(path: 'groups/:id', controller: :groups) do
+scope(path: 'groups/:id',
+ controller: :groups,
+ constraints: { id: Gitlab::Regex.namespace_route_regex }) do
get :edit, as: :edit_group
get :issues, as: :issues_group
get :merge_requests, as: :merge_requests_group
@@ -22,7 +24,10 @@ scope(path: 'groups/:id', controller: :groups) do
get :activity, as: :activity_group
end
-scope(path: 'groups/:group_id', module: :groups, as: :group) do
+scope(path: 'groups/:group_id',
+ module: :groups,
+ as: :group,
+ constraints: { group_id: Gitlab::Regex.namespace_route_regex }) do
resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do
post :resend_invite, on: :member
delete :leave, on: :collection
@@ -30,8 +35,11 @@ scope(path: 'groups/:group_id', module: :groups, as: :group) do
resource :avatar, only: [:destroy]
resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create]
- resources :labels, except: [:show], constraints: { id: /\d+/ }
+
+ resources :labels, except: [:show], constraints: { id: /\d+/ } do
+ post :toggle_subscription, on: :member
+ end
end
# Must be last route in this file
-get 'groups/:id' => 'groups#show', as: :group_canonical
+get 'groups/:id' => 'groups#show', as: :group_canonical, constraints: { id: Gitlab::Regex.namespace_route_regex }
diff --git a/config/routes/profile.rb b/config/routes/profile.rb
index 52b9a565db8..6b91485da9e 100644
--- a/config/routes/profile.rb
+++ b/config/routes/profile.rb
@@ -23,6 +23,12 @@ resource :profile, only: [:show, :update] do
resource :preferences, only: [:show, :update]
resources :keys, only: [:index, :show, :new, :create, :destroy]
resources :emails, only: [:index, :create, :destroy]
+ resources :chat_names, only: [:index, :new, :create, :destroy] do
+ collection do
+ delete :deny
+ end
+ end
+
resource :avatar, only: [:destroy]
resources :personal_access_tokens, only: [:index, :create] do
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 9cf8465dca8..d6eae1c9fce 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -153,6 +153,18 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only:
resource :cycle_analytics, only: [:show]
+ namespace :cycle_analytics do
+ scope :events, controller: 'events' do
+ get :issue
+ get :plan
+ get :code
+ get :test
+ get :review
+ get :staging
+ get :production
+ end
+ end
+
resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do
collection do
post :cancel_all
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index f3531dd30a5..69136b73946 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -35,6 +35,7 @@
- [clear_database_cache, 1]
- [delete_user, 1]
- [delete_merged_branches, 1]
+ - [authorized_projects, 1]
- [expire_build_instance_artifacts, 1]
- [group_destroy, 1]
- [irker, 1]
diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb
index a984eda5ab5..18a2df7c059 100644
--- a/db/fixtures/development/04_project.rb
+++ b/db/fixtures/development/04_project.rb
@@ -1,4 +1,5 @@
require 'sidekiq/testing'
+require './db/fixtures/support/serialized_transaction'
Sidekiq::Testing.inline! do
Gitlab::Seeder.quiet do
diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb
index e882a492757..7b3908fae98 100644
--- a/db/fixtures/development/17_cycle_analytics.rb
+++ b/db/fixtures/development/17_cycle_analytics.rb
@@ -1,5 +1,6 @@
require 'sidekiq/testing'
require './spec/support/test_env'
+require './db/fixtures/support/serialized_transaction'
class Gitlab::Seeder::CycleAnalytics
def initialize(project, perf: false)
@@ -203,6 +204,8 @@ class Gitlab::Seeder::CycleAnalytics
pipeline.run!
Timecop.travel rand(1..6).hours.from_now
pipeline.succeed!
+
+ PipelineMetricsWorker.new.perform(pipeline.id)
end
end
diff --git a/db/fixtures/support/serialized_transaction.rb b/db/fixtures/support/serialized_transaction.rb
new file mode 100644
index 00000000000..d3305b661e5
--- /dev/null
+++ b/db/fixtures/support/serialized_transaction.rb
@@ -0,0 +1,9 @@
+require 'gitlab/database'
+
+module Gitlab
+ module Database
+ def self.serialized_transaction
+ connection.transaction { yield }
+ end
+ end
+end
diff --git a/db/migrate/20161010142410_create_project_authorizations.rb b/db/migrate/20161010142410_create_project_authorizations.rb
new file mode 100644
index 00000000000..e095ab969f8
--- /dev/null
+++ b/db/migrate/20161010142410_create_project_authorizations.rb
@@ -0,0 +1,15 @@
+class CreateProjectAuthorizations < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :project_authorizations do |t|
+ t.references :user, foreign_key: { on_delete: :cascade }
+ t.references :project, foreign_key: { on_delete: :cascade }
+ t.integer :access_level
+
+ t.index [:user_id, :project_id, :access_level], unique: true, name: 'index_project_authorizations_on_user_id_project_id_access_level'
+ end
+ end
+end
diff --git a/db/migrate/20161017091941_add_authorized_projects_populated_to_users.rb b/db/migrate/20161017091941_add_authorized_projects_populated_to_users.rb
new file mode 100644
index 00000000000..8f6be9dd677
--- /dev/null
+++ b/db/migrate/20161017091941_add_authorized_projects_populated_to_users.rb
@@ -0,0 +1,9 @@
+class AddAuthorizedProjectsPopulatedToUsers < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :users, :authorized_projects_populated, :boolean
+ end
+end
diff --git a/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb
new file mode 100644
index 00000000000..f49df6802a7
--- /dev/null
+++ b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb
@@ -0,0 +1,33 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddPipelineIdToMergeRequestMetrics < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = true
+
+ # When a migration requires downtime you **must** uncomment the following
+ # constant and define a short and easy to understand explanation as to why the
+ # migration requires downtime.
+ DOWNTIME_REASON = 'Adding a foreign key'
+
+ # When using the methods "add_concurrent_index" or "add_column_with_default"
+ # you must disable the use of transactions as these methods can not run in an
+ # existing transaction. When using "add_concurrent_index" make sure that this
+ # method is the _only_ method called in the migration, any other changes
+ # should go in a separate migration. This ensures that upon failure _only_ the
+ # index creation fails and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ # disable_ddl_transaction!
+
+ def change
+ add_column :merge_request_metrics, :pipeline_id, :integer
+ add_concurrent_index :merge_request_metrics, :pipeline_id
+ add_foreign_key :merge_request_metrics, :ci_commits, column: :pipeline_id, on_delete: :cascade
+ end
+end
diff --git a/db/migrate/20161031171301_add_project_id_to_subscriptions.rb b/db/migrate/20161031171301_add_project_id_to_subscriptions.rb
new file mode 100644
index 00000000000..97534679b59
--- /dev/null
+++ b/db/migrate/20161031171301_add_project_id_to_subscriptions.rb
@@ -0,0 +1,14 @@
+class AddProjectIdToSubscriptions < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ add_column :subscriptions, :project_id, :integer
+ add_foreign_key :subscriptions, :projects, column: :project_id, on_delete: :cascade
+ end
+
+ def down
+ remove_column :subscriptions, :project_id
+ end
+end
diff --git a/db/migrate/20161031174110_migrate_subscriptions_project_id.rb b/db/migrate/20161031174110_migrate_subscriptions_project_id.rb
new file mode 100644
index 00000000000..549145a0a65
--- /dev/null
+++ b/db/migrate/20161031174110_migrate_subscriptions_project_id.rb
@@ -0,0 +1,44 @@
+class MigrateSubscriptionsProjectId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = true
+ DOWNTIME_REASON = 'Subscriptions will not work as expected until this migration is complete.'
+
+ def up
+ execute <<-EOF.strip_heredoc
+ UPDATE subscriptions
+ SET project_id = (
+ SELECT issues.project_id
+ FROM issues
+ WHERE issues.id = subscriptions.subscribable_id
+ )
+ WHERE subscriptions.subscribable_type = 'Issue';
+ EOF
+
+ execute <<-EOF.strip_heredoc
+ UPDATE subscriptions
+ SET project_id = (
+ SELECT merge_requests.target_project_id
+ FROM merge_requests
+ WHERE merge_requests.id = subscriptions.subscribable_id
+ )
+ WHERE subscriptions.subscribable_type = 'MergeRequest';
+ EOF
+
+ execute <<-EOF.strip_heredoc
+ UPDATE subscriptions
+ SET project_id = (
+ SELECT projects.id
+ FROM labels INNER JOIN projects ON projects.id = labels.project_id
+ WHERE labels.id = subscriptions.subscribable_id
+ )
+ WHERE subscriptions.subscribable_type = 'Label';
+ EOF
+ end
+
+ def down
+ execute <<-EOF.strip_heredoc
+ UPDATE subscriptions SET project_id = NULL;
+ EOF
+ end
+end
diff --git a/db/migrate/20161031181638_add_unique_index_to_subscriptions.rb b/db/migrate/20161031181638_add_unique_index_to_subscriptions.rb
new file mode 100644
index 00000000000..4b1b29e1265
--- /dev/null
+++ b/db/migrate/20161031181638_add_unique_index_to_subscriptions.rb
@@ -0,0 +1,18 @@
+class AddUniqueIndexToSubscriptions < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = true
+ DOWNTIME_REASON = 'This migration requires downtime because it changes a column to not accept null values.'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :subscriptions, [:subscribable_id, :subscribable_type, :user_id, :project_id], { unique: true, name: 'index_subscriptions_on_subscribable_and_user_id_and_project_id' }
+ remove_index :subscriptions, name: 'subscriptions_user_id_and_ref_fields' if index_name_exists?(:subscriptions, 'subscriptions_user_id_and_ref_fields', false)
+ end
+
+ def down
+ add_concurrent_index :subscriptions, [:subscribable_id, :subscribable_type, :user_id], { unique: true, name: 'subscriptions_user_id_and_ref_fields' }
+ remove_index :subscriptions, name: 'index_subscriptions_on_subscribable_and_user_id_and_project_id' if index_name_exists?(:subscriptions, 'index_subscriptions_on_subscribable_and_user_id_and_project_id', false)
+ end
+end
diff --git a/db/migrate/20161113184239_create_user_chat_names_table.rb b/db/migrate/20161113184239_create_user_chat_names_table.rb
new file mode 100644
index 00000000000..97b597654f7
--- /dev/null
+++ b/db/migrate/20161113184239_create_user_chat_names_table.rb
@@ -0,0 +1,21 @@
+class CreateUserChatNamesTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :chat_names do |t|
+ t.integer :user_id, null: false
+ t.integer :service_id, null: false
+ t.string :team_id, null: false
+ t.string :team_domain
+ t.string :chat_id, null: false
+ t.string :chat_name
+ t.datetime :last_used_at
+ t.timestamps null: false
+ end
+
+ add_index :chat_names, [:user_id, :service_id], unique: true
+ add_index :chat_names, [:service_id, :team_id, :chat_id], unique: true
+ end
+end
diff --git a/db/migrate/20161117114805_remove_undeleted_groups.rb b/db/migrate/20161117114805_remove_undeleted_groups.rb
new file mode 100644
index 00000000000..ebc2d974ae0
--- /dev/null
+++ b/db/migrate/20161117114805_remove_undeleted_groups.rb
@@ -0,0 +1,16 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveUndeletedGroups < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ execute "DELETE FROM namespaces WHERE deleted_at IS NOT NULL;"
+ end
+
+ def down
+ # This is an irreversible migration;
+ # If someone is trying to rollback for other reasons, we should not throw an Exception.
+ # raise ActiveRecord::IrreversibleMigration
+ end
+end
diff --git a/db/migrate/20161118183841_add_commit_events_to_services.rb b/db/migrate/20161118183841_add_commit_events_to_services.rb
new file mode 100644
index 00000000000..4f9b5dd2281
--- /dev/null
+++ b/db/migrate/20161118183841_add_commit_events_to_services.rb
@@ -0,0 +1,15 @@
+class AddCommitEventsToServices < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:services, :commit_events, :boolean, default: true, allow_null: false)
+ end
+
+ def down
+ remove_column(:services, :commit_events)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 79e0513bf5d..31bf0d1281a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20161109150329) do
+ActiveRecord::Schema.define(version: 20161118183841) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -152,6 +152,21 @@ ActiveRecord::Schema.define(version: 20161109150329) do
t.text "message_html"
end
+ create_table "chat_names", force: :cascade do |t|
+ t.integer "user_id", null: false
+ t.integer "service_id", null: false
+ t.string "team_id", null: false
+ t.string "team_domain"
+ t.string "chat_id", null: false
+ t.string "chat_name"
+ t.datetime "last_used_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "chat_names", ["service_id", "team_id", "chat_id"], name: "index_chat_names_on_service_id_and_team_id_and_chat_id", unique: true, using: :btree
+ add_index "chat_names", ["user_id", "service_id"], name: "index_chat_names_on_user_id_and_service_id", unique: true, using: :btree
+
create_table "ci_application_settings", force: :cascade do |t|
t.boolean "all_broken_builds"
t.boolean "add_pusher"
@@ -635,10 +650,12 @@ ActiveRecord::Schema.define(version: 20161109150329) do
t.datetime "merged_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.integer "pipeline_id"
end
add_index "merge_request_metrics", ["first_deployed_to_production_at"], name: "index_merge_request_metrics_on_first_deployed_to_production_at", using: :btree
add_index "merge_request_metrics", ["merge_request_id"], name: "index_merge_request_metrics", using: :btree
+ add_index "merge_request_metrics", ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id", using: :btree
create_table "merge_requests", force: :cascade do |t|
t.string "target_branch", null: false
@@ -845,6 +862,14 @@ ActiveRecord::Schema.define(version: 20161109150329) do
add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree
add_index "personal_access_tokens", ["user_id"], name: "index_personal_access_tokens_on_user_id", using: :btree
+ create_table "project_authorizations", force: :cascade do |t|
+ t.integer "user_id"
+ t.integer "project_id"
+ t.integer "access_level"
+ end
+
+ add_index "project_authorizations", ["user_id", "project_id", "access_level"], name: "index_project_authorizations_on_user_id_project_id_access_level", unique: true, using: :btree
+
create_table "project_features", force: :cascade do |t|
t.integer "project_id"
t.integer "merge_requests_access_level"
@@ -1007,6 +1032,7 @@ ActiveRecord::Schema.define(version: 20161109150329) do
t.boolean "wiki_page_events", default: true
t.boolean "pipeline_events", default: false, null: false
t.boolean "confidential_issues_events", default: true, null: false
+ t.boolean "commit_events", default: true, null: false
end
add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree
@@ -1053,9 +1079,10 @@ ActiveRecord::Schema.define(version: 20161109150329) do
t.boolean "subscribed"
t.datetime "created_at"
t.datetime "updated_at"
+ t.integer "project_id"
end
- add_index "subscriptions", ["subscribable_id", "subscribable_type", "user_id"], name: "subscriptions_user_id_and_ref_fields", unique: true, using: :btree
+ add_index "subscriptions", ["subscribable_id", "subscribable_type", "user_id", "project_id"], name: "index_subscriptions_on_subscribable_and_user_id_and_project_id", unique: true, using: :btree
create_table "taggings", force: :cascade do |t|
t.integer "tag_id"
@@ -1188,6 +1215,7 @@ ActiveRecord::Schema.define(version: 20161109150329) do
t.boolean "external", default: false
t.string "organization"
t.string "incoming_email_token"
+ t.boolean "authorized_projects_populated"
end
add_index "users", ["admin"], name: "index_users_on_admin", using: :btree
@@ -1245,12 +1273,16 @@ ActiveRecord::Schema.define(version: 20161109150329) do
add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "lists", "boards"
add_foreign_key "lists", "labels"
+ add_foreign_key "merge_request_metrics", "ci_commits", column: "pipeline_id", on_delete: :cascade
add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade
add_foreign_key "personal_access_tokens", "users"
+ add_foreign_key "project_authorizations", "projects", on_delete: :cascade
+ add_foreign_key "project_authorizations", "users", on_delete: :cascade
add_foreign_key "protected_branch_merge_access_levels", "protected_branches"
add_foreign_key "protected_branch_push_access_levels", "protected_branches"
+ add_foreign_key "subscriptions", "projects", on_delete: :cascade
add_foreign_key "trending_projects", "projects", on_delete: :cascade
add_foreign_key "u2f_registrations", "users"
end
diff --git a/doc/api/groups.md b/doc/api/groups.md
index 45a3118f27a..5e6f498c365 100644
--- a/doc/api/groups.md
+++ b/doc/api/groups.md
@@ -6,8 +6,13 @@ Get a list of groups. (As user: my groups or all available, as admin: all groups
Parameters:
-- `all_available` (optional) - if passed, show all groups you have access to
-- `skip_groups` (optional)(array of group IDs) - if passed, skip groups
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `skip_groups` | array of integers | no | Skip the group IDs passes |
+| `all_available` | boolean | no | Show all the groups you have access to |
+| `search` | string | no | Return list of authorized groups matching the search criteria |
+| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` |
+| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
```
GET /groups
diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md
index a29b3eb6f44..6455c333faf 100644
--- a/doc/api/pipelines.md
+++ b/doc/api/pipelines.md
@@ -114,6 +114,51 @@ Example of response
}
```
+## Create a new pipeline
+
+> [Introduced][ce-7209] in GitLab 8.14
+
+```
+POST /projects/:id/pipeline
+```
+
+| Attribute | Type | Required | Description |
+|------------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+| `ref` | string | yes | Reference to commit |
+
+```
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipeline?ref=master"
+```
+
+Example of response
+
+```json
+{
+ "id": 61,
+ "sha": "384c444e840a515b23f21915ee5766b87068a70d",
+ "ref": "master",
+ "status": "pending",
+ "before_sha": "0000000000000000000000000000000000000000",
+ "tag": false,
+ "yaml_errors": null,
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2016-11-04T09:36:13.747Z",
+ "updated_at": "2016-11-04T09:36:13.977Z",
+ "started_at": null,
+ "finished_at": null,
+ "committed_at": null,
+ "duration": null
+}
+```
+
## Retry failed builds in a pipeline
> [Introduced][ce-5837] in GitLab 8.11
@@ -205,3 +250,4 @@ Response:
```
[ce-5837]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5837
+[ce-7209]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7209
diff --git a/doc/api/projects.md b/doc/api/projects.md
index bbb3bfb4995..467a880ac13 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -850,7 +850,7 @@ POST /projects/:id/archive
| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME |
```bash
-curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/archive"
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/archive"
```
Example response:
@@ -939,7 +939,7 @@ POST /projects/:id/unarchive
| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME |
```bash
-curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/unarchive"
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/unarchive"
```
Example response:
diff --git a/doc/api/users.md b/doc/api/users.md
index 041df07c051..b38c335490a 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -369,24 +369,24 @@ Parameters:
Get a list of a specified user's SSH keys. Available only for admin
```
-GET /users/:uid/keys
+GET /users/:id/keys
```
Parameters:
-- `uid` (required) - id of specified user
+- `id` (required) - id of specified user
## Single SSH key
Get a single key.
```
-GET /user/keys/:id
+GET /user/keys/:key_id
```
Parameters:
-- `id` (required) - The ID of an SSH key
+- `key_id` (required) - The ID of an SSH key
```json
{
@@ -458,25 +458,25 @@ This is an idempotent function and calling it on a key that is already deleted
or not available results in `200 OK`.
```
-DELETE /user/keys/:id
+DELETE /user/keys/:key_id
```
Parameters:
-- `id` (required) - SSH key ID
+- `key_id` (required) - SSH key ID
## Delete SSH key for given user
Deletes key owned by a specified user. Available only for admin.
```
-DELETE /users/:uid/keys/:id
+DELETE /users/:id/keys/:key_id
```
Parameters:
-- `uid` (required) - id of specified user
-- `id` (required) - SSH key ID
+- `id` (required) - id of specified user
+- `key_id` (required) - SSH key ID
Will return `200 OK` on success, or `404 Not found` if either user or key cannot be found.
@@ -510,24 +510,24 @@ Parameters:
Get a list of a specified user's emails. Available only for admin
```
-GET /users/:uid/emails
+GET /users/:id/emails
```
Parameters:
-- `uid` (required) - id of specified user
+- `id` (required) - id of specified user
## Single email
Get a single email.
```
-GET /user/emails/:id
+GET /user/emails/:email_id
```
Parameters:
-- `id` (required) - email ID
+- `email_id` (required) - email ID
```json
{
@@ -590,25 +590,25 @@ This is an idempotent function and calling it on a email that is already deleted
or not available results in `200 OK`.
```
-DELETE /user/emails/:id
+DELETE /user/emails/:email_id
```
Parameters:
-- `id` (required) - email ID
+- `email_id` (required) - email ID
## Delete email for given user
Deletes email owned by a specified user. Available only for admin.
```
-DELETE /users/:uid/emails/:id
+DELETE /users/:id/emails/:email_id
```
Parameters:
-- `uid` (required) - id of specified user
-- `id` (required) - email ID
+- `id` (required) - id of specified user
+- `email_id` (required) - email ID
Will return `200 OK` on success, or `404 Not found` if either user or email cannot be found.
@@ -617,12 +617,12 @@ Will return `200 OK` on success, or `404 Not found` if either user or email cann
Blocks the specified user. Available only for admin.
```
-PUT /users/:uid/block
+PUT /users/:id/block
```
Parameters:
-- `uid` (required) - id of specified user
+- `id` (required) - id of specified user
Will return `200 OK` on success, `404 User Not Found` is user cannot be found or
`403 Forbidden` when trying to block an already blocked user by LDAP synchronization.
@@ -632,12 +632,12 @@ Will return `200 OK` on success, `404 User Not Found` is user cannot be found or
Unblocks the specified user. Available only for admin.
```
-PUT /users/:uid/unblock
+PUT /users/:id/unblock
```
Parameters:
-- `uid` (required) - id of specified user
+- `id` (required) - id of specified user
Will return `200 OK` on success, `404 User Not Found` is user cannot be found or
`403 Forbidden` when trying to unblock a user blocked by LDAP synchronization.
diff --git a/doc/ci/environments.md b/doc/ci/environments.md
index cfb41307c43..9dd84a5ff81 100644
--- a/doc/ci/environments.md
+++ b/doc/ci/environments.md
@@ -235,7 +235,7 @@ will help us achieve that.
As the name suggests, it is possible to create environments on the fly by just
declaring their names dynamically in `.gitlab-ci.yml`. Dynamic environments is
-the base of [Review apps](review_apps.md).
+the basis of [Review apps](review_apps/index.md).
GitLab Runner exposes various [environment variables][variables] when a job runs,
and as such, you can use them as environment names. Let's add another job in
@@ -344,6 +344,10 @@ review_app:
It is assumed that the user has already setup NGINX and GitLab Runner in the
server this job will run on.
+>**Note:**
+Be sure to check out the [limitations](#limitations) section for some edge
+cases regarding naming of you branches and Review Apps.
+
---
The development workflow would now be:
@@ -489,12 +493,13 @@ fetch = +refs/environments/*:refs/remotes/origin/environments/*
## Limitations
-- You are limited to use only the [CI predefined variables][variables] in the
- `environment: name`. Any variables defined inside `script` will not work.
-- If the branch name contains special characters and you use the
- `$CI_BUILD_REF_NAME` variable to dynamically create environments, there might
- be complications during deployment. Follow the [issue 22849][ce-22849] for
- more information.
+1. If the branch name contains special characters (`/`), and you use the
+ `$CI_BUILD_REF_NAME` variable to dynamically create environments, there might
+ be complications during your Review Apps deployment. Follow the
+ [issue 22849][ce-22849] for more information.
+1. You are limited to use only the [CI predefined variables][variables] in the
+ `environment: name`. If you try to re-use variables defined inside `script`
+ as part of the environment name, it will not work.
## Further reading
@@ -515,5 +520,6 @@ Below are some links you may find interesting:
[only]: yaml/README.md#only-and-except
[onstop]: yaml/README.md#environment-on_stop
[ce-7015]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7015
+[ce-22849]: https://gitlab.com/gitlab-org/gitlab-ce/issues/22849
[gitlab runner]: https://docs.gitlab.com/runner/
[git-strategy]: yaml/README.md#git-strategy
diff --git a/doc/ci/examples/test-scala-application.md b/doc/ci/examples/test-scala-application.md
index 7412fdbbc78..85f8849fa99 100644
--- a/doc/ci/examples/test-scala-application.md
+++ b/doc/ci/examples/test-scala-application.md
@@ -1,11 +1,11 @@
-## Test a Scala application
+# Test and deploy to Heroku a Scala application
This example demonstrates the integration of Gitlab CI with Scala
applications using SBT. Checkout the example
[project](https://gitlab.com/gitlab-examples/scala-sbt) and
[build status](https://gitlab.com/gitlab-examples/scala-sbt/builds).
-### Add `.gitlab-ci.yml` file to project
+## Add `.gitlab-ci.yml` file to project
The following `.gitlab-ci.yml` should be added in the root of your
repository to trigger CI:
@@ -13,10 +13,14 @@ repository to trigger CI:
``` yaml
image: java:8
+stages:
+ - test
+ - deploy
+
before_script:
- apt-get update -y
- apt-get install apt-transport-https -y
- # Install SBT
+ ## Install SBT
- echo "deb http://dl.bintray.com/sbt/debian /" | tee -a /etc/apt/sources.list.d/sbt.list
- apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 642AC823
- apt-get update -y
@@ -24,8 +28,17 @@ before_script:
- sbt sbt-version
test:
+ stage: test
script:
- sbt clean coverage test coverageReport
+
+deploy:
+ stage: deploy
+ script:
+ - apt-get update -yq
+ - apt-get install rubygems ruby-dev -y
+ - gem install dpl
+ - dpl --provider=heroku --app=gitlab-play-sample-app --api-key=$HEROKU_API_KEY
```
The `before_script` installs [SBT](http://www.scala-sbt.org/) and
@@ -33,15 +46,31 @@ displays the version that is being used. The `test` stage executes SBT
to compile and test the project.
[scoverage](https://github.com/scoverage/sbt-scoverage) is used as an SBT
plugin to measure test coverage.
+The `deploy` stage automatically deploys the project to Heroku using dpl.
You can use other versions of Scala and SBT by defining them in
`build.sbt`.
-### Display test coverage in build
+## Display test coverage in build
Add the `Coverage was \[\d+.\d+\%\]` regular expression in the
-**Settings > Edit Project > Test coverage parsing** project setting to
-retrieve the test coverage rate from the build trace and have it
+**Settings âž” Edit Project âž” Test coverage parsing** project setting to
+retrieve the [test coverage] rate from the build trace and have it
displayed with your builds.
**Builds** must be enabled for this option to appear.
+
+## Heroku application
+
+A Heroku application is required. You can create one through the
+[Dashboard](https://dashboard.heroku.com/). Substitute `gitlab-play-sample-app`
+in the `.gitlab-ci.yml` file with your application's name.
+
+## Heroku API key
+
+You can look up your Heroku API key in your
+[account](https://dashboard.heroku.com/account). Add a secure [variable] with
+this value in **Project âž” Variables** with key `HEROKU_API_KEY`.
+
+[variable]: ../variables/README.md#user-defined-variables-secure-variables
+[test coverage]: ../../user/project/pipelines/settings.md#test-coverage-report-badge
diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md
index b41ae130bc2..a66165dc973 100644
--- a/doc/ci/review_apps/index.md
+++ b/doc/ci/review_apps/index.md
@@ -6,7 +6,7 @@
- Inspired by [Heroku's Review Apps][heroku-apps] which itself was inspired by
[Fourchette].
-The base of Review Apps is the [dynamic environments] which allow you to create
+The basis of Review Apps is the [dynamic environments] which allow you to create
a new environment (dynamically) for each one of your branches.
A Review App can then be visible as a link when you visit the [merge request]
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 6fee750c709..338c9a27789 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -760,6 +760,15 @@ artifacts:
- binaries/
```
+To disable artifact passing, define the job with empty [dependencies](#dependencies):
+
+```yaml
+job:
+ stage: build
+ script: make build
+ dependencies: []
+```
+
You may want to create artifacts only for tagged releases to avoid filling the
build server storage with temporary build artifacts.
diff --git a/doc/development/README.md b/doc/development/README.md
index f88456a7a7a..371bb55c127 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -22,6 +22,7 @@
## Process
- [Generate a changelog entry with `bin/changelog`](changelog.md)
+- [Limit conflicts with EE when developing on CE](limit_ee_conflicts.md)
- [Code review guidelines](code_review.md) for reviewing code and having code reviewed.
- [Merge request performance guidelines](merge_request_performance_guidelines.md)
for ensuring merge requests do not negatively impact GitLab performance
diff --git a/doc/development/frontend.md b/doc/development/frontend.md
index ec8f2d6531c..9e782ab977f 100644
--- a/doc/development/frontend.md
+++ b/doc/development/frontend.md
@@ -205,6 +205,57 @@ command line.
Please note: Not all of the frontend fixtures are generated. Some are still static
files. These will not be touched by `rake teaspoon:fixtures`.
+## Design Patterns
+
+### Singletons
+
+When exactly one object is needed for a given task, prefer to define it as a
+`class` rather than as an object literal. Prefer also to explicitly restrict
+instantiation, unless flexibility is important (e.g. for testing).
+
+```
+// bad
+
+gl.MyThing = {
+ prop1: 'hello',
+ method1: () => {}
+};
+
+// good
+
+class MyThing {
+ constructor() {
+ this.prop1 = 'hello';
+ }
+ method1() {}
+}
+
+gl.MyThing = new MyThing();
+
+// best
+
+let singleton;
+
+class MyThing {
+ constructor() {
+ if (!singleton) {
+ singleton = this;
+ singleton.init();
+ }
+ return singleton;
+ }
+
+ init() {
+ this.prop1 = 'hello';
+ }
+
+ method1() {}
+}
+
+gl.MyThing = MyThing;
+
+```
+
## Supported browsers
For our currently-supported browsers, see our [requirements][requirements].
diff --git a/doc/development/limit_ee_conflicts.md b/doc/development/limit_ee_conflicts.md
new file mode 100644
index 00000000000..b7e6387838e
--- /dev/null
+++ b/doc/development/limit_ee_conflicts.md
@@ -0,0 +1,272 @@
+# Limit conflicts with EE when developing on CE
+
+This guide contains best-practices for avoiding conflicts between CE and EE.
+
+## Context
+
+Usually, GitLab Community Edition is merged into the Enterprise Edition once a
+week. During these merges, it's very common to get conflicts when some changes
+in CE do not apply cleanly to EE.
+
+There are a few things that can help you as a developer to:
+
+- know when your merge request to CE will conflict when merged to EE
+- avoid such conflicts in the first place
+- ease future conflict resolutions if conflict is inevitable
+
+## Check the `rake ee_compat_check` in your merge requests
+
+For each commit (except on `master`), the `rake ee_compat_check` CI job tries to
+detect if the current branch's changes will conflict during the CE->EE merge.
+
+The job reports what files are conflicting and how to setup a merge request
+against EE. Here is roughly how it works:
+
+1. Generates the diff between your branch and current CE `master`
+1. Tries to apply it to current EE `master`
+1. If it applies cleanly, the job succeeds, otherwise...
+1. Detects a branch with the `-ee` suffix in EE
+1. If it exists, generate the diff between this branch and current EE `master`
+1. Tries to apply it to current EE `master`
+1. If it applies cleanly, the job succeeds
+
+In the case where the job fails, it means you should create a `<ce_branch>-ee`
+branch, push it to EE and open a merge request against EE `master`. At this
+point if you retry the failing job in your CE merge request, it should now pass.
+
+Notes:
+
+- This task is not a silver-bullet, its current goal is to bring awareness to
+ developers that their work needs to be ported to EE.
+- Community contributors shouldn't submit merge requests against EE, but
+ reviewers should take actions by either creating such EE merge request or
+ asking a GitLab developer to do it once the merge request is merged.
+- If you branch is more than 500 commits behind `master`, the job will fail and
+ you should rebase your branch upon latest `master`.
+
+## Possible type of conflicts
+
+### Controllers
+
+#### List or arrays are augmented in EE
+
+In controllers, the most common type of conflict is with `before_action` that
+has a list of actions in CE but EE adds some actions to that list.
+
+The same problem often occurs for `params.require` / `params.permit` calls.
+
+##### Mitigations
+
+Separate CE and EE actions/keywords. For instance for `params.require` in
+`ProjectsController`:
+
+```ruby
+def project_params
+ params.require(:project).permit(project_params_ce)
+ # On EE, this is always:
+ # params.require(:project).permit(project_params_ce << project_params_ee)
+end
+
+# Always returns an array of symbols, created however best fits the use case.
+# It _should_ be sorted alphabetically.
+def project_params_ce
+ %i[
+ description
+ name
+ path
+ ]
+end
+
+# (On EE)
+def project_params_ee
+ %i[
+ approvals_before_merge
+ approver_group_ids
+ approver_ids
+ ...
+ ]
+end
+```
+
+#### Additional condition(s) in EE
+
+For instance for LDAP:
+
+```diff
+ def destroy
+ @key = current_user.keys.find(params[:id])
+ - @key.destroy
+ + @key.destroy unless @key.is_a? LDAPKey
+
+ respond_to do |format|
+```
+
+Or for Geo:
+
+```diff
+def after_sign_out_path_for(resource)
+- current_application_settings.after_sign_out_path.presence || new_user_session_path
++ if Gitlab::Geo.secondary?
++ Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state)
++ else
++ current_application_settings.after_sign_out_path.presence || new_user_session_path
++ end
+end
+```
+
+Or even for audit log:
+
+```diff
+def approve_access_request
+- Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute
++ member = Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute
++
++ log_audit_event(member, action: :create)
+
+ redirect_to polymorphic_url([membershipable, :members])
+end
+```
+
+### Views
+
+#### Additional view code in EE
+
+A block of code added in CE conflicts because there is already another block
+at the same place in EE
+
+##### Mitigations
+
+Blocks of code that are EE-specific should be moved to partials as much as
+possible to avoid conflicts with big chunks of HAML code that that are not fun
+to resolve when you add the indentation to the equation.
+
+For instance this kind of thing:
+
+```haml
+- if can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project)
+ - has_due_date = issuable.has_attribute?(:due_date)
+ %hr
+ .row
+ %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") }
+ .form-group.issue-assignee
+ = f.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder
+ - if issuable.assignee_id
+ = f.hidden_field :assignee_id
+ = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
+ placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
+ .form-group.issue-milestone
+ = f.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder
+ = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
+ .form-group
+ - has_labels = @labels && @labels.any?
+ = f.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}"
+ = f.hidden_field :label_ids, multiple: true, value: ''
+ .col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" }
+ .issuable-form-select-holder
+ = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false, show_menu_above: 'true' }, dropdown_title: "Select label"
+
+ - if issuable.respond_to?(:weight)
+ .form-group
+ = f.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do
+ Weight
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ = f.select :weight, issues_weight_options(issuable.weight, edit: true), { include_blank: true },
+ { class: 'select2 js-select2', data: { placeholder: "Select weight" }}
+
+ - if has_due_date
+ .col-lg-6
+ .form-group
+ = f.label :due_date, "Due date", class: "control-label"
+ .col-sm-10
+ .issuable-form-select-holder
+ = f.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date"
+```
+
+could be simplified by using partials:
+
+```haml
+= render 'metadata_form', issuable: issuable
+```
+
+and then the `_metadata_form.html.haml` could be as follows:
+
+```haml
+- return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project)
+
+- has_due_date = issuable.has_attribute?(:due_date)
+%hr
+.row
+ %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") }
+ .form-group.issue-assignee
+ = f.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder
+ - if issuable.assignee_id
+ = f.hidden_field :assignee_id
+ = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
+ placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
+ .form-group.issue-milestone
+ = f.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder
+ = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
+ .form-group
+ - has_labels = @labels && @labels.any?
+ = f.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}"
+ = f.hidden_field :label_ids, multiple: true, value: ''
+ .col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" }
+ .issuable-form-select-holder
+ = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false, show_menu_above: 'true' }, dropdown_title: "Select label"
+
+ = render 'weight_form', issuable: issuable, has_due_date: has_due_date
+
+ - if has_due_date
+ .col-lg-6
+ .form-group
+ = f.label :due_date, "Due date", class: "control-label"
+ .col-sm-10
+ .issuable-form-select-holder
+ = f.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date"
+```
+
+and then the `_weight_form.html.haml` could be as follows:
+
+```haml
+- return unless issuable.respond_to?(:weight)
+
+- has_due_date = issuable.has_attribute?(:due_date)
+
+.form-group
+ = f.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do
+ Weight
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ = f.select :weight, issues_weight_options(issuable.weight, edit: true), { include_blank: true },
+ { class: 'select2 js-select2', data: { placeholder: "Select weight" }}
+```
+
+Note:
+
+- The safeguards at the top allow to get rid of an unneccessary indentation level
+- Here we only moved the 'Weight' code to a partial since this is the only
+ EE-specific code in that view, so it's the most likely to conflict, but you
+ are encouraged to use partials even for code that's in CE to logically split
+ big views into several smaller files.
+
+#### Indentation issue
+
+Sometimes a code block is indented more or less in EE because there's an
+additional condition.
+
+##### Mitigations
+
+Blocks of code that are EE-specific should be moved to partials as much as
+possible to avoid conflicts with big chunks of HAML code that that are not fun
+to resolve when you add the indentation in the equation.
+
+---
+
+[Return to Development documentation](README.md)
diff --git a/doc/development/testing.md b/doc/development/testing.md
index 4dc535fb359..6106e47daa0 100644
--- a/doc/development/testing.md
+++ b/doc/development/testing.md
@@ -70,6 +70,7 @@ the command line via `bundle exec teaspoon`, or via a web browser at
- Try to match the ordering of tests to the ordering within the class.
- Try to follow the [Four-Phase Test][four-phase-test] pattern, using newlines
to separate phases.
+- Try to use `Gitlab.config.gitlab.host` rather than hard coding `'localhost'`
[four-phase-test]: https://robots.thoughtbot.com/four-phase-test
diff --git a/doc/development/ux_guide/copy.md b/doc/development/ux_guide/copy.md
index 03392a003ee..227b4fd3451 100644
--- a/doc/development/ux_guide/copy.md
+++ b/doc/development/ux_guide/copy.md
@@ -7,7 +7,7 @@ We are currently inconsistent with this guidance. Images below are created to il
## Contents
* [Brevity](#brevity)
-* [Forms](#forms)
+* [Sentence case](#sentence-case)
* [Terminology](#terminology)
---
@@ -27,52 +27,73 @@ Preferrably use context and placement of controls to make it obvious what clicki
---
-## Forms
+## Sentence case
+Use sentence case for all titles, headings, labels, menu items, and buttons.
-### Adding items
+---
+
+## Terminology
+Only use the terms in the tables below.
+
+### Issues
+
+#### Adjectives (states)
+
+| Term |
+| ---- |
+| Open |
+| Closed |
+| Deleted |
+
+>**Example:**
+Use `5 open issues` and don't use `5 pending issues`.
+
+#### Verbs (actions)
+
+| Term | Use | Don't |
+| ---- | --- | --- |
+| Add | Add an issue | Don't use `create` or `new` |
+| View | View an open or closed issue ||
+| Edit | Edit an open or closed issue | Don't use `update` |
+| Close | Close an open issue ||
+| Re-open | Re-open a closed issue | There should never be a need to use `open` as a verb |
+| Delete | Delete an open or closed issue ||
+
+#### Add issue
When viewing a list of issues, there is a button that is labeled `Add`. Given the context in the example, it is clearly referring to issues. If the context were not clear enough, the label could be `Add issue`. Clicking the button will bring you to the `Add issue` form. Other add flows should be similar.
![Add issue button](img/copy-form-addissuebutton.png)
-The form should be titled `Add issue`. The submit button should be labeled `Save` or `Submit`. Do not use `Add`, `Create`, `New`, or `Save Changes`. The cancel button should be labeled `Cancel`. Do not use `Back`.
+The form should be titled `Add issue`. The submit button should be labeled `Submit`. Don't use `Add`, `Create`, `New`, or `Save changes`. The cancel button should be labeled `Cancel`. Don't use `Back`.
![Add issue form](img/copy-form-addissueform.png)
-### Editing items
+#### Edit issue
When in context of an issue, the affordance to edit it is labeled `Edit`. If the context is not clear enough, `Edit issue` could be considered. Other edit flows should be similar.
![Edit issue button](img/copy-form-editissuebutton.png)
-The form should be titled `Edit Issue`. The submit button should be labeled `Save`. Do not use `Edit`, `Update`, `New`, or `Save Changes`. The cancel button should be labeled `Cancel`. Do not use `Back`.
+The form should be titled `Edit issue`. The submit button should be labeled `Save`. Don't use `Edit`, `Update`, `Submit`, or `Save changes`. The cancel button should be labeled `Cancel`. Don't use `Back`.
![Edit issue form](img/copy-form-editissueform.png)
----
-
-## Terminology
-### Issues
+### Merge requests
#### Adjectives (states)
-| Term | Use |
-| ---- | --- |
-| Open | Issue is active |
-| Closed | Issue is no longer active |
-
->**Example:**
-Use `5 open issues` and do not use `5 pending issues`.
-Only use the adjectives in the table above.
+| Term |
+| ---- |
+| Open |
+| Merged |
#### Verbs (actions)
-| Term | Use |
-| ---- | --- |
-| Add | For adding an issue. Do not use `create` or `new` |
-| View | View an issue |
-| Edit | Edit an issue. Do not use `update` |
-| Close | Closing an issue |
-| Re-open | Re-open an issue. There should never be a need to use `open` as a verb |
-| Delete | Deleting an issue. Do not use `remove` | \ No newline at end of file
+| Term | Use | Don't |
+| ---- | --- | --- |
+| Add | Add a merge request | Do not use `create` or `new` |
+| View | View an open or merged merge request ||
+| Edit | Edit an open or merged merge request| Do not use `update` |
+| Merge | Merge an open merge request || \ No newline at end of file
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index 766a7119943..42b515761e0 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -143,9 +143,6 @@ On a very active server (10,000 active users) the Sidekiq process can use 1GB+ o
## Supported web browsers
-- Chrome (Latest stable version)
-- Firefox (Latest released version and [latest ESR version](https://www.mozilla.org/en-US/firefox/organizations/))
-- Safari 7+ (known problem: required fields in html5 do not work)
-- Opera (Latest released version)
-- Internet Explorer (IE) 11+ but please make sure that you have the `Compatibility View` mode disabled.
-- Edge (Latest stable version)
+We support the current and the previous major release of Firefox, Safari and Microsoft browsers (Microsoft Edge and Internet Explorer 11).
+
+Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version.
diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md
index 2215f37b81a..c228ea72f22 100644
--- a/doc/workflow/gitlab_flow.md
+++ b/doc/workflow/gitlab_flow.md
@@ -279,7 +279,7 @@ The trick is to use the merge/pull request with multiple commits when your work
The commit message should reflect your intention, not the contents of the commit.
The contents of the commit can be easily seen anyway, the question is why you did it.
An example of a good commit message is: "Combine templates to dry up the user views.".
-Some words that are bad commit messages because they don't contain munch information are: change, improve and refactor.
+Some words that are bad commit messages because they don't contain much information are: change, improve and refactor.
The word fix or fixes is also a red flag, unless it comes after the commit sentence and references an issue number.
To see more information about the formatting of commit messages please see this great [blog post by Tim Pope](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md
index a50ba305deb..54e7ae19ea5 100644
--- a/doc/workflow/todos.md
+++ b/doc/workflow/todos.md
@@ -24,9 +24,11 @@ you still have open.
A Todo appears in your Todos dashboard when:
-- an issue or merge request is assigned to you
+- an issue or merge request is assigned to you,
- you are `@mentioned` in an issue or merge request, be it the description of
- the issue/merge request or in a comment
+ the issue/merge request or in a comment,
+- build in the CI pipeline running for your merge request failed, but this
+ build is not allowed to fail.
>**Note:** Commenting on a commit will _not_ trigger a Todo.
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index b50f5238e80..aaf0ede67e6 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -62,7 +62,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "New Issue"' do
- click_link "New Issue"
+ page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
end
step 'I click "author" dropdown' do
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 54c35d21b0b..4cf8a478f1d 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -218,7 +218,7 @@ module API
expose :assignee, :author, using: Entities::UserBasic
expose :subscribed do |issue, options|
- issue.subscribed?(options[:current_user])
+ issue.subscribed?(options[:current_user], options[:project] || issue.project)
end
expose :user_notes_count
expose :upvotes, :downvotes
@@ -248,7 +248,7 @@ module API
expose :diff_head_sha, as: :sha
expose :merge_commit_sha
expose :subscribed do |merge_request, options|
- merge_request.subscribed?(options[:current_user])
+ merge_request.subscribed?(options[:current_user], options[:project])
end
expose :user_notes_count
expose :should_remove_source_branch?, as: :should_remove_source_branch
@@ -454,7 +454,7 @@ module API
end
expose :subscribed do |label, options|
- label.subscribed?(options[:current_user])
+ label.subscribed?(options[:current_user], options[:project])
end
end
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index 3f57b9ab5bc..48ad3b80ae0 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -19,6 +19,8 @@ module API
optional :skip_groups, type: Array[Integer], desc: 'Array of group ids to exclude from list'
optional :all_available, type: Boolean, desc: 'Show all group that you have access to'
optional :search, type: String, desc: 'Search for a specific group'
+ optional :order_by, type: String, values: %w[name path], default: 'name', desc: 'Order by name or path'
+ optional :sort, type: String, values: %w[asc desc], default: 'asc', desc: 'Sort by asc (ascending) or desc (descending)'
end
get do
groups = if current_user.admin
@@ -31,6 +33,8 @@ module API
groups = groups.search(params[:search]) if params[:search].present?
groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present?
+ groups = groups.reorder(params[:order_by] => params[:sort].to_sym)
+
present paginate(groups), with: Entities::Group
end
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 84cc9200d1b..2c593dbb4ea 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -85,8 +85,8 @@ module API
end
end
- def project_service
- @project_service ||= user_project.find_or_initialize_service(params[:service_slug].underscore)
+ def project_service(project = user_project)
+ @project_service ||= project.find_or_initialize_service(params[:service_slug].underscore)
@project_service || not_found!("Service")
end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index c9689e6f8ef..eea5b91d4f9 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -120,7 +120,7 @@ module API
issues = issues.reorder(issuable_order_by => issuable_sort)
- present paginate(issues), with: Entities::Issue, current_user: current_user
+ present paginate(issues), with: Entities::Issue, current_user: current_user, project: user_project
end
# Get a single project issue
@@ -132,7 +132,7 @@ module API
# GET /projects/:id/issues/:issue_id
get ":id/issues/:issue_id" do
@issue = find_project_issue(params[:issue_id])
- present @issue, with: Entities::Issue, current_user: current_user
+ present @issue, with: Entities::Issue, current_user: current_user, project: user_project
end
# Create a new project issue
@@ -174,7 +174,7 @@ module API
end
if issue.valid?
- present issue, with: Entities::Issue, current_user: current_user
+ present issue, with: Entities::Issue, current_user: current_user, project: user_project
else
render_validation_error!(issue)
end
@@ -217,7 +217,7 @@ module API
issue = ::Issues::UpdateService.new(user_project, current_user, attrs).execute(issue)
if issue.valid?
- present issue, with: Entities::Issue, current_user: current_user
+ present issue, with: Entities::Issue, current_user: current_user, project: user_project
else
render_validation_error!(issue)
end
@@ -239,7 +239,7 @@ module API
begin
issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project)
- present issue, with: Entities::Issue, current_user: current_user
+ present issue, with: Entities::Issue, current_user: current_user, project: user_project
rescue ::Issues::MoveService::MoveError => error
render_api_error!(error.message, 400)
end
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index f9720786e63..4176c7eec06 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -60,7 +60,7 @@ module API
end
merge_requests = merge_requests.reorder(issuable_order_by => issuable_sort)
- present paginate(merge_requests), with: Entities::MergeRequest, current_user: current_user
+ present paginate(merge_requests), with: Entities::MergeRequest, current_user: current_user, project: user_project
end
desc 'Create a merge request' do
@@ -87,7 +87,7 @@ module API
merge_request = ::MergeRequests::CreateService.new(user_project, current_user, mr_params).execute
if merge_request.valid?
- present merge_request, with: Entities::MergeRequest, current_user: current_user
+ present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
else
handle_merge_request_errors! merge_request.errors
end
@@ -120,7 +120,7 @@ module API
get path do
merge_request = user_project.merge_requests.find(params[:merge_request_id])
authorize! :read_merge_request, merge_request
- present merge_request, with: Entities::MergeRequest, current_user: current_user
+ present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
end
desc 'Get the commits of a merge request' do
@@ -167,7 +167,7 @@ module API
merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request)
if merge_request.valid?
- present merge_request, with: Entities::MergeRequest, current_user: current_user
+ present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
else
handle_merge_request_errors! merge_request.errors
end
@@ -212,7 +212,7 @@ module API
execute(merge_request)
end
- present merge_request, with: Entities::MergeRequest, current_user: current_user
+ present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
end
desc 'Cancel merge if "Merge when build succeeds" is enabled' do
diff --git a/lib/api/milestones.rb b/lib/api/milestones.rb
index ba4a84275bc..937c118779d 100644
--- a/lib/api/milestones.rb
+++ b/lib/api/milestones.rb
@@ -114,7 +114,7 @@ module API
}
issues = IssuesFinder.new(current_user, finder_params).execute
- present paginate(issues), with: Entities::Issue, current_user: current_user
+ present paginate(issues), with: Entities::Issue, current_user: current_user, project: user_project
end
end
end
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
index 2a0c8e1f2c0..e69b0569612 100644
--- a/lib/api/pipelines.rb
+++ b/lib/api/pipelines.rb
@@ -22,6 +22,27 @@ module API
pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope])
present paginate(pipelines), with: Entities::Pipeline
end
+
+ desc 'Create a new pipeline' do
+ detail 'This feature was introduced in GitLab 8.14'
+ success Entities::Pipeline
+ end
+ params do
+ requires :ref, type: String, desc: 'Reference'
+ end
+ post ':id/pipeline' do
+ authorize! :create_pipeline, user_project
+
+ new_pipeline = Ci::CreatePipelineService.new(user_project,
+ current_user,
+ declared_params(include_missing: false))
+ .execute(ignore_skip_ci: true, save_on_errors: false)
+ if new_pipeline.persisted?
+ present new_pipeline, with: Entities::Pipeline
+ else
+ render_validation_error!(new_pipeline)
+ end
+ end
desc 'Gets a specific pipeline for the project' do
detail 'This feature was introduced in GitLab 8.11'
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index ce1bf0d26d2..d0ee9c9a5b2 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -3,6 +3,9 @@ module API
class ProjectSnippets < Grape::API
before { authenticate! }
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
resource :projects do
helpers do
def handle_project_member_errors(errors)
@@ -18,111 +21,108 @@ module API
end
end
- # Get a project snippets
- #
- # Parameters:
- # id (required) - The ID of a project
- # Example Request:
- # GET /projects/:id/snippets
+ desc 'Get all project snippets' do
+ success Entities::ProjectSnippet
+ end
get ":id/snippets" do
present paginate(snippets_for_current_user), with: Entities::ProjectSnippet
end
- # Get a project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # Example Request:
- # GET /projects/:id/snippets/:snippet_id
+ desc 'Get a single project snippet' do
+ success Entities::ProjectSnippet
+ end
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ end
get ":id/snippets/:snippet_id" do
- @snippet = snippets_for_current_user.find(params[:snippet_id])
- present @snippet, with: Entities::ProjectSnippet
- end
-
- # Create a new project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # title (required) - The title of a snippet
- # file_name (required) - The name of a snippet file
- # code (required) - The content of a snippet
- # visibility_level (required) - The snippet's visibility
- # Example Request:
- # POST /projects/:id/snippets
+ snippet = snippets_for_current_user.find(params[:snippet_id])
+ present snippet, with: Entities::ProjectSnippet
+ end
+
+ desc 'Create a new project snippet' do
+ success Entities::ProjectSnippet
+ end
+ params do
+ 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'
+ requires :visibility_level, type: Integer,
+ values: [Gitlab::VisibilityLevel::PRIVATE,
+ Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PUBLIC],
+ desc: 'The visibility level of the snippet'
+ end
post ":id/snippets" do
authorize! :create_project_snippet, user_project
- required_attributes! [:title, :file_name, :code, :visibility_level]
+ snippet_params = declared_params
+ snippet_params[:content] = snippet_params.delete(:code)
- attrs = attributes_for_keys [:title, :file_name, :visibility_level]
- attrs[:content] = params[:code] if params[:code].present?
- @snippet = CreateSnippetService.new(user_project, current_user,
- attrs).execute
+ snippet = CreateSnippetService.new(user_project, current_user, snippet_params).execute
- if @snippet.errors.any?
- render_validation_error!(@snippet)
+ if snippet.persisted?
+ present snippet, with: Entities::ProjectSnippet
else
- present @snippet, with: Entities::ProjectSnippet
+ render_validation_error!(snippet)
end
end
- # Update an existing project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # title (optional) - The title of a snippet
- # file_name (optional) - The name of a snippet file
- # code (optional) - The content of a snippet
- # visibility_level (optional) - The snippet's visibility
- # Example Request:
- # PUT /projects/:id/snippets/:snippet_id
+ desc 'Update an existing project snippet' do
+ success Entities::ProjectSnippet
+ end
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ 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 :visibility_level, type: Integer,
+ values: [Gitlab::VisibilityLevel::PRIVATE,
+ Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PUBLIC],
+ desc: 'The visibility level of the snippet'
+ at_least_one_of :title, :file_name, :code, :visibility_level
+ end
put ":id/snippets/:snippet_id" do
- @snippet = snippets_for_current_user.find(params[:snippet_id])
- authorize! :update_project_snippet, @snippet
+ snippet = snippets_for_current_user.find_by(id: params.delete(:snippet_id))
+ not_found!('Snippet') unless snippet
+
+ authorize! :update_project_snippet, snippet
+
+ snippet_params = declared_params(include_missing: false)
+ snippet_params[:content] = snippet_params.delete(:code) if snippet_params[:code].present?
- attrs = attributes_for_keys [:title, :file_name, :visibility_level]
- attrs[:content] = params[:code] if params[:code].present?
+ UpdateSnippetService.new(user_project, current_user, snippet,
+ snippet_params).execute
- UpdateSnippetService.new(user_project, current_user, @snippet,
- attrs).execute
- if @snippet.errors.any?
- render_validation_error!(@snippet)
+ if snippet.persisted?
+ present snippet, with: Entities::ProjectSnippet
else
- present @snippet, with: Entities::ProjectSnippet
+ render_validation_error!(snippet)
end
end
- # Delete a project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # Example Request:
- # DELETE /projects/:id/snippets/:snippet_id
+ desc 'Delete a project snippet'
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ end
delete ":id/snippets/:snippet_id" do
- begin
- @snippet = snippets_for_current_user.find(params[:snippet_id])
- authorize! :update_project_snippet, @snippet
- @snippet.destroy
- rescue
- not_found!('Snippet')
- end
+ snippet = snippets_for_current_user.find_by(id: params[:snippet_id])
+ not_found!('Snippet') unless snippet
+
+ authorize! :admin_project_snippet, snippet
+ snippet.destroy
end
- # Get a raw project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # Example Request:
- # GET /projects/:id/snippets/:snippet_id/raw
+ desc 'Get a raw project snippet'
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ end
get ":id/snippets/:snippet_id/raw" do
- @snippet = snippets_for_current_user.find(params[:snippet_id])
+ snippet = snippets_for_current_user.find_by(id: params[:snippet_id])
+ not_found!('Snippet') unless snippet
env['api.format'] = :txt
content_type 'text/plain'
- present @snippet.content
+ present snippet.content
end
end
end
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index 0bb2f74809a..c287ee34a68 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -1,11 +1,13 @@
require 'mime/types'
module API
- # Projects API
class Repositories < Grape::API
before { authenticate! }
before { authorize! :download_code, user_project }
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
resource :projects do
helpers do
def handle_project_member_errors(errors)
@@ -16,43 +18,35 @@ module API
end
end
- # Get a project repository tree
- #
- # Parameters:
- # id (required) - The ID of a project
- # ref_name (optional) - The name of a repository branch or tag, if not given the default branch is used
- # recursive (optional) - Used to get a recursive tree
- # Example Request:
- # GET /projects/:id/repository/tree
+ desc 'Get a project repository tree' do
+ success Entities::RepoTreeObject
+ end
+ params do
+ optional :ref_name, type: String, desc: 'The name of a repository branch or tag, if not given the default branch is used'
+ optional :path, type: String, desc: 'The path of the tree'
+ optional :recursive, type: Boolean, default: false, desc: 'Used to get a recursive tree'
+ end
get ':id/repository/tree' do
ref = params[:ref_name] || user_project.try(:default_branch) || 'master'
path = params[:path] || nil
- recursive = to_boolean(params[:recursive])
commit = user_project.commit(ref)
not_found!('Tree') unless commit
- tree = user_project.repository.tree(commit.id, path, recursive: recursive)
+ tree = user_project.repository.tree(commit.id, path, recursive: params[:recursive])
present tree.sorted_entries, with: Entities::RepoTreeObject
end
- # Get a raw file contents
- #
- # Parameters:
- # id (required) - The ID of a project
- # sha (required) - The commit or branch name
- # filepath (required) - The path to the file to display
- # Example Request:
- # GET /projects/:id/repository/blobs/:sha
+ desc 'Get a raw file contents'
+ params do
+ requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+ requires :filepath, type: String, desc: 'The path to the file to display'
+ end
get [ ":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob" ] do
- required_attributes! [:filepath]
-
- ref = params[:sha]
-
repo = user_project.repository
- commit = repo.commit(ref)
+ commit = repo.commit(params[:sha])
not_found! "Commit" unless commit
blob = Gitlab::Git::Blob.find(repo, commit.id, params[:filepath])
@@ -61,20 +55,15 @@ module API
send_git_blob repo, blob
end
- # Get a raw blob contents by blob sha
- #
- # Parameters:
- # id (required) - The ID of a project
- # sha (required) - The blob's sha
- # Example Request:
- # GET /projects/:id/repository/raw_blobs/:sha
+ desc 'Get a raw blob contents by blob sha'
+ params do
+ requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+ end
get ':id/repository/raw_blobs/:sha' do
- ref = params[:sha]
-
repo = user_project.repository
begin
- blob = Gitlab::Git::Blob.raw(repo, ref)
+ blob = Gitlab::Git::Blob.raw(repo, params[:sha])
rescue
not_found! 'Blob'
end
@@ -84,15 +73,12 @@ module API
send_git_blob repo, blob
end
- # Get a an archive of the repository
- #
- # Parameters:
- # id (required) - The ID of a project
- # sha (optional) - the commit sha to download defaults to the tip of the default branch
- # Example Request:
- # GET /projects/:id/repository/archive
- get ':id/repository/archive',
- requirements: { format: Gitlab::Regex.archive_formats_regex } do
+ desc 'Get an archive of the repository'
+ params do
+ optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded'
+ optional :format, type: String, desc: 'The archive format'
+ end
+ get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do
authorize! :download_code, user_project
begin
@@ -102,27 +88,22 @@ module API
end
end
- # Compare two branches, tags or commits
- #
- # Parameters:
- # id (required) - The ID of a project
- # from (required) - the commit sha or branch name
- # to (required) - the commit sha or branch name
- # Example Request:
- # GET /projects/:id/repository/compare?from=master&to=feature
+ desc 'Compare two branches, tags, or commits' do
+ success Entities::Compare
+ end
+ params do
+ requires :from, type: String, desc: 'The commit, branch name, or tag name to start comparison'
+ requires :to, type: String, desc: 'The commit, branch name, or tag name to stop comparison'
+ end
get ':id/repository/compare' do
authorize! :download_code, user_project
- required_attributes! [:from, :to]
compare = Gitlab::Git::Compare.new(user_project.repository.raw_repository, params[:from], params[:to])
present compare, with: Entities::Compare
end
- # Get repository contributors
- #
- # Parameters:
- # id (required) - The ID of a project
- # Example Request:
- # GET /projects/:id/repository/contributors
+ desc 'Get repository contributors' do
+ success Entities::Contributor
+ end
get ':id/repository/contributors' do
authorize! :download_code, user_project
diff --git a/lib/api/services.rb b/lib/api/services.rb
index fc8598daa32..4d23499aa39 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -1,10 +1,10 @@
module API
# Projects API
class Services < Grape::API
- before { authenticate! }
- before { authorize_admin_project }
-
resource :projects do
+ before { authenticate! }
+ before { authorize_admin_project }
+
# Set <service_slug> service for project
#
# Example Request:
@@ -59,5 +59,28 @@ module API
present project_service, with: Entities::ProjectService, include_passwords: current_user.is_admin?
end
end
+
+ resource :projects do
+ desc 'Trigger a slash command' do
+ detail 'Added in GitLab 8.13'
+ end
+ post ':id/services/:service_slug/trigger' do
+ project = Project.find_with_namespace(params[:id]) || Project.find_by(id: params[:id])
+
+ # This is not accurate, but done to prevent leakage of the project names
+ not_found!('Service') unless project
+
+ service = project_service(project)
+
+ result = service.try(:active?) && service.try(:trigger, params)
+
+ if result
+ status result[:status] || 200
+ present result
+ else
+ not_found!('Service')
+ end
+ end
+ end
end
end
diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb
index 00a79c24f96..10749b34004 100644
--- a/lib/api/subscriptions.rb
+++ b/lib/api/subscriptions.rb
@@ -24,11 +24,11 @@ module API
post ":id/#{type}/:subscribable_id/subscription" do
resource = instance_exec(params[:subscribable_id], &finder)
- if resource.subscribed?(current_user)
+ if resource.subscribed?(current_user, user_project)
not_modified!
else
- resource.subscribe(current_user)
- present resource, with: entity_class, current_user: current_user
+ resource.subscribe(current_user, user_project)
+ present resource, with: entity_class, current_user: current_user, project: user_project
end
end
@@ -38,11 +38,11 @@ module API
delete ":id/#{type}/:subscribable_id/subscription" do
resource = instance_exec(params[:subscribable_id], &finder)
- if !resource.subscribed?(current_user)
+ if !resource.subscribed?(current_user, user_project)
not_modified!
else
- resource.unsubscribe(current_user)
- present resource, with: entity_class, current_user: current_user
+ resource.unsubscribe(current_user, user_project)
+ present resource, with: entity_class, current_user: current_user, project: user_project
end
end
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index aea328d2f8f..c07539194ed 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -4,89 +4,93 @@ module API
before { authenticate! }
resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do
- # Get a users list
- #
- # Example Request:
- # GET /users
- # GET /users?search=Admin
- # GET /users?username=root
- # GET /users?active=true
- # GET /users?external=true
- # GET /users?blocked=true
+ helpers do
+ params :optional_attributes do
+ optional :skype, type: String, desc: 'The Skype username'
+ optional :linkedin, type: String, desc: 'The LinkedIn username'
+ optional :twitter, type: String, desc: 'The Twitter username'
+ optional :website_url, type: String, desc: 'The website of the user'
+ optional :organization, type: String, desc: 'The organization of the user'
+ optional :projects_limit, type: Integer, desc: 'The number of projects a user can create'
+ optional :extern_uid, type: Integer, desc: 'The external authentication provider UID'
+ optional :provider, type: String, desc: 'The external provider'
+ optional :bio, type: String, desc: 'The biography of the user'
+ optional :location, type: String, desc: 'The location of the user'
+ optional :admin, type: Boolean, desc: 'Flag indicating the user is an administrator'
+ optional :can_create_group, type: Boolean, desc: 'Flag indicating the user can create groups'
+ optional :confirm, type: Boolean, desc: 'Flag indicating the account needs to be confirmed'
+ optional :external, type: Boolean, desc: 'Flag indicating the user is an external user'
+ all_or_none_of :extern_uid, :provider
+ end
+ end
+
+ desc 'Get the list of users' do
+ success Entities::UserBasic
+ end
+ params do
+ optional :username, type: String, desc: 'Get a single user with a specific username'
+ optional :search, type: String, desc: 'Search for a username'
+ optional :active, type: Boolean, default: false, desc: 'Filters only active users'
+ optional :external, type: Boolean, default: false, desc: 'Filters only external users'
+ optional :blocked, type: Boolean, default: false, desc: 'Filters only blocked users'
+ end
get do
unless can?(current_user, :read_users_list, nil)
render_api_error!("Not authorized.", 403)
end
if params[:username].present?
- @users = User.where(username: params[:username])
+ users = User.where(username: params[:username])
else
- @users = User.all
- @users = @users.active if to_boolean(params[:active])
- @users = @users.search(params[:search]) if params[:search].present?
- @users = @users.blocked if to_boolean(params[:blocked])
- @users = @users.external if to_boolean(params[:external]) && current_user.is_admin?
- @users = paginate @users
+ users = User.all
+ users = users.active if params[:active]
+ users = users.search(params[:search]) if params[:search].present?
+ users = users.blocked if params[:blocked]
+ users = users.external if params[:external] && current_user.is_admin?
end
- if current_user.is_admin?
- present @users, with: Entities::UserFull
- else
- present @users, with: Entities::UserBasic
- end
+ entity = current_user.is_admin? ? Entities::UserFull : Entities::UserBasic
+ present paginate(users), with: entity
end
- # Get a single user
- #
- # Parameters:
- # id (required) - The ID of a user
- # Example Request:
- # GET /users/:id
+ desc 'Get a single user' do
+ success Entities::UserBasic
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
get ":id" do
- @user = User.find(params[:id])
+ user = User.find_by(id: params[:id])
+ not_found!('User') unless user
if current_user && current_user.is_admin?
- present @user, with: Entities::UserFull
- elsif can?(current_user, :read_user, @user)
- present @user, with: Entities::User
+ present user, with: Entities::UserFull
+ elsif can?(current_user, :read_user, user)
+ present user, with: Entities::User
else
render_api_error!("User not found.", 404)
end
end
- # Create user. Available only for admin
- #
- # Parameters:
- # email (required) - Email
- # password (required) - Password
- # name (required) - Name
- # username (required) - Name
- # skype - Skype ID
- # linkedin - Linkedin
- # twitter - Twitter account
- # website_url - Website url
- # organization - Organization
- # projects_limit - Number of projects user can create
- # extern_uid - External authentication provider UID
- # provider - External provider
- # bio - Bio
- # location - Location of the user
- # admin - User is admin - true or false (default)
- # can_create_group - User can create groups - true or false
- # confirm - Require user confirmation - true (default) or false
- # external - Flags the user as external - true or false(default)
- # Example Request:
- # POST /users
+ desc 'Create a user. Available only for admins.' do
+ success Entities::UserFull
+ end
+ params do
+ requires :email, type: String, desc: 'The email of the user'
+ requires :password, type: String, desc: 'The password of the new user'
+ requires :name, type: String, desc: 'The name of the user'
+ requires :username, type: String, desc: 'The username of the user'
+ use :optional_attributes
+ end
post do
authenticated_as_admin!
- required_attributes! [:email, :password, :name, :username]
- attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :confirm, :external, :organization]
- admin = attrs.delete(:admin)
- confirm = !(attrs.delete(:confirm) =~ /(false|f|no|0)$/i)
- user = User.build_user(attrs)
- user.admin = admin unless admin.nil?
+
+ # Filter out params which are used later
+ identity_attrs = params.slice(:provider, :extern_uid)
+ confirm = params.delete(:confirm)
+
+ user = User.build_user(declared_params(include_missing: false))
user.skip_confirmation! unless confirm
- identity_attrs = attributes_for_keys [:provider, :extern_uid]
if identity_attrs.any?
user.identities.build(identity_attrs)
@@ -107,46 +111,40 @@ module API
end
end
- # Update user. Available only for admin
- #
- # Parameters:
- # email - Email
- # name - Name
- # password - Password
- # skype - Skype ID
- # linkedin - Linkedin
- # twitter - Twitter account
- # website_url - Website url
- # organization - Organization
- # projects_limit - Limit projects each user can create
- # bio - Bio
- # location - Location of the user
- # admin - User is admin - true or false (default)
- # can_create_group - User can create groups - true or false
- # external - Flags the user as external - true or false(default)
- # Example Request:
- # PUT /users/:id
+ desc 'Update a user. Available only for admins.' do
+ success Entities::UserFull
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ optional :email, type: String, desc: 'The email of the user'
+ optional :password, type: String, desc: 'The password of the new user'
+ 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!
- attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :external, :organization]
- user = User.find(params[:id])
+ user = User.find_by(id: params.delete(:id))
not_found!('User') unless user
- admin = attrs.delete(:admin)
- user.admin = admin unless admin.nil?
-
- conflict!('Email has already been taken') if attrs[:email] &&
- User.where(email: attrs[:email]).
+ conflict!('Email has already been taken') if params[:email] &&
+ User.where(email: params[:email]).
where.not(id: user.id).count > 0
- conflict!('Username has already been taken') if attrs[:username] &&
- User.where(username: attrs[:username]).
+ conflict!('Username has already been taken') if params[:username] &&
+ User.where(username: params[:username]).
where.not(id: user.id).count > 0
- identity_attrs = attributes_for_keys [:provider, :extern_uid]
+ identity_attrs = params.slice(:provider, :extern_uid)
+
if identity_attrs.any?
identity = user.identities.find_by(provider: identity_attrs[:provider])
+
if identity
identity.update_attributes(identity_attrs)
else
@@ -155,28 +153,33 @@ module API
end
end
- if user.update_attributes(attrs)
+ # Delete already handled parameters
+ params.delete(:extern_uid)
+ params.delete(:provider)
+
+ if user.update_attributes(declared_params(include_missing: false))
present user, with: Entities::UserFull
else
render_validation_error!(user)
end
end
- # Add ssh key to a specified user. Only available to admin users.
- #
- # Parameters:
- # id (required) - The ID of a user
- # key (required) - New SSH Key
- # title (required) - New SSH Key's title
- # Example Request:
- # POST /users/:id/keys
+ desc 'Add an SSH key to a specified user. Available only for admins.' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ requires :key, type: String, desc: 'The new SSH key'
+ requires :title, type: String, desc: 'The title of the new SSH key'
+ end
post ":id/keys" do
authenticated_as_admin!
- required_attributes! [:title, :key]
- user = User.find(params[:id])
- attrs = attributes_for_keys [:title, :key]
- key = user.keys.new attrs
+ user = User.find_by(id: params.delete(:id))
+ not_found!('User') unless user
+
+ key = user.keys.new(declared_params(include_missing: false))
+
if key.save
present key, with: Entities::SSHKey
else
@@ -184,55 +187,55 @@ module API
end
end
- # Get ssh keys of a specified user. Only available to admin users.
- #
- # Parameters:
- # uid (required) - The ID of a user
- # Example Request:
- # GET /users/:uid/keys
- get ':uid/keys' do
+ desc 'Get the SSH keys of a specified user. Available only for admins.' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
+ get ':id/keys' do
authenticated_as_admin!
- user = User.find_by(id: params[:uid])
+
+ user = User.find_by(id: params[:id])
not_found!('User') unless user
present user.keys, with: Entities::SSHKey
end
- # Delete existing ssh key of a specified user. Only available to admin
- # users.
- #
- # Parameters:
- # uid (required) - The ID of a user
- # id (required) - SSH Key ID
- # Example Request:
- # DELETE /users/:uid/keys/:id
- delete ':uid/keys/:id' do
+ desc 'Delete an existing SSH key from a specified user. Available only for admins.' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ requires :key_id, type: Integer, desc: 'The ID of the SSH key'
+ end
+ delete ':id/keys/:key_id' do
authenticated_as_admin!
- user = User.find_by(id: params[:uid])
+
+ user = User.find_by(id: params[:id])
not_found!('User') unless user
- begin
- key = user.keys.find params[:id]
- key.destroy
- rescue ActiveRecord::RecordNotFound
- not_found!('Key')
- end
+ key = user.keys.find_by(id: params[:key_id])
+ not_found!('Key') unless key
+
+ present key.destroy, with: Entities::SSHKey
end
- # Add email to a specified user. Only available to admin users.
- #
- # Parameters:
- # id (required) - The ID of a user
- # email (required) - Email address
- # Example Request:
- # POST /users/:id/emails
+ desc 'Add an email address to a specified user. Available only for admins.' do
+ success Entities::Email
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ requires :email, type: String, desc: 'The email of the user'
+ end
post ":id/emails" do
authenticated_as_admin!
- required_attributes! [:email]
- user = User.find(params[:id])
- attrs = attributes_for_keys [:email]
- email = user.emails.new attrs
+ user = User.find_by(id: params.delete(:id))
+ not_found!('User') unless user
+
+ email = user.emails.new(declared_params(include_missing: false))
+
if email.save
NotificationService.new.new_email(email)
present email, with: Entities::Email
@@ -241,98 +244,91 @@ module API
end
end
- # Get emails of a specified user. Only available to admin users.
- #
- # Parameters:
- # uid (required) - The ID of a user
- # Example Request:
- # GET /users/:uid/emails
- get ':uid/emails' do
+ desc 'Get the emails addresses of a specified user. Available only for admins.' do
+ success Entities::Email
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
+ get ':id/emails' do
authenticated_as_admin!
- user = User.find_by(id: params[:uid])
+ user = User.find_by(id: params[:id])
not_found!('User') unless user
present user.emails, with: Entities::Email
end
- # Delete existing email of a specified user. Only available to admin
- # users.
- #
- # Parameters:
- # uid (required) - The ID of a user
- # id (required) - Email ID
- # Example Request:
- # DELETE /users/:uid/emails/:id
- delete ':uid/emails/:id' do
+ desc 'Delete an email address of a specified user. Available only for admins.' do
+ success Entities::Email
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ requires :email_id, type: Integer, desc: 'The ID of the email'
+ end
+ delete ':id/emails/:email_id' do
authenticated_as_admin!
- user = User.find_by(id: params[:uid])
+ user = User.find_by(id: params[:id])
not_found!('User') unless user
- begin
- email = user.emails.find params[:id]
- email.destroy
+ email = user.emails.find_by(id: params[:email_id])
+ not_found!('Email') unless email
- user.update_secondary_emails!
- rescue ActiveRecord::RecordNotFound
- not_found!('Email')
- end
+ email.destroy
+ user.update_secondary_emails!
end
- # Delete user. Available only for admin
- #
- # Example Request:
- # DELETE /users/:id
+ desc 'Delete a user. Available only for admins.' do
+ success Entities::Email
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
delete ":id" do
authenticated_as_admin!
user = User.find_by(id: params[:id])
+ not_found!('User') unless user
- if user
- DeleteUserService.new(current_user).execute(user)
- else
- not_found!('User')
- end
+ DeleteUserService.new(current_user).execute(user)
end
- # Block user. Available only for admin
- #
- # Example Request:
- # PUT /users/:id/block
+ desc 'Block a user. Available only for admins.'
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
put ':id/block' do
authenticated_as_admin!
user = User.find_by(id: params[:id])
+ not_found!('User') unless user
- if !user
- not_found!('User')
- elsif !user.ldap_blocked?
+ if !user.ldap_blocked?
user.block
else
forbidden!('LDAP blocked users cannot be modified by the API')
end
end
- # Unblock user. Available only for admin
- #
- # Example Request:
- # PUT /users/:id/unblock
+ desc 'Unblock a user. Available only for admins.'
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
put ':id/unblock' do
authenticated_as_admin!
user = User.find_by(id: params[:id])
+ not_found!('User') unless user
- if !user
- not_found!('User')
- elsif user.ldap_blocked?
+ if user.ldap_blocked?
forbidden!('LDAP blocked users cannot be unblocked by the API')
else
user.activate
end
end
- desc 'Get contribution events of a specified user' 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
- requires :id, type: String, desc: 'The user ID'
+ requires :id, type: Integer, desc: 'The ID of the user'
end
get ':id/events' do
user = User.find_by(id: params[:id])
@@ -349,43 +345,43 @@ module API
end
resource :user do
- # Get currently authenticated user
- #
- # Example Request:
- # GET /user
+ desc 'Get the currently authenticated user' do
+ success Entities::UserFull
+ end
get do
- present @current_user, with: Entities::UserFull
+ present current_user, with: Entities::UserFull
end
- # Get currently authenticated user's keys
- #
- # Example Request:
- # GET /user/keys
+ desc "Get the currently authenticated user's SSH keys" do
+ success Entities::SSHKey
+ end
get "keys" do
present current_user.keys, with: Entities::SSHKey
end
- # Get single key owned by currently authenticated user
- #
- # Example Request:
- # GET /user/keys/:id
- get "keys/:id" do
- key = current_user.keys.find params[:id]
+ desc 'Get a single key owned by currently authenticated user' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :key_id, type: Integer, desc: 'The ID of the SSH key'
+ end
+ get "keys/:key_id" do
+ key = current_user.keys.find_by(id: params[:key_id])
+ not_found!('Key') unless key
+
present key, with: Entities::SSHKey
end
- # Add new ssh key to currently authenticated user
- #
- # Parameters:
- # key (required) - New SSH Key
- # title (required) - New SSH Key's title
- # Example Request:
- # POST /user/keys
+ desc 'Add a new SSH key to the currently authenticated user' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :key, type: String, desc: 'The new SSH key'
+ requires :title, type: String, desc: 'The title of the new SSH key'
+ end
post "keys" do
- required_attributes! [:title, :key]
+ key = current_user.keys.new(declared_params)
- attrs = attributes_for_keys [:title, :key]
- key = current_user.keys.new attrs
if key.save
present key, with: Entities::SSHKey
else
@@ -393,48 +389,48 @@ module API
end
end
- # Delete existing ssh key of currently authenticated user
- #
- # Parameters:
- # id (required) - SSH Key ID
- # Example Request:
- # DELETE /user/keys/:id
- delete "keys/:id" do
- begin
- key = current_user.keys.find params[:id]
- key.destroy
- rescue
- end
+ desc 'Delete an SSH key from the currently authenticated user' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :key_id, type: Integer, desc: 'The ID of the SSH key'
+ end
+ delete "keys/:key_id" do
+ key = current_user.keys.find_by(id: params[:key_id])
+ not_found!('Key') unless key
+
+ present key.destroy, with: Entities::SSHKey
end
- # Get currently authenticated user's emails
- #
- # Example Request:
- # GET /user/emails
+ desc "Get the currently authenticated user's email addresses" do
+ success Entities::Email
+ end
get "emails" do
present current_user.emails, with: Entities::Email
end
- # Get single email owned by currently authenticated user
- #
- # Example Request:
- # GET /user/emails/:id
- get "emails/:id" do
- email = current_user.emails.find params[:id]
+ desc 'Get a single email address owned by the currently authenticated user' do
+ success Entities::Email
+ end
+ params do
+ requires :email_id, type: Integer, desc: 'The ID of the email'
+ end
+ get "emails/:email_id" do
+ email = current_user.emails.find_by(id: params[:email_id])
+ not_found!('Email') unless email
+
present email, with: Entities::Email
end
- # Add new email to currently authenticated user
- #
- # Parameters:
- # email (required) - Email address
- # Example Request:
- # POST /user/emails
+ desc 'Add new email address to the currently authenticated user' do
+ success Entities::Email
+ end
+ params do
+ requires :email, type: String, desc: 'The new email'
+ end
post "emails" do
- required_attributes! [:email]
+ email = current_user.emails.new(declared_params)
- attrs = attributes_for_keys [:email]
- email = current_user.emails.new attrs
if email.save
NotificationService.new.new_email(email)
present email, with: Entities::Email
@@ -443,20 +439,16 @@ module API
end
end
- # Delete existing email of currently authenticated user
- #
- # Parameters:
- # id (required) - EMail ID
- # Example Request:
- # DELETE /user/emails/:id
- delete "emails/:id" do
- begin
- email = current_user.emails.find params[:id]
- email.destroy
+ desc 'Delete an email address from the currently authenticated user'
+ params do
+ requires :email_id, type: Integer, desc: 'The ID of the email'
+ end
+ delete "emails/:email_id" do
+ email = current_user.emails.find_by(id: params[:email_id])
+ not_found!('Email') unless email
- current_user.update_secondary_emails!
- rescue
- end
+ email.destroy
+ current_user.update_secondary_emails!
end
end
end
diff --git a/lib/ci/api/entities.rb b/lib/ci/api/entities.rb
index 66c05773b68..792ff628b09 100644
--- a/lib/ci/api/entities.rb
+++ b/lib/ci/api/entities.rb
@@ -32,6 +32,10 @@ module Ci
expose :artifacts_file, using: ArtifactFile, if: ->(build, _) { build.artifacts? }
end
+ class BuildCredentials < Grape::Entity
+ expose :type, :url, :username, :password
+ end
+
class BuildDetails < Build
expose :commands
expose :repo_url
@@ -50,6 +54,8 @@ module Ci
expose :variables
expose :depends_on_builds, using: Build
+
+ expose :credentials, using: BuildCredentials
end
class Runner < Grape::Entity
diff --git a/lib/gitlab/chat_commands/base_command.rb b/lib/gitlab/chat_commands/base_command.rb
new file mode 100644
index 00000000000..e59d69b72b9
--- /dev/null
+++ b/lib/gitlab/chat_commands/base_command.rb
@@ -0,0 +1,49 @@
+module Gitlab
+ module ChatCommands
+ class BaseCommand
+ QUERY_LIMIT = 5
+
+ def self.match(_text)
+ raise NotImplementedError
+ end
+
+ def self.help_message
+ raise NotImplementedError
+ end
+
+ def self.available?(_project)
+ raise NotImplementedError
+ end
+
+ def self.allowed?(_user, _ability)
+ true
+ end
+
+ def self.can?(object, action, subject)
+ Ability.allowed?(object, action, subject)
+ end
+
+ def execute(_)
+ raise NotImplementedError
+ end
+
+ def collection
+ raise NotImplementedError
+ end
+
+ attr_accessor :project, :current_user, :params
+
+ def initialize(project, user, params = {})
+ @project, @current_user, @params = project, user, params.dup
+ end
+
+ private
+
+ def find_by_iid(iid)
+ resource = collection.find_by(iid: iid)
+
+ readable?(resource) ? resource : nil
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb
new file mode 100644
index 00000000000..0ec358debc7
--- /dev/null
+++ b/lib/gitlab/chat_commands/command.rb
@@ -0,0 +1,62 @@
+module Gitlab
+ module ChatCommands
+ class Command < BaseCommand
+ COMMANDS = [
+ Gitlab::ChatCommands::IssueShow,
+ Gitlab::ChatCommands::IssueCreate,
+ Gitlab::ChatCommands::Deploy,
+ ].freeze
+
+ def execute
+ command, match = match_command
+
+ if command
+ if command.allowed?(project, current_user)
+ present command.new(project, current_user, params).execute(match)
+ else
+ access_denied
+ end
+ else
+ help(help_messages)
+ end
+ end
+
+ private
+
+ def match_command
+ match = nil
+ service = available_commands.find do |klass|
+ match = klass.match(command)
+ end
+
+ [service, match]
+ end
+
+ def help_messages
+ available_commands.map(&:help_message)
+ end
+
+ def available_commands
+ COMMANDS.select do |klass|
+ klass.available?(project)
+ end
+ end
+
+ def command
+ params[:text]
+ end
+
+ def help(messages)
+ Mattermost::Presenter.help(messages, params[:command])
+ end
+
+ def access_denied
+ Mattermost::Presenter.access_denied
+ end
+
+ def present(resource)
+ Mattermost::Presenter.present(resource)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/deploy.rb b/lib/gitlab/chat_commands/deploy.rb
new file mode 100644
index 00000000000..0eed1fce0dc
--- /dev/null
+++ b/lib/gitlab/chat_commands/deploy.rb
@@ -0,0 +1,57 @@
+module Gitlab
+ module ChatCommands
+ class Deploy < BaseCommand
+ include Gitlab::Routing.url_helpers
+
+ def self.match(text)
+ /\Adeploy\s+(?<from>.*)\s+to+\s+(?<to>.*)\z/.match(text)
+ end
+
+ def self.help_message
+ 'deploy <environment> to <target-environment>'
+ end
+
+ def self.available?(project)
+ project.builds_enabled?
+ end
+
+ def self.allowed?(project, user)
+ can?(user, :create_deployment, project)
+ end
+
+ def execute(match)
+ from = match[:from]
+ to = match[:to]
+
+ actions = find_actions(from, to)
+ return unless actions.present?
+
+ if actions.one?
+ play!(from, to, actions.first)
+ else
+ Result.new(:error, 'Too many actions defined')
+ end
+ end
+
+ private
+
+ def play!(from, to, action)
+ new_action = action.play(current_user)
+
+ Result.new(:success, "Deployment from #{from} to #{to} started. Follow the progress: #{url(new_action)}.")
+ end
+
+ def find_actions(from, to)
+ environment = project.environments.find_by(name: from)
+ return unless environment
+
+ environment.actions_for(to).select(&:starts_environment?)
+ end
+
+ def url(subject)
+ polymorphic_url(
+ [ subject.project.namespace.becomes(Namespace), subject.project, subject ])
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/issue_command.rb b/lib/gitlab/chat_commands/issue_command.rb
new file mode 100644
index 00000000000..f1bc36239d5
--- /dev/null
+++ b/lib/gitlab/chat_commands/issue_command.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module ChatCommands
+ class IssueCommand < BaseCommand
+ def self.available?(project)
+ project.issues_enabled? && project.default_issues_tracker?
+ end
+
+ def collection
+ project.issues
+ end
+
+ def readable?(issue)
+ self.class.can?(current_user, :read_issue, issue)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/issue_create.rb b/lib/gitlab/chat_commands/issue_create.rb
new file mode 100644
index 00000000000..98338ebfa27
--- /dev/null
+++ b/lib/gitlab/chat_commands/issue_create.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module ChatCommands
+ class IssueCreate < IssueCommand
+ def self.match(text)
+ /\Aissue\s+create\s+(?<title>[^\n]*)\n*(?<description>.*)\z/.match(text)
+ end
+
+ def self.help_message
+ 'issue create <title>\n<description>'
+ end
+
+ def self.allowed?(project, user)
+ can?(user, :create_issue, project)
+ end
+
+ def execute(match)
+ title = match[:title]
+ description = match[:description]
+
+ Issues::CreateService.new(project, current_user, title: title, description: description).execute
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/issue_show.rb b/lib/gitlab/chat_commands/issue_show.rb
new file mode 100644
index 00000000000..f5bceb038e5
--- /dev/null
+++ b/lib/gitlab/chat_commands/issue_show.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module ChatCommands
+ class IssueShow < IssueCommand
+ def self.match(text)
+ /\Aissue\s+show\s+(?<iid>\d+)/.match(text)
+ end
+
+ def self.help_message
+ "issue show <id>"
+ end
+
+ def execute(match)
+ find_by_iid(match[:iid])
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/result.rb b/lib/gitlab/chat_commands/result.rb
new file mode 100644
index 00000000000..324d7ef43a3
--- /dev/null
+++ b/lib/gitlab/chat_commands/result.rb
@@ -0,0 +1,5 @@
+module Gitlab
+ module ChatCommands
+ Result = Struct.new(:type, :message)
+ end
+end
diff --git a/lib/gitlab/chat_name_token.rb b/lib/gitlab/chat_name_token.rb
new file mode 100644
index 00000000000..1b081aa9b1d
--- /dev/null
+++ b/lib/gitlab/chat_name_token.rb
@@ -0,0 +1,45 @@
+require 'json'
+
+module Gitlab
+ class ChatNameToken
+ attr_reader :token
+
+ TOKEN_LENGTH = 50
+ EXPIRY_TIME = 10.minutes
+
+ def initialize(token = new_token)
+ @token = token
+ end
+
+ def get
+ Gitlab::Redis.with do |redis|
+ data = redis.get(redis_key)
+ JSON.parse(data, symbolize_names: true) if data
+ end
+ end
+
+ def store!(params)
+ Gitlab::Redis.with do |redis|
+ params = params.to_json
+ redis.set(redis_key, params, ex: EXPIRY_TIME)
+ token
+ end
+ end
+
+ def delete
+ Gitlab::Redis.with do |redis|
+ redis.del(redis_key)
+ end
+ end
+
+ private
+
+ def new_token
+ Devise.friendly_token(TOKEN_LENGTH)
+ end
+
+ def redis_key
+ "gitlab:chat_names:#{token}"
+ end
+ end
+end
diff --git a/lib/gitlab/ci/build/credentials/base.rb b/lib/gitlab/ci/build/credentials/base.rb
new file mode 100644
index 00000000000..29a7a27c963
--- /dev/null
+++ b/lib/gitlab/ci/build/credentials/base.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ module Ci
+ module Build
+ module Credentials
+ class Base
+ def type
+ self.class.name.demodulize.underscore
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/build/credentials/factory.rb b/lib/gitlab/ci/build/credentials/factory.rb
new file mode 100644
index 00000000000..2423aa8857d
--- /dev/null
+++ b/lib/gitlab/ci/build/credentials/factory.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module Ci
+ module Build
+ module Credentials
+ class Factory
+ def initialize(build)
+ @build = build
+ end
+
+ def create!
+ credentials.select(&:valid?)
+ end
+
+ private
+
+ def credentials
+ providers.map { |provider| provider.new(@build) }
+ end
+
+ def providers
+ [Registry]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/build/credentials/registry.rb b/lib/gitlab/ci/build/credentials/registry.rb
new file mode 100644
index 00000000000..55eafcaed10
--- /dev/null
+++ b/lib/gitlab/ci/build/credentials/registry.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module Ci
+ module Build
+ module Credentials
+ class Registry < Base
+ attr_reader :username, :password
+
+ def initialize(build)
+ @username = 'gitlab-ci-token'
+ @password = build.token
+ end
+
+ def url
+ Gitlab.config.registry.host_port
+ end
+
+ def valid?
+ Gitlab.config.registry.enabled
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb
index 06599238d22..f7ff7ea212e 100644
--- a/lib/gitlab/ci/config.rb
+++ b/lib/gitlab/ci/config.rb
@@ -4,12 +4,6 @@ module Gitlab
# Base GitLab CI Configuration facade
#
class Config
- ##
- # Temporary delegations that should be removed after refactoring
- #
- delegate :before_script, :image, :services, :after_script, :variables,
- :stages, :cache, :jobs, to: :@global
-
def initialize(config)
@config = Loader.new(config).load!
@@ -28,6 +22,41 @@ module Gitlab
def to_hash
@config
end
+
+ ##
+ # Temporary method that should be removed after refactoring
+ #
+ def before_script
+ @global.before_script_value
+ end
+
+ def image
+ @global.image_value
+ end
+
+ def services
+ @global.services_value
+ end
+
+ def after_script
+ @global.after_script_value
+ end
+
+ def variables
+ @global.variables_value
+ end
+
+ def stages
+ @global.stages_value
+ end
+
+ def cache
+ @global.cache_value
+ end
+
+ def jobs
+ @global.jobs_value
+ end
end
end
end
diff --git a/lib/gitlab/ci/config/entry/configurable.rb b/lib/gitlab/ci/config/entry/configurable.rb
index 0f438faeda2..833ae4a0ff3 100644
--- a/lib/gitlab/ci/config/entry/configurable.rb
+++ b/lib/gitlab/ci/config/entry/configurable.rb
@@ -66,8 +66,6 @@ module Gitlab
@entries[symbol].value
end
-
- alias_method symbol.to_sym, "#{symbol}_value".to_sym
end
end
end
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index ab4ef333629..20dcc024b4e 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -13,12 +13,10 @@ module Gitlab
type stage when artifacts cache dependencies before_script
after_script variables environment]
- attributes :tags, :allow_failure, :when, :dependencies
-
validations do
validates :config, allowed_keys: ALLOWED_KEYS
-
validates :config, presence: true
+ validates :script, presence: true
validates :name, presence: true
validates :name, type: Symbol
@@ -77,6 +75,8 @@ module Gitlab
:cache, :image, :services, :only, :except, :variables,
:artifacts, :commands, :environment
+ attributes :script, :tags, :allow_failure, :when, :dependencies
+
def compose!(deps = nil)
super do
if type_defined? && !stage_defined?
@@ -118,20 +118,20 @@ module Gitlab
def to_hash
{ name: name,
- before_script: before_script,
- script: script,
+ before_script: before_script_value,
+ script: script_value,
commands: commands,
- image: image,
- services: services,
- stage: stage,
- cache: cache,
- only: only,
- except: except,
- variables: variables_defined? ? variables : nil,
- environment: environment_defined? ? environment : nil,
- environment_name: environment_defined? ? environment[:name] : nil,
- artifacts: artifacts,
- after_script: after_script }
+ image: image_value,
+ services: services_value,
+ stage: stage_value,
+ cache: cache_value,
+ only: only_value,
+ except: except_value,
+ variables: variables_defined? ? variables_value : nil,
+ environment: environment_defined? ? environment_value : nil,
+ environment_name: environment_defined? ? environment_value[:name] : nil,
+ artifacts: artifacts_value,
+ after_script: after_script_value }
end
end
end
diff --git a/lib/gitlab/cycle_analytics/base_event.rb b/lib/gitlab/cycle_analytics/base_event.rb
new file mode 100644
index 00000000000..486139b1687
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/base_event.rb
@@ -0,0 +1,57 @@
+module Gitlab
+ module CycleAnalytics
+ class BaseEvent
+ include MetricsTables
+
+ attr_reader :stage, :start_time_attrs, :end_time_attrs, :projections, :query
+
+ def initialize(project:, options:)
+ @query = EventsQuery.new(project: project, options: options)
+ @project = project
+ @options = options
+ end
+
+ def fetch
+ update_author!
+
+ event_result.map do |event|
+ serialize(event) if has_permission?(event['id'])
+ end
+ end
+
+ def custom_query(_base_query); end
+
+ def order
+ @order || @start_time_attrs
+ end
+
+ private
+
+ def update_author!
+ return unless event_result.any? && event_result.first['author_id']
+
+ Updater.update!(event_result, from: 'author_id', to: 'author', klass: User)
+ end
+
+ def event_result
+ @event_result ||= @query.execute(self).to_a
+ end
+
+ def serialize(_event)
+ raise NotImplementedError.new("Expected #{self.name} to implement serialize(event)")
+ end
+
+ def has_permission?(id)
+ allowed_ids.nil? || allowed_ids.include?(id.to_i)
+ end
+
+ def allowed_ids
+ nil
+ end
+
+ def event_result_ids
+ event_result.map { |event| event['id'] }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/code_event.rb b/lib/gitlab/cycle_analytics/code_event.rb
new file mode 100644
index 00000000000..2afdf0b8518
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/code_event.rb
@@ -0,0 +1,28 @@
+module Gitlab
+ module CycleAnalytics
+ class CodeEvent < BaseEvent
+ include MergeRequestAllowed
+
+ def initialize(*args)
+ @stage = :code
+ @start_time_attrs = issue_metrics_table[:first_mentioned_in_commit_at]
+ @end_time_attrs = mr_table[:created_at]
+ @projections = [mr_table[:title],
+ mr_table[:iid],
+ mr_table[:id],
+ mr_table[:created_at],
+ mr_table[:state],
+ mr_table[:author_id]]
+ @order = mr_table[:created_at]
+
+ super(*args)
+ end
+
+ private
+
+ def serialize(event)
+ AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/events.rb b/lib/gitlab/cycle_analytics/events.rb
new file mode 100644
index 00000000000..2d703d76cbb
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/events.rb
@@ -0,0 +1,38 @@
+module Gitlab
+ module CycleAnalytics
+ class Events
+ def initialize(project:, options:)
+ @project = project
+ @options = options
+ end
+
+ def issue_events
+ IssueEvent.new(project: @project, options: @options).fetch
+ end
+
+ def plan_events
+ PlanEvent.new(project: @project, options: @options).fetch
+ end
+
+ def code_events
+ CodeEvent.new(project: @project, options: @options).fetch
+ end
+
+ def test_events
+ TestEvent.new(project: @project, options: @options).fetch
+ end
+
+ def review_events
+ ReviewEvent.new(project: @project, options: @options).fetch
+ end
+
+ def staging_events
+ StagingEvent.new(project: @project, options: @options).fetch
+ end
+
+ def production_events
+ ProductionEvent.new(project: @project, options: @options).fetch
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/events_query.rb b/lib/gitlab/cycle_analytics/events_query.rb
new file mode 100644
index 00000000000..2418832ccc2
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/events_query.rb
@@ -0,0 +1,37 @@
+module Gitlab
+ module CycleAnalytics
+ class EventsQuery
+ attr_reader :project
+
+ def initialize(project:, options: {})
+ @project = project
+ @from = options[:from]
+ @branch = options[:branch]
+ @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: @from, branch: @branch)
+ end
+
+ def execute(stage_class)
+ @stage_class = stage_class
+
+ ActiveRecord::Base.connection.exec_query(query.to_sql)
+ end
+
+ private
+
+ def query
+ base_query = @fetcher.base_query_for(@stage_class.stage)
+ diff_fn = @fetcher.subtract_datetimes_diff(base_query, @stage_class.start_time_attrs, @stage_class.end_time_attrs)
+
+ @stage_class.custom_query(base_query)
+
+ base_query.project(extract_epoch(diff_fn).as('total_time'), *@stage_class.projections).order(@stage_class.order.desc)
+ end
+
+ def extract_epoch(arel_attribute)
+ return arel_attribute unless Gitlab::Database.postgresql?
+
+ Arel.sql(%Q{EXTRACT(EPOCH FROM (#{arel_attribute.to_sql}))})
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/issue_allowed.rb b/lib/gitlab/cycle_analytics/issue_allowed.rb
new file mode 100644
index 00000000000..a7652a70641
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/issue_allowed.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ module CycleAnalytics
+ module IssueAllowed
+ def allowed_ids
+ @allowed_ids ||= IssuesFinder.new(@options[:current_user], project_id: @project.id).execute.where(id: event_result_ids).pluck(:id)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/issue_event.rb b/lib/gitlab/cycle_analytics/issue_event.rb
new file mode 100644
index 00000000000..705b7e5ce24
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/issue_event.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module CycleAnalytics
+ class IssueEvent < BaseEvent
+ include IssueAllowed
+
+ def initialize(*args)
+ @stage = :issue
+ @start_time_attrs = issue_table[:created_at]
+ @end_time_attrs = [issue_metrics_table[:first_associated_with_milestone_at],
+ issue_metrics_table[:first_added_to_board_at]]
+ @projections = [issue_table[:title],
+ issue_table[:iid],
+ issue_table[:id],
+ issue_table[:created_at],
+ issue_table[:author_id]]
+
+ super(*args)
+ end
+
+ private
+
+ def serialize(event)
+ AnalyticsIssueSerializer.new(project: @project).represent(event).as_json
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/merge_request_allowed.rb b/lib/gitlab/cycle_analytics/merge_request_allowed.rb
new file mode 100644
index 00000000000..28f6db44759
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/merge_request_allowed.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ module CycleAnalytics
+ module MergeRequestAllowed
+ def allowed_ids
+ @allowed_ids ||= MergeRequestsFinder.new(@options[:current_user], project_id: @project.id).execute.where(id: event_result_ids).pluck(:id)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/metrics_fetcher.rb b/lib/gitlab/cycle_analytics/metrics_fetcher.rb
new file mode 100644
index 00000000000..b71e8735e27
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/metrics_fetcher.rb
@@ -0,0 +1,60 @@
+module Gitlab
+ module CycleAnalytics
+ class MetricsFetcher
+ include Gitlab::Database::Median
+ include Gitlab::Database::DateTime
+ include MetricsTables
+
+ DEPLOYMENT_METRIC_STAGES = %i[production staging]
+
+ def initialize(project:, from:, branch:)
+ @project = project
+ @project = project
+ @from = from
+ @branch = branch
+ end
+
+ def calculate_metric(name, start_time_attrs, end_time_attrs)
+ cte_table = Arel::Table.new("cte_table_for_#{name}")
+
+ # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time).
+ # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time).
+ # We compute the (end_time - start_time) interval, and give it an alias based on the current
+ # cycle analytics stage.
+ interval_query = Arel::Nodes::As.new(
+ cte_table,
+ subtract_datetimes(base_query_for(name), start_time_attrs, end_time_attrs, name.to_s))
+
+ median_datetime(cte_table, interval_query, name)
+ end
+
+ # Join table with a row for every <issue,merge_request> pair (where the merge request
+ # closes the given issue) with issue and merge request metrics included. The metrics
+ # are loaded with an inner join, so issues / merge requests without metrics are
+ # automatically excluded.
+ def base_query_for(name)
+ # Load issues
+ query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id])).
+ join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id])).
+ where(issue_table[:project_id].eq(@project.id)).
+ where(issue_table[:deleted_at].eq(nil)).
+ where(issue_table[:created_at].gteq(@from))
+
+ query = query.where(build_table[:ref].eq(@branch)) if name == :test && @branch
+
+ # Load merge_requests
+ query = query.join(mr_table, Arel::Nodes::OuterJoin).
+ on(mr_table[:id].eq(mr_closing_issues_table[:merge_request_id])).
+ join(mr_metrics_table).
+ on(mr_table[:id].eq(mr_metrics_table[:merge_request_id]))
+
+ if DEPLOYMENT_METRIC_STAGES.include?(name)
+ # Limit to merge requests that have been deployed to production after `@from`
+ query.where(mr_metrics_table[:first_deployed_to_production_at].gteq(@from))
+ end
+
+ query
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/metrics_tables.rb b/lib/gitlab/cycle_analytics/metrics_tables.rb
new file mode 100644
index 00000000000..9d25ef078e8
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/metrics_tables.rb
@@ -0,0 +1,37 @@
+module Gitlab
+ module CycleAnalytics
+ module MetricsTables
+ def mr_metrics_table
+ MergeRequest::Metrics.arel_table
+ end
+
+ def mr_table
+ MergeRequest.arel_table
+ end
+
+ def mr_diff_table
+ MergeRequestDiff.arel_table
+ end
+
+ def mr_closing_issues_table
+ MergeRequestsClosingIssues.arel_table
+ end
+
+ def issue_table
+ Issue.arel_table
+ end
+
+ def issue_metrics_table
+ Issue::Metrics.arel_table
+ end
+
+ def user_table
+ User.arel_table
+ end
+
+ def build_table
+ ::CommitStatus.arel_table
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/permissions.rb b/lib/gitlab/cycle_analytics/permissions.rb
new file mode 100644
index 00000000000..bef3b95ff1b
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/permissions.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ module CycleAnalytics
+ class Permissions
+ STAGE_PERMISSIONS = {
+ issue: :read_issue,
+ code: :read_merge_request,
+ test: :read_build,
+ review: :read_merge_request,
+ staging: :read_build,
+ production: :read_issue,
+ }.freeze
+
+ def self.get(*args)
+ new(*args).get
+ end
+
+ def initialize(user:, project:)
+ @user = user
+ @project = project
+ @stage_permission_hash = {}
+ end
+
+ def get
+ ::CycleAnalytics::STAGES.each do |stage|
+ @stage_permission_hash[stage] = authorized_stage?(stage)
+ end
+
+ @stage_permission_hash
+ end
+
+ private
+
+ def authorized_stage?(stage)
+ return false unless authorize_project(:read_cycle_analytics)
+
+ STAGE_PERMISSIONS[stage] ? authorize_project(STAGE_PERMISSIONS[stage]) : true
+ end
+
+ def authorize_project(permission)
+ Ability.allowed?(@user, permission, @project)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/plan_event.rb b/lib/gitlab/cycle_analytics/plan_event.rb
new file mode 100644
index 00000000000..b1ae215f348
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/plan_event.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ module CycleAnalytics
+ class PlanEvent < BaseEvent
+ def initialize(*args)
+ @stage = :plan
+ @start_time_attrs = issue_metrics_table[:first_associated_with_milestone_at]
+ @end_time_attrs = [issue_metrics_table[:first_added_to_board_at],
+ issue_metrics_table[:first_mentioned_in_commit_at]]
+ @projections = [mr_diff_table[:st_commits].as('commits'),
+ issue_metrics_table[:first_mentioned_in_commit_at]]
+
+ super(*args)
+ end
+
+ def custom_query(base_query)
+ base_query.join(mr_diff_table).on(mr_diff_table[:merge_request_id].eq(mr_table[:id]))
+ end
+
+ private
+
+ def serialize(event)
+ st_commit = first_time_reference_commit(event.delete('commits'), event)
+
+ return unless st_commit
+
+ serialize_commit(event, st_commit, query)
+ end
+
+ def first_time_reference_commit(commits, event)
+ YAML.load(commits).find do |commit|
+ next unless commit[:committed_date] && event['first_mentioned_in_commit_at']
+
+ commit[:committed_date].to_i == DateTime.parse(event['first_mentioned_in_commit_at'].to_s).to_i
+ end
+ end
+
+ def serialize_commit(event, st_commit, query)
+ commit = Commit.new(Gitlab::Git::Commit.new(st_commit), @project)
+
+ AnalyticsCommitSerializer.new(project: @project, total_time: event['total_time']).represent(commit).as_json
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/production_event.rb b/lib/gitlab/cycle_analytics/production_event.rb
new file mode 100644
index 00000000000..4868c3c6237
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/production_event.rb
@@ -0,0 +1,26 @@
+module Gitlab
+ module CycleAnalytics
+ class ProductionEvent < BaseEvent
+ include IssueAllowed
+
+ def initialize(*args)
+ @stage = :production
+ @start_time_attrs = issue_table[:created_at]
+ @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at]
+ @projections = [issue_table[:title],
+ issue_table[:iid],
+ issue_table[:id],
+ issue_table[:created_at],
+ issue_table[:author_id]]
+
+ super(*args)
+ end
+
+ private
+
+ def serialize(event)
+ AnalyticsIssueSerializer.new(project: @project).represent(event).as_json
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/review_event.rb b/lib/gitlab/cycle_analytics/review_event.rb
new file mode 100644
index 00000000000..b394a02cc52
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/review_event.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module CycleAnalytics
+ class ReviewEvent < BaseEvent
+ include MergeRequestAllowed
+
+ def initialize(*args)
+ @stage = :review
+ @start_time_attrs = mr_table[:created_at]
+ @end_time_attrs = mr_metrics_table[:merged_at]
+ @projections = [mr_table[:title],
+ mr_table[:iid],
+ mr_table[:id],
+ mr_table[:created_at],
+ mr_table[:state],
+ mr_table[:author_id]]
+
+ super(*args)
+ end
+
+ def serialize(event)
+ AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/staging_event.rb b/lib/gitlab/cycle_analytics/staging_event.rb
new file mode 100644
index 00000000000..a1f30b716f6
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/staging_event.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module CycleAnalytics
+ class StagingEvent < BaseEvent
+ def initialize(*args)
+ @stage = :staging
+ @start_time_attrs = mr_metrics_table[:merged_at]
+ @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at]
+ @projections = [build_table[:id]]
+ @order = build_table[:created_at]
+
+ super(*args)
+ end
+
+ def fetch
+ Updater.update!(event_result, from: 'id', to: 'build', klass: ::Ci::Build)
+
+ super
+ end
+
+ def custom_query(base_query)
+ base_query.join(build_table).on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id]))
+ end
+
+ private
+
+ def serialize(event)
+ AnalyticsBuildSerializer.new.represent(event['build']).as_json
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/test_event.rb b/lib/gitlab/cycle_analytics/test_event.rb
new file mode 100644
index 00000000000..d553d0b5aec
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/test_event.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ module CycleAnalytics
+ class TestEvent < StagingEvent
+ def initialize(*args)
+ super(*args)
+
+ @stage = :test
+ @start_time_attrs = mr_metrics_table[:latest_build_started_at]
+ @end_time_attrs = mr_metrics_table[:latest_build_finished_at]
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/updater.rb b/lib/gitlab/cycle_analytics/updater.rb
new file mode 100644
index 00000000000..953268ebd46
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/updater.rb
@@ -0,0 +1,30 @@
+module Gitlab
+ module CycleAnalytics
+ class Updater
+ def self.update!(*args)
+ new(*args).update!
+ end
+
+ def initialize(event_result, from:, to:, klass:)
+ @event_result = event_result
+ @klass = klass
+ @from = from
+ @to = to
+ end
+
+ def update!
+ @event_result.each do |event|
+ event[@to] = items[event.delete(@from).to_i].first
+ end
+ end
+
+ def result_ids
+ @event_result.map { |event| event[@from] }
+ end
+
+ def items
+ @items ||= @klass.find(result_ids).group_by { |item| item['id'] }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb
index 55b8f888d53..2d5c9232425 100644
--- a/lib/gitlab/database.rb
+++ b/lib/gitlab/database.rb
@@ -35,6 +35,13 @@ module Gitlab
order
end
+ def self.serialized_transaction
+ opts = {}
+ opts[:isolation] = :serializable unless Rails.env.test? && connection.transaction_open?
+
+ connection.transaction(opts) { yield }
+ end
+
def self.random
Gitlab::Database.postgresql? ? "RANDOM()" : "RAND()"
end
diff --git a/lib/gitlab/database/date_time.rb b/lib/gitlab/database/date_time.rb
index b6a89f715fd..25e56998038 100644
--- a/lib/gitlab/database/date_time.rb
+++ b/lib/gitlab/database/date_time.rb
@@ -7,21 +7,25 @@ module Gitlab
#
# Note: For MySQL, the interval is returned in seconds.
# For PostgreSQL, the interval is returned as an INTERVAL type.
- def subtract_datetimes(query_so_far, end_time_attrs, start_time_attrs, as)
- diff_fn = if Gitlab::Database.postgresql?
- Arel::Nodes::Subtraction.new(
- Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs)),
- Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)))
- elsif Gitlab::Database.mysql?
- Arel::Nodes::NamedFunction.new(
- "TIMESTAMPDIFF",
- [Arel.sql('second'),
- Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)),
- Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs))])
- end
+ def subtract_datetimes(query_so_far, start_time_attrs, end_time_attrs, as)
+ diff_fn = subtract_datetimes_diff(query_so_far, start_time_attrs, end_time_attrs)
query_so_far.project(diff_fn.as(as))
end
+
+ def subtract_datetimes_diff(query_so_far, start_time_attrs, end_time_attrs)
+ if Gitlab::Database.postgresql?
+ Arel::Nodes::Subtraction.new(
+ Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs)),
+ Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)))
+ elsif Gitlab::Database.mysql?
+ Arel::Nodes::NamedFunction.new(
+ "TIMESTAMPDIFF",
+ [Arel.sql('second'),
+ Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)),
+ Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs))])
+ end
+ end
end
end
end
diff --git a/lib/gitlab/email/html_parser.rb b/lib/gitlab/email/html_parser.rb
new file mode 100644
index 00000000000..a4ca62bfc41
--- /dev/null
+++ b/lib/gitlab/email/html_parser.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module Email
+ class HTMLParser
+ def self.parse_reply(raw_body)
+ new(raw_body).filtered_text
+ end
+
+ attr_reader :raw_body
+ def initialize(raw_body)
+ @raw_body = raw_body
+ end
+
+ def document
+ @document ||= Nokogiri::HTML.parse(raw_body)
+ end
+
+ def filter_replies!
+ document.xpath('//blockquote').each(&:remove)
+ document.xpath('//table').each(&:remove)
+ end
+
+ def filtered_html
+ @filtered_html ||= begin
+ filter_replies!
+ document.inner_html
+ end
+ end
+
+ def filtered_text
+ @filtered_text ||= Html2Text.convert(filtered_html)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/email/reply_parser.rb b/lib/gitlab/email/reply_parser.rb
index 3411eb1d9ce..85402c2a278 100644
--- a/lib/gitlab/email/reply_parser.rb
+++ b/lib/gitlab/email/reply_parser.rb
@@ -23,19 +23,26 @@ module Gitlab
private
def select_body(message)
- text = message.text_part if message.multipart?
- text ||= message if message.content_type !~ /text\/html/
+ if message.multipart?
+ part = message.text_part || message.html_part || message
+ else
+ part = message
+ end
- return "" unless text
+ decoded = fix_charset(part)
- text = fix_charset(text)
+ return "" unless decoded
# Certain trigger phrases that means we didn't parse correctly
- if text =~ /(Content\-Type\:|multipart\/alternative|text\/plain)/
+ if decoded =~ /(Content\-Type\:|multipart\/alternative|text\/plain)/
return ""
end
- text
+ if (part.content_type || '').include? 'text/html'
+ HTMLParser.parse_reply(decoded)
+ else
+ decoded
+ end
end
# Force encoding to UTF-8 on a Mail::Message or Mail::Part
diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb
new file mode 100644
index 00000000000..1d93a67dc56
--- /dev/null
+++ b/lib/gitlab/file_detector.rb
@@ -0,0 +1,63 @@
+require 'set'
+
+module Gitlab
+ # Module that can be used to detect if a path points to a special file such as
+ # a README or a CONTRIBUTING file.
+ module FileDetector
+ PATTERNS = {
+ readme: /\Areadme/i,
+ changelog: /\A(changelog|history|changes|news)/i,
+ license: /\A(licen[sc]e|copying)(\..+|\z)/i,
+ contributing: /\Acontributing/i,
+ version: 'version',
+ gitignore: '.gitignore',
+ koding: '.koding.yml',
+ gitlab_ci: '.gitlab-ci.yml',
+ avatar: /\Alogo\.(png|jpg|gif)\z/
+ }
+
+ # Returns an Array of file types based on the given paths.
+ #
+ # This method can be used to check if a list of file paths (e.g. of changed
+ # files) involve any special files such as a README or a LICENSE file.
+ #
+ # Example:
+ #
+ # types_in_paths(%w{README.md foo/bar.txt}) # => [:readme]
+ def self.types_in_paths(paths)
+ types = Set.new
+
+ paths.each do |path|
+ type = type_of(path)
+
+ types << type if type
+ end
+
+ types.to_a
+ end
+
+ # Returns the type of a file path, or nil if none could be detected.
+ #
+ # Returned types are Symbols such as `:readme`, `:version`, etc.
+ #
+ # Example:
+ #
+ # type_of('README.md') # => :readme
+ # type_of('VERSION') # => :version
+ def self.type_of(path)
+ name = File.basename(path)
+
+ PATTERNS.each do |type, search|
+ did_match = if search.is_a?(Regexp)
+ name =~ search
+ else
+ name.casecmp(search) == 0
+ end
+
+ return type if did_match
+ end
+
+ nil
+ end
+ end
+end
diff --git a/lib/gitlab/github_import/importer.rb b/lib/gitlab/github_import/importer.rb
index 90cf38a8513..281b65bdeba 100644
--- a/lib/gitlab/github_import/importer.rb
+++ b/lib/gitlab/github_import/importer.rb
@@ -20,10 +20,18 @@ module Gitlab
end
def execute
+ # The ordering of importing is important here due to the way GitHub structures their data
+ # 1. Labels are required by other items while not having a dependency on anything else
+ # so need to be first
+ # 2. Pull requests must come before issues. Every pull request is also an issue but not
+ # all issues are pull requests. Only the issue entity has labels defined in GitHub. GitLab
+ # doesn't structure data like this so we need to make sure that we've created the MRs
+ # before we attempt to add the labels defined in the GitHub issue for the related, already
+ # imported, pull request
import_labels
import_milestones
- import_issues
import_pull_requests
+ import_issues
import_comments(:issues)
import_comments(:pull_requests)
import_wiki
@@ -79,13 +87,17 @@ module Gitlab
issues.each do |raw|
gh_issue = IssueFormatter.new(project, raw)
- if gh_issue.valid?
- begin
- issue = gh_issue.create!
- apply_labels(issue, raw)
- rescue => e
- errors << { type: :issue, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message }
- end
+ begin
+ issuable =
+ if gh_issue.pull_request?
+ MergeRequest.find_by_iid(gh_issue.number)
+ else
+ gh_issue.create!
+ end
+
+ apply_labels(issuable, raw)
+ rescue => e
+ errors << { type: :issue, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message }
end
end
end
@@ -101,8 +113,7 @@ module Gitlab
restore_source_branch(pull_request) unless pull_request.source_branch_exists?
restore_target_branch(pull_request) unless pull_request.target_branch_exists?
- merge_request = pull_request.create!
- apply_labels(merge_request, raw)
+ pull_request.create!
rescue => e
errors << { type: :pull_request, url: Gitlab::UrlSanitizer.sanitize(pull_request.url), errors: e.message }
ensure
@@ -133,21 +144,14 @@ module Gitlab
remove_branch(pull_request.target_branch_name) unless pull_request.target_branch_exists?
end
- def apply_labels(issuable, raw_issuable)
- # GH returns labels for issues but not for pull requests!
- labels = if issuable.is_a?(MergeRequest)
- client.labels_for_issue(repo, raw_issuable.number)
- else
- raw_issuable.labels
- end
+ def apply_labels(issuable, raw)
+ return unless raw.labels.count > 0
- if labels.count > 0
- label_ids = labels
- .map { |attrs| @labels[attrs.name] }
- .compact
+ label_ids = raw.labels
+ .map { |attrs| @labels[attrs.name] }
+ .compact
- issuable.update_attribute(:label_ids, label_ids)
- end
+ issuable.update_attribute(:label_ids, label_ids)
end
def import_comments(issuable_type)
diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb
index 8c32ac59fc5..887690bcc7c 100644
--- a/lib/gitlab/github_import/issue_formatter.rb
+++ b/lib/gitlab/github_import/issue_formatter.rb
@@ -32,8 +32,8 @@ module Gitlab
raw_data.number
end
- def valid?
- raw_data.pull_request.nil?
+ def pull_request?
+ raw_data.pull_request.present?
end
private
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index 155ca47e04c..c12358ceef4 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -2,7 +2,14 @@ module Gitlab
module Regex
extend self
- NAMESPACE_REGEX_STR = '(?:[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*[a-zA-Z0-9_\-]|[a-zA-Z0-9_])(?<!\.git|\.atom)'.freeze
+ # The namespace regex is used in Javascript to validate usernames in the "Register" form. However, Javascript
+ # does not support the negative lookbehind assertion (?<!) that disallows usernames ending in `.git` and `.atom`.
+ # Since this is a non-trivial problem to solve in Javascript (heavily complicate the regex, modify view code to
+ # allow non-regex validatiions, etc), `NAMESPACE_REGEX_STR_SIMPLE` serves as a Javascript-compatible version of
+ # `NAMESPACE_REGEX_STR`, with the negative lookbehind assertion removed. This means that the client-side validation
+ # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation.
+ NAMESPACE_REGEX_STR_SIMPLE = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze
+ NAMESPACE_REGEX_STR = '(?:' + NAMESPACE_REGEX_STR_SIMPLE + ')(?<!\.git|\.atom)'.freeze
def namespace_regex
@namespace_regex ||= /\A#{NAMESPACE_REGEX_STR}\z/.freeze
diff --git a/lib/mattermost/presenter.rb b/lib/mattermost/presenter.rb
new file mode 100644
index 00000000000..67eda983a74
--- /dev/null
+++ b/lib/mattermost/presenter.rb
@@ -0,0 +1,131 @@
+module Mattermost
+ class Presenter
+ class << self
+ include Gitlab::Routing.url_helpers
+
+ def authorize_chat_name(url)
+ message = if url
+ ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{url})."
+ else
+ ":sweat_smile: Couldn't identify you, nor can I autorize you!"
+ end
+
+ ephemeral_response(message)
+ end
+
+ def help(commands, trigger)
+ if commands.none?
+ ephemeral_response("No commands configured")
+ else
+ commands.map! { |command| "#{trigger} #{command}" }
+ message = header_with_list("Available commands", commands)
+
+ ephemeral_response(message)
+ end
+ end
+
+ def present(subject)
+ return not_found unless subject
+
+ if subject.is_a?(Gitlab::ChatCommands::Result)
+ show_result(subject)
+ elsif subject.respond_to?(:count)
+ if subject.many?
+ multiple_resources(subject)
+ elsif subject.none?
+ not_found
+ else
+ single_resource(subject)
+ end
+ else
+ single_resource(subject)
+ end
+ end
+
+ def access_denied
+ ephemeral_response("Whoops! That action is not allowed. This incident will be [reported](https://xkcd.com/838/).")
+ end
+
+ private
+
+ def show_result(result)
+ case result.type
+ when :success
+ in_channel_response(result.message)
+ else
+ ephemeral_response(result.message)
+ end
+ end
+
+ def not_found
+ ephemeral_response("404 not found! GitLab couldn't find what you were looking for! :boom:")
+ end
+
+ def single_resource(resource)
+ return error(resource) if resource.errors.any? || !resource.persisted?
+
+ message = "### #{title(resource)}"
+ message << "\n\n#{resource.description}" if resource.try(:description)
+
+ in_channel_response(message)
+ end
+
+ def multiple_resources(resources)
+ resources.map! { |resource| title(resource) }
+
+ message = header_with_list("Multiple results were found:", resources)
+
+ ephemeral_response(message)
+ end
+
+ def error(resource)
+ message = header_with_list("The action was not successful, because:", resource.errors.messages)
+
+ ephemeral_response(message)
+ end
+
+ def title(resource)
+ reference = resource.try(:to_reference) || resource.try(:id)
+ title = resource.try(:title) || resource.try(:name)
+
+ "[#{reference} #{title}](#{url(resource)})"
+ end
+
+ def header_with_list(header, items)
+ message = [header]
+
+ items.each do |item|
+ message << "- #{item}"
+ end
+
+ message.join("\n")
+ end
+
+ def url(resource)
+ url_for(
+ [
+ resource.project.namespace.becomes(Namespace),
+ resource.project,
+ resource
+ ]
+ )
+ end
+
+ def ephemeral_response(message)
+ {
+ response_type: :ephemeral,
+ text: message,
+ status: 200
+ }
+ end
+
+ def in_channel_response(message)
+ {
+ response_type: :in_channel,
+ text: message,
+ status: 200
+ }
+ end
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake
index b7cbdc6cd78..4a696a52b4d 100644
--- a/lib/tasks/gitlab/cleanup.rake
+++ b/lib/tasks/gitlab/cleanup.rake
@@ -91,5 +91,28 @@ namespace :gitlab do
puts "To block these users run this command with BLOCK=true".color(:yellow)
end
end
+
+ # This is a rake task which removes faulty refs. These refs where only
+ # created in the 8.13.RC cycle, and fixed in the stable builds which were
+ # released. So likely this should only be run once on gitlab.com
+ # Faulty refs are moved so they are kept around, else some features break.
+ desc 'GitLab | Cleanup | Remove faulty deployment refs'
+ task move_faulty_deployment_refs: :environment do
+ projects = Project.where(id: Deployment.select(:project_id).distinct)
+
+ projects.find_each do |project|
+ rugged = project.repository.rugged
+
+ max_iid = project.deployments.maximum(:iid)
+
+ rugged.references.each('refs/environments/**/*') do |ref|
+ id = ref.name.split('/').last.to_i
+ next unless id > max_iid
+
+ project.deployments.find(id).create_ref
+ rugged.references.delete(ref)
+ end
+ end
+ end
end
end
diff --git a/lib/tasks/gitlab/dev.rake b/lib/tasks/gitlab/dev.rake
index 3117075b08b..7db0779def8 100644
--- a/lib/tasks/gitlab/dev.rake
+++ b/lib/tasks/gitlab/dev.rake
@@ -4,10 +4,7 @@ namespace :gitlab do
task :ee_compat_check, [:branch] => :environment do |_, args|
opts =
if ENV['CI']
- {
- branch: ENV['CI_BUILD_REF_NAME'],
- ce_repo: ENV['CI_BUILD_REPO']
- }
+ { branch: ENV['CI_BUILD_REF_NAME'] }
else
unless args[:branch]
puts "Must specify a branch as an argument".color(:red)
diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh
index 1eaafdce389..6e3f76b8399 100755
--- a/scripts/prepare_build.sh
+++ b/scripts/prepare_build.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/bin/sh
retry() {
if eval "$@"; then
@@ -24,11 +24,12 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then
cp config/resque.yml.example config/resque.yml
sed -i 's/localhost/redis/g' config/resque.yml
- export FLAGS=(--path vendor --retry 3 --quiet)
+ export FLAGS="--path vendor --retry 3 --quiet"
else
- export PATH=$HOME/bin:/usr/local/bin:/usr/bin:/bin
+ rnd=$(awk 'BEGIN { srand() ; printf("%d\n",rand()*5) }')
+ export PATH="$HOME/bin:/usr/local/bin:/usr/bin:/bin"
cp config/database.yml.mysql config/database.yml
sed "s/username\:.*$/username\: runner/" -i config/database.yml
sed "s/password\:.*$/password\: 'password'/" -i config/database.yml
- sed "s/gitlabhq_test/gitlabhq_test_$((RANDOM/5000))/" -i config/database.yml
+ sed "s/gitlabhq_test/gitlabhq_test_$rnd/" -i config/database.yml
fi
diff --git a/spec/controllers/groups/labels_controller_spec.rb b/spec/controllers/groups/labels_controller_spec.rb
new file mode 100644
index 00000000000..899d8ebd12b
--- /dev/null
+++ b/spec/controllers/groups/labels_controller_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe Groups::LabelsController do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ before do
+ group.add_owner(user)
+
+ sign_in(user)
+ end
+
+ describe 'POST #toggle_subscription' do
+ it 'allows user to toggle subscription on group labels' do
+ label = create(:group_label, group: group)
+
+ post :toggle_subscription, group_id: group.to_param, id: label.to_param
+
+ expect(response).to have_http_status(200)
+ end
+ end
+end
diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb
index cbe0417a4a7..299d2c981d3 100644
--- a/spec/controllers/projects/boards/issues_controller_spec.rb
+++ b/spec/controllers/projects/boards/issues_controller_spec.rb
@@ -25,7 +25,7 @@ describe Projects::Boards::IssuesController do
create(:labeled_issue, project: project, labels: [planning])
create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow)
create(:labeled_issue, project: project, labels: [development], assignee: johndoe)
- issue.subscribe(johndoe)
+ issue.subscribe(johndoe, project)
list_issues user: user, board: board, list: list2
diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb
new file mode 100644
index 00000000000..a971adf0539
--- /dev/null
+++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Projects::CycleAnalyticsController do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ project.team << [user, :master]
+ end
+
+ describe 'cycle analytics not set up flag' do
+ context 'with no data' do
+ it 'is true' do
+ get(:show,
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param)
+
+ expect(response).to be_success
+ expect(assigns(:cycle_analytics_no_data)).to eq(true)
+ end
+ end
+
+ context 'with data' do
+ before do
+ issue = create(:issue, project: project, created_at: 4.days.ago)
+ milestone = create(:milestone, project: project, created_at: 5.days.ago)
+ issue.update(milestone: milestone)
+
+ create_merge_request_closing_issue(issue)
+ end
+
+ it 'is false' do
+ get(:show,
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param)
+
+ expect(response).to be_success
+ expect(assigns(:cycle_analytics_no_data)).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 768105cae95..bc5e2711125 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe Projects::EnvironmentsController do
+ include ApiHelpers
+
let(:environment) { create(:environment) }
let(:project) { environment.project }
let(:user) { create(:user) }
@@ -11,6 +13,27 @@ describe Projects::EnvironmentsController do
sign_in(user)
end
+ describe 'GET index' do
+ context 'when standardrequest has been made' do
+ it 'responds with status code 200' do
+ get :index, environment_params
+
+ expect(response).to be_ok
+ end
+ end
+
+ context 'when requesting JSON response' do
+ it 'responds with correct JSON' do
+ get :index, environment_params(format: :json)
+
+ first_environment = json_response.first
+
+ expect(first_environment).not_to be_empty
+ expect(first_environment['name']). to eq environment.name
+ end
+ end
+ end
+
describe 'GET show' do
context 'with valid id' do
it 'responds with a status code 200' do
@@ -48,11 +71,9 @@ describe Projects::EnvironmentsController do
end
end
- def environment_params
- {
- namespace_id: project.namespace,
- project_id: project,
- id: environment.id
- }
+ def environment_params(opts = {})
+ opts.reverse_merge(namespace_id: project.namespace,
+ project_id: project,
+ id: environment.id)
end
end
diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb
index ac3469cb8a9..028ea067a97 100644
--- a/spec/controllers/projects/forks_controller_spec.rb
+++ b/spec/controllers/projects/forks_controller_spec.rb
@@ -67,4 +67,62 @@ describe Projects::ForksController do
end
end
end
+
+ describe 'GET new' do
+ def get_new
+ get :new,
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param
+ end
+
+ context 'when user is signed in' do
+ it 'responds with status 200' do
+ sign_in(user)
+
+ get_new
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when user is not signed in' do
+ it 'redirects to the sign-in page' do
+ sign_out(user)
+
+ get_new
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+
+ describe 'POST create' do
+ def post_create
+ post :create,
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ namespace_key: user.namespace.id
+ end
+
+ context 'when user is signed in' do
+ it 'responds with status 302' do
+ sign_in(user)
+
+ post_create
+
+ expect(response).to have_http_status(302)
+ expect(response).to redirect_to(namespace_project_import_path(user.namespace, project))
+ end
+ end
+
+ context 'when user is not signed in' do
+ it 'redirects to the sign-in page' do
+ sign_out(user)
+
+ post_create
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb
index 8faecec0063..ec6cea5c0f4 100644
--- a/spec/controllers/projects/labels_controller_spec.rb
+++ b/spec/controllers/projects/labels_controller_spec.rb
@@ -72,14 +72,8 @@ describe Projects::LabelsController do
end
describe 'POST #generate' do
- let(:admin) { create(:admin) }
-
- before do
- sign_in(admin)
- end
-
context 'personal project' do
- let(:personal_project) { create(:empty_project) }
+ let(:personal_project) { create(:empty_project, namespace: user.namespace) }
it 'creates labels' do
post :generate, namespace_id: personal_project.namespace.to_param, project_id: personal_project.to_param
@@ -96,4 +90,26 @@ describe Projects::LabelsController do
end
end
end
+
+ describe 'POST #toggle_subscription' do
+ it 'allows user to toggle subscription on project labels' do
+ label = create(:label, project: project)
+
+ toggle_subscription(label)
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'allows user to toggle subscription on group labels' do
+ group_label = create(:group_label, group: group)
+
+ toggle_subscription(group_label)
+
+ expect(response).to have_http_status(200)
+ end
+
+ def toggle_subscription(label)
+ post :toggle_subscription, namespace_id: project.namespace.to_param, project_id: project.to_param, id: label.to_param
+ end
+ end
end
diff --git a/spec/controllers/sent_notifications_controller_spec.rb b/spec/controllers/sent_notifications_controller_spec.rb
index 191e290a118..954fc2eaf21 100644
--- a/spec/controllers/sent_notifications_controller_spec.rb
+++ b/spec/controllers/sent_notifications_controller_spec.rb
@@ -3,11 +3,11 @@ require 'rails_helper'
describe SentNotificationsController, type: :controller do
let(:user) { create(:user) }
let(:project) { create(:empty_project) }
- let(:sent_notification) { create(:sent_notification, noteable: issue, recipient: user) }
+ let(:sent_notification) { create(:sent_notification, project: project, noteable: issue, recipient: user) }
let(:issue) do
create(:issue, project: project, author: user) do |issue|
- issue.subscriptions.create(user: user, subscribed: true)
+ issue.subscriptions.create(user: user, project: project, subscribed: true)
end
end
@@ -17,7 +17,7 @@ describe SentNotificationsController, type: :controller do
before { get(:unsubscribe, id: sent_notification.reply_key, force: true) }
it 'unsubscribes the user' do
- expect(issue.subscribed?(user)).to be_falsey
+ expect(issue.subscribed?(user, project)).to be_falsey
end
it 'sets the flash message' do
@@ -33,7 +33,7 @@ describe SentNotificationsController, type: :controller do
before { get(:unsubscribe, id: sent_notification.reply_key) }
it 'does not unsubscribe the user' do
- expect(issue.subscribed?(user)).to be_truthy
+ expect(issue.subscribed?(user, project)).to be_truthy
end
it 'does not set the flash message' do
@@ -53,7 +53,7 @@ describe SentNotificationsController, type: :controller do
before { get(:unsubscribe, id: sent_notification.reply_key.reverse) }
it 'does not unsubscribe the user' do
- expect(issue.subscribed?(user)).to be_truthy
+ expect(issue.subscribed?(user, project)).to be_truthy
end
it 'does not set the flash message' do
@@ -69,7 +69,7 @@ describe SentNotificationsController, type: :controller do
before { get(:unsubscribe, id: sent_notification.reply_key, force: true) }
it 'unsubscribes the user' do
- expect(issue.subscribed?(user)).to be_falsey
+ expect(issue.subscribed?(user, project)).to be_falsey
end
it 'sets the flash message' do
@@ -85,14 +85,14 @@ describe SentNotificationsController, type: :controller do
context 'when the force param is not passed' do
let(:merge_request) do
create(:merge_request, source_project: project, author: user) do |merge_request|
- merge_request.subscriptions.create(user: user, subscribed: true)
+ merge_request.subscriptions.create(user: user, project: project, subscribed: true)
end
end
- let(:sent_notification) { create(:sent_notification, noteable: merge_request, recipient: user) }
+ let(:sent_notification) { create(:sent_notification, project: project, noteable: merge_request, recipient: user) }
before { get(:unsubscribe, id: sent_notification.reply_key) }
it 'unsubscribes the user' do
- expect(merge_request.subscribed?(user)).to be_falsey
+ expect(merge_request.subscribed?(user, project)).to be_falsey
end
it 'sets the flash message' do
diff --git a/spec/factories/chat_names.rb b/spec/factories/chat_names.rb
new file mode 100644
index 00000000000..24225468d55
--- /dev/null
+++ b/spec/factories/chat_names.rb
@@ -0,0 +1,16 @@
+FactoryGirl.define do
+ factory :chat_name, class: ChatName do
+ user factory: :user
+ service factory: :service
+
+ team_id 'T0001'
+ team_domain 'Awesome Team'
+
+ sequence :chat_id do |n|
+ "U#{n}"
+ end
+ sequence :chat_name do |n|
+ "user#{n}"
+ end
+ end
+end
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index 0c93bbdfe26..eb20bd7dd58 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -55,6 +55,12 @@ FactoryGirl.define do
self.when 'manual'
end
+ trait :teardown_environment do
+ options do
+ { environment: { action: 'stop' } }
+ end
+ end
+
trait :allowed_to_fail do
allow_failure true
end
diff --git a/spec/factories/subscriptions.rb b/spec/factories/subscriptions.rb
new file mode 100644
index 00000000000..b11b0a0a17b
--- /dev/null
+++ b/spec/factories/subscriptions.rb
@@ -0,0 +1,7 @@
+FactoryGirl.define do
+ factory :subscription do
+ user
+ project factory: :empty_project
+ subscribable factory: :issue
+ end
+end
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
new file mode 100644
index 00000000000..f6d625fa7f6
--- /dev/null
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+feature 'Admin Groups', feature: true do
+ let(:internal) { Gitlab::VisibilityLevel::INTERNAL }
+
+ before do
+ login_as(:admin)
+
+ stub_application_setting(default_group_visibility: internal)
+ end
+
+ describe 'create a group' do
+ scenario 'shows the visibility level radio populated with the default value' do
+ visit new_admin_group_path
+
+ expect_selected_visibility(internal)
+ end
+ end
+
+ describe 'group edit' do
+ scenario 'shows the visibility level radio populated with the group visibility_level value' do
+ group = create(:group, :private)
+
+ visit edit_admin_group_path(group)
+
+ expect_selected_visibility(group.visibility_level)
+ end
+ end
+
+ def expect_selected_visibility(level)
+ selector = "#group_visibility_level_#{level}[checked=checked]"
+
+ expect(page).to have_selector(selector, count: 1)
+ end
+end
diff --git a/spec/features/environment_spec.rb b/spec/features/environment_spec.rb
new file mode 100644
index 00000000000..0c1939fd885
--- /dev/null
+++ b/spec/features/environment_spec.rb
@@ -0,0 +1,161 @@
+require 'spec_helper'
+
+feature 'Environment', :feature do
+ given(:project) { create(:empty_project) }
+ given(:user) { create(:user) }
+ given(:role) { :developer }
+
+ background do
+ login_as(user)
+ project.team << [user, role]
+ end
+
+ feature 'environment details page' do
+ given!(:environment) { create(:environment, project: project) }
+ given!(:deployment) { }
+ given!(:manual) { }
+
+ before do
+ visit_environment(environment)
+ end
+
+ context 'without deployments' do
+ scenario 'does show no deployments' do
+ expect(page).to have_content('You don\'t have any deployments right now.')
+ end
+ end
+
+ context 'with deployments' do
+ context 'when there is no related deployable' do
+ given(:deployment) do
+ create(:deployment, environment: environment, deployable: nil)
+ end
+
+ scenario 'does show deployment SHA' do
+ expect(page).to have_link(deployment.short_sha)
+ end
+
+ scenario 'does not show a re-deploy button for deployment without build' do
+ expect(page).not_to have_link('Re-deploy')
+ end
+ end
+
+ context 'with related deployable present' do
+ given(:pipeline) { create(:ci_pipeline, project: project) }
+ given(:build) { create(:ci_build, pipeline: pipeline) }
+
+ given(:deployment) do
+ create(:deployment, environment: environment, deployable: build)
+ end
+
+ scenario 'does show build name' do
+ expect(page).to have_link("#{build.name} (##{build.id})")
+ end
+
+ scenario 'does show re-deploy button' do
+ expect(page).to have_link('Re-deploy')
+ end
+
+ scenario 'does not show stop button' do
+ expect(page).not_to have_link('Stop')
+ end
+
+ context 'with manual action' do
+ given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') }
+
+ scenario 'does show a play button' do
+ expect(page).to have_link(manual.name.humanize)
+ end
+
+ scenario 'does allow to play manual action' do
+ expect(manual).to be_skipped
+ expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count }
+ expect(page).to have_content(manual.name)
+ expect(manual.reload).to be_pending
+ end
+
+ context 'with external_url' do
+ given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') }
+ given(:build) { create(:ci_build, pipeline: pipeline) }
+ given(:deployment) { create(:deployment, environment: environment, deployable: build) }
+
+ scenario 'does show an external link button' do
+ expect(page).to have_link(nil, href: environment.external_url)
+ end
+ end
+
+ context 'with stop action' do
+ given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') }
+ given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') }
+
+ scenario 'does show stop button' do
+ expect(page).to have_link('Stop')
+ end
+
+ scenario 'does allow to stop environment' do
+ click_link('Stop')
+
+ expect(page).to have_content('close_app')
+ end
+
+ context 'for reporter' do
+ let(:role) { :reporter }
+
+ scenario 'does not show stop button' do
+ expect(page).not_to have_link('Stop')
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ feature 'auto-close environment when branch is deleted' do
+ given(:project) { create(:project) }
+
+ given!(:environment) do
+ create(:environment, :with_review_app, project: project,
+ ref: 'feature')
+ end
+
+ scenario 'user visits environment page' do
+ visit_environment(environment)
+
+ expect(page).to have_link('Stop')
+ end
+
+ scenario 'user deletes the branch with running environment' do
+ visit namespace_project_branches_path(project.namespace, project)
+
+ remove_branch_with_hooks(project, user, 'feature') do
+ page.within('.js-branch-feature') { find('a.btn-remove').click }
+ end
+
+ visit_environment(environment)
+
+ expect(page).to have_no_link('Stop')
+ end
+
+ ##
+ # This is a workaround for problem described in #24543
+ #
+ def remove_branch_with_hooks(project, user, branch)
+ params = {
+ oldrev: project.commit(branch).id,
+ newrev: Gitlab::Git::BLANK_SHA,
+ ref: "refs/heads/#{branch}"
+ }
+
+ yield
+
+ GitPushService.new(project, user, params).execute
+ end
+ end
+
+ def visit_environment(environment)
+ visit namespace_project_environment_path(environment.project.namespace,
+ environment.project,
+ environment)
+ end
+end
diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb
index 1fe509c2cac..c7fe622c477 100644
--- a/spec/features/environments_spec.rb
+++ b/spec/features/environments_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Environments', feature: true do
+feature 'Environments page', :feature, :js do
given(:project) { create(:empty_project) }
given(:user) { create(:user) }
given(:role) { :developer }
@@ -10,221 +10,138 @@ feature 'Environments', feature: true do
login_as(user)
end
- describe 'when showing environments' do
- given!(:environment) { }
- given!(:deployment) { }
- given!(:manual) { }
+ given!(:environment) { }
+ given!(:deployment) { }
+ given!(:manual) { }
- before do
- visit_environments(project)
- end
+ before do
+ visit_environments(project)
+ end
- context 'shows two tabs' do
- scenario 'shows "Available" and "Stopped" tab with links' do
- expect(page).to have_link('Available')
- expect(page).to have_link('Stopped')
- end
+ describe 'page tabs' do
+ scenario 'shows "Available" and "Stopped" tab with links' do
+ expect(page).to have_link('Available')
+ expect(page).to have_link('Stopped')
end
+ end
- context 'without environments' do
- scenario 'does show no environments' do
- expect(page).to have_content('You don\'t have any environments right now.')
- end
-
- scenario 'does show 0 as counter for environments in both tabs' do
- expect(page.find('.js-available-environments-count').text).to eq('0')
- expect(page.find('.js-stopped-environments-count').text).to eq('0')
- end
+ context 'without environments' do
+ scenario 'does show no environments' do
+ expect(page).to have_content('You don\'t have any environments right now.')
end
- context 'with environments' do
- given(:environment) { create(:environment, project: project) }
-
- scenario 'does show environment name' do
- expect(page).to have_link(environment.name)
- end
-
- scenario 'does show number of available and stopped environments' do
- expect(page.find('.js-available-environments-count').text).to eq('1')
- expect(page.find('.js-stopped-environments-count').text).to eq('0')
- end
-
- context 'without deployments' do
- scenario 'does show no deployments' do
- expect(page).to have_content('No deployments yet')
- end
- end
-
- context 'with deployments' do
- given(:deployment) { create(:deployment, environment: environment) }
-
- scenario 'does show deployment SHA' do
- expect(page).to have_link(deployment.short_sha)
- end
-
- scenario 'does show deployment internal id' do
- expect(page).to have_content(deployment.iid)
- end
-
- context 'with build and manual actions' do
- given(:pipeline) { create(:ci_pipeline, project: project) }
- given(:build) { create(:ci_build, pipeline: pipeline) }
- given(:deployment) { create(:deployment, environment: environment, deployable: build) }
- given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') }
-
- scenario 'does show a play button' do
- expect(page).to have_link(manual.name.humanize)
- end
-
- scenario 'does allow to play manual action' do
- expect(manual).to be_skipped
- expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count }
- expect(page).to have_content(manual.name)
- expect(manual.reload).to be_pending
- end
-
- scenario 'does show build name and id' do
- expect(page).to have_link("#{build.name} (##{build.id})")
- end
-
- scenario 'does not show stop button' do
- expect(page).not_to have_selector('.stop-env-link')
- end
-
- scenario 'does not show external link button' do
- expect(page).not_to have_css('external-url')
- end
-
- context 'with external_url' do
- given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') }
- given(:build) { create(:ci_build, pipeline: pipeline) }
- given(:deployment) { create(:deployment, environment: environment, deployable: build) }
-
- scenario 'does show an external link button' do
- expect(page).to have_link(nil, href: environment.external_url)
- end
- end
-
- context 'with stop action' do
- given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') }
- given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') }
-
- scenario 'does show stop button' do
- expect(page).to have_selector('.stop-env-link')
- end
-
- scenario 'starts build when stop button clicked' do
- first('.stop-env-link').click
-
- expect(page).to have_content('close_app')
- end
-
- context 'for reporter' do
- let(:role) { :reporter }
-
- scenario 'does not show stop button' do
- expect(page).not_to have_selector('.stop-env-link')
- end
- end
- end
- end
- end
- end
-
- scenario 'does have a New environment button' do
- expect(page).to have_link('New environment')
+ scenario 'does show 0 as counter for environments in both tabs' do
+ expect(page.find('.js-available-environments-count').text).to eq('0')
+ expect(page.find('.js-stopped-environments-count').text).to eq('0')
end
end
describe 'when showing the environment' do
given(:environment) { create(:environment, project: project) }
- given!(:deployment) { }
- given!(:manual) { }
- before do
- visit_environment(environment)
+ scenario 'does show environment name' do
+ expect(page).to have_link(environment.name)
+ end
+
+ scenario 'does show number of available and stopped environments' do
+ expect(page.find('.js-available-environments-count').text).to eq('1')
+ expect(page.find('.js-stopped-environments-count').text).to eq('0')
end
context 'without deployments' do
scenario 'does show no deployments' do
- expect(page).to have_content('You don\'t have any deployments right now.')
+ expect(page).to have_content('No deployments yet')
end
end
context 'with deployments' do
+ given(:project) { create(:project) }
+
given(:deployment) do
- create(:deployment, environment: environment, deployable: nil)
+ create(:deployment, environment: environment,
+ sha: project.commit.id)
end
scenario 'does show deployment SHA' do
expect(page).to have_link(deployment.short_sha)
end
- scenario 'does not show a re-deploy button for deployment without build' do
- expect(page).not_to have_link('Re-deploy')
+ scenario 'does show deployment internal id' do
+ expect(page).to have_content(deployment.iid)
end
- context 'with build' do
+ context 'with build and manual actions' do
given(:pipeline) { create(:ci_pipeline, project: project) }
given(:build) { create(:ci_build, pipeline: pipeline) }
- given(:deployment) { create(:deployment, environment: environment, deployable: build) }
- scenario 'does show build name' do
- expect(page).to have_link("#{build.name} (##{build.id})")
+ given(:manual) do
+ create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production')
end
- scenario 'does show re-deploy button' do
- expect(page).to have_link('Re-deploy')
+ given(:deployment) do
+ create(:deployment, environment: environment,
+ deployable: build,
+ sha: project.commit.id)
end
- scenario 'does not show stop button' do
- expect(page).not_to have_link('Stop')
+ scenario 'does show a play button' do
+ find('.dropdown-play-icon-container').click
+ expect(page).to have_content(manual.name.humanize)
end
- context 'with manual action' do
- given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') }
+ scenario 'does allow to play manual action', js: true do
+ expect(manual).to be_skipped
- scenario 'does show a play button' do
- expect(page).to have_link(manual.name.humanize)
- end
+ find('.dropdown-play-icon-container').click
+ expect(page).to have_content(manual.name.humanize)
- scenario 'does allow to play manual action' do
- expect(manual).to be_skipped
- expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count }
- expect(page).to have_content(manual.name)
- expect(manual.reload).to be_pending
- end
+ expect { click_link(manual.name.humanize) }
+ .not_to change { Ci::Pipeline.count }
- context 'with external_url' do
- given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') }
- given(:build) { create(:ci_build, pipeline: pipeline) }
- given(:deployment) { create(:deployment, environment: environment, deployable: build) }
+ expect(manual.reload).to be_pending
+ end
- scenario 'does show an external link button' do
- expect(page).to have_link(nil, href: environment.external_url)
- end
+ scenario 'does show build name and id' do
+ expect(page).to have_link("#{build.name} ##{build.id}")
+ end
+
+ scenario 'does not show stop button' do
+ expect(page).not_to have_selector('.stop-env-link')
+ end
+
+ scenario 'does not show external link button' do
+ expect(page).not_to have_css('external-url')
+ end
+
+ context 'with external_url' do
+ given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') }
+ given(:build) { create(:ci_build, pipeline: pipeline) }
+ given(:deployment) { create(:deployment, environment: environment, deployable: build) }
+
+ scenario 'does show an external link button' do
+ expect(page).to have_link(nil, href: environment.external_url)
end
+ end
- context 'with stop action' do
- given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') }
- given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') }
+ context 'with stop action' do
+ given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') }
+ given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') }
- scenario 'does show stop button' do
- expect(page).to have_link('Stop')
- end
+ scenario 'does show stop button' do
+ expect(page).to have_selector('.stop-env-link')
+ end
- scenario 'does allow to stop environment' do
- click_link('Stop')
+ scenario 'starts build when stop button clicked' do
+ find('.stop-env-link').click
- expect(page).to have_content('close_app')
- end
+ expect(page).to have_content('close_app')
+ end
- context 'for reporter' do
- let(:role) { :reporter }
+ context 'for reporter' do
+ let(:role) { :reporter }
- scenario 'does not show stop button' do
- expect(page).not_to have_link('Stop')
- end
+ scenario 'does not show stop button' do
+ expect(page).not_to have_selector('.stop-env-link')
end
end
end
@@ -232,6 +149,10 @@ feature 'Environments', feature: true do
end
end
+ scenario 'does have a New environment button' do
+ expect(page).to have_link('New environment')
+ end
+
describe 'when creating a new environment' do
before do
visit_environments(project)
@@ -274,55 +195,7 @@ feature 'Environments', feature: true do
end
end
- feature 'auto-close environment when branch deleted' do
- given(:project) { create(:project) }
-
- given!(:environment) do
- create(:environment, :with_review_app, project: project,
- ref: 'feature')
- end
-
- scenario 'user visits environment page' do
- visit_environment(environment)
-
- expect(page).to have_link('Stop')
- end
-
- scenario 'user deletes the branch with running environment' do
- visit namespace_project_branches_path(project.namespace, project)
-
- remove_branch_with_hooks(project, user, 'feature') do
- page.within('.js-branch-feature') { find('a.btn-remove').click }
- end
-
- visit_environment(environment)
-
- expect(page).to have_no_link('Stop')
- end
-
- ##
- # This is a workaround for problem described in #24543
- #
- def remove_branch_with_hooks(project, user, branch)
- params = {
- oldrev: project.commit(branch).id,
- newrev: Gitlab::Git::BLANK_SHA,
- ref: "refs/heads/#{branch}"
- }
-
- yield
-
- GitPushService.new(project, user, params).execute
- end
- end
-
def visit_environments(project)
visit namespace_project_environments_path(project.namespace, project)
end
-
- def visit_environment(environment)
- visit namespace_project_environment_path(environment.project.namespace,
- environment.project,
- environment)
- end
end
diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/issues/filter_issues_spec.rb
index 2798db92f0f..0d19563d628 100644
--- a/spec/features/issues/filter_issues_spec.rb
+++ b/spec/features/issues/filter_issues_spec.rb
@@ -3,8 +3,8 @@ require 'rails_helper'
describe 'Filter issues', feature: true do
include WaitForAjax
- let!(:project) { create(:project) }
let!(:group) { create(:group) }
+ let!(:project) { create(:project, group: group) }
let!(:user) { create(:user)}
let!(:milestone) { create(:milestone, project: project) }
let!(:label) { create(:label, project: project) }
@@ -127,7 +127,7 @@ describe 'Filter issues', feature: true do
expect(page).to have_content wontfix.title
end
- find('body').click
+ find('.dropdown-menu-close-icon').click
expect(find('.filtered-labels')).to have_content(wontfix.title)
@@ -135,7 +135,7 @@ describe 'Filter issues', feature: true do
wait_for_ajax
find('.dropdown-menu-labels a', text: label.title).click
- find('body').click
+ find('.dropdown-menu-close-icon').click
expect(find('.filtered-labels')).to have_content(wontfix.title)
expect(find('.filtered-labels')).to have_content(label.title)
@@ -150,8 +150,8 @@ describe 'Filter issues', feature: true do
it "selects and unselects `won't fix`" do
find('.dropdown-menu-labels a', text: wontfix.title).click
find('.dropdown-menu-labels a', text: wontfix.title).click
- # Close label dropdown to load
- find('body').click
+
+ find('.dropdown-menu-close-icon').click
expect(page).not_to have_css('.filtered-labels')
end
end
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index 4b1aec8bf71..bc068b5e7e0 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -1,7 +1,9 @@
require 'rails_helper'
feature 'Issue Sidebar', feature: true do
- let(:project) { create(:project) }
+ include WaitForAjax
+
+ let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
let!(:user) { create(:user)}
@@ -10,6 +12,37 @@ feature 'Issue Sidebar', feature: true do
login_as(user)
end
+ context 'assignee', js: true do
+ let(:user2) { create(:user) }
+ let(:issue2) { create(:issue, project: project, author: user2) }
+
+ before do
+ project.team << [user, :developer]
+ visit_issue(project, issue2)
+
+ find('.block.assignee .edit-link').click
+
+ wait_for_ajax
+ end
+
+ it 'shows author in assignee dropdown' do
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_content(user2.name)
+ end
+ end
+
+ it 'shows author when filtering assignee dropdown' do
+ page.within '.dropdown-menu-user' do
+ find('.dropdown-input-field').native.send_keys user2.name
+ sleep 1 # Required to wait for end of input delay
+
+ wait_for_ajax
+
+ expect(page).to have_content(user2.name)
+ end
+ end
+ end
+
context 'as a allowed user' do
before do
project.team << [user, :developer]
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index cdd02a8c8e3..5c958455604 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -371,10 +371,12 @@ describe 'Issues', feature: true do
describe 'when I want to reset my incoming email token' do
let(:project1) { create(:project, namespace: @user.namespace) }
+ let(:issue) { create(:issue, project: project1) }
before do
allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true)
project1.team << [@user, :master]
+ project1.issues << issue
visit namespace_project_issues_path(@user.namespace, project1)
end
@@ -576,7 +578,10 @@ describe 'Issues', feature: true do
describe 'new issue by email' do
shared_examples 'show the email in the modal' do
+ let(:issue) { create(:issue, project: project) }
+
before do
+ project.issues << issue
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
visit namespace_project_issues_path(project.namespace, project)
diff --git a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions.rb b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb
index 7f11db3c417..7f11db3c417 100644
--- a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions.rb
+++ b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb
diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb
index 584574cc91a..702869b6e8b 100644
--- a/spec/features/merge_requests/create_new_mr_spec.rb
+++ b/spec/features/merge_requests/create_new_mr_spec.rb
@@ -77,4 +77,11 @@ feature 'Create New Merge Request', feature: true, js: true do
expect(page).to have_content "6049019_460s.jpg"
end
end
+
+ # Isolates a regression (see #24627)
+ it 'does not show error messages on initial form' do
+ visit new_namespace_project_merge_request_path(project.namespace, project)
+ expect(page).not_to have_selector('#error_explanation')
+ expect(page).not_to have_content('The form contains the following error')
+ end
end
diff --git a/spec/features/merge_requests/deleted_source_branch_spec.rb b/spec/features/merge_requests/deleted_source_branch_spec.rb
new file mode 100644
index 00000000000..778b3a90cf3
--- /dev/null
+++ b/spec/features/merge_requests/deleted_source_branch_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe 'Deleted source branch', feature: true, js: true do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request) }
+
+ before do
+ login_as user
+ merge_request.project.team << [user, :master]
+ merge_request.update!(source_branch: 'this-branch-does-not-exist')
+ visit namespace_project_merge_request_path(
+ merge_request.project.namespace,
+ merge_request.project, merge_request
+ )
+ end
+
+ it 'shows a message about missing source branch' do
+ expect(page).to have_content(
+ 'Source branch this-branch-does-not-exist does not exist'
+ )
+ end
+
+ it 'hides Discussion, Commits and Changes tabs' do
+ within '.merge-request-details' do
+ expect(page).to have_no_content('Discussion')
+ expect(page).to have_no_content('Commits')
+ expect(page).to have_no_content('Changes')
+ end
+ end
+end
diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb
index d5e3d8e7eff..eab64bd4b54 100644
--- a/spec/features/merge_requests/diff_notes_resolve_spec.rb
+++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb
@@ -201,7 +201,7 @@ feature 'Diff notes resolve', feature: true, js: true do
expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
end
- expect(page).to have_content('Last updated')
+ expect(page).not_to have_content('Last updated')
page.within '.line-resolve-all-container' do
expect(page).to have_content('0/1 discussion resolved')
diff --git a/spec/features/merge_requests/merge_request_versions_spec.rb b/spec/features/merge_requests/merge_request_versions_spec.rb
index 23cee891bac..09451f41de4 100644
--- a/spec/features/merge_requests/merge_request_versions_spec.rb
+++ b/spec/features/merge_requests/merge_request_versions_spec.rb
@@ -3,11 +3,12 @@ require 'spec_helper'
feature 'Merge Request versions', js: true, feature: true do
let(:merge_request) { create(:merge_request, importing: true) }
let(:project) { merge_request.source_project }
+ let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) }
+ let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
before do
login_as :admin
- merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
- merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e')
visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
end
@@ -53,7 +54,7 @@ feature 'Merge Request versions', js: true, feature: true do
project.namespace,
project,
merge_request.iid,
- diff_id: 2,
+ diff_id: merge_request_diff3.id,
start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9'
)
end
diff --git a/spec/features/merge_requests/merge_when_build_succeeds_spec.rb b/spec/features/merge_requests/merge_when_build_succeeds_spec.rb
index c3c3ab33872..8eceaad2457 100644
--- a/spec/features/merge_requests/merge_when_build_succeeds_spec.rb
+++ b/spec/features/merge_requests/merge_when_build_succeeds_spec.rb
@@ -73,7 +73,7 @@ feature 'Merge When Build Succeeds', feature: true, js: true do
expect(page).to have_button "Merge When Build Succeeds"
visit_merge_request(merge_request) # refresh the page
- expect(page).to have_content "Canceled the automatic merge"
+ expect(page).to have_content "canceled the automatic merge"
end
it "allows the user to remove the source branch" do
@@ -101,7 +101,7 @@ feature 'Merge When Build Succeeds', feature: true, js: true do
expect(page).not_to have_link "Merge When Build Succeeds"
end
end
-
+
def visit_merge_request(merge_request)
visit namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
end
diff --git a/spec/features/merge_requests/only_allow_merge_if_build_succeeds.rb b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb
index 80e8b8fc642..1ec3103feef 100644
--- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds.rb
+++ b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
feature 'Only allow merge requests to be merged if the build succeeds', feature: true do
- let(:project) { create(:project, :public) }
- let(:merge_request) { create(:merge_request_with_diffs, source_project: project) }
+ let(:merge_request) { create(:merge_request_with_diffs) }
+ let(:project) { merge_request.target_project }
before do
login_as merge_request.author
@@ -19,7 +19,13 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature:
end
context 'when project has CI enabled' do
- let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) }
+ given!(:pipeline) do
+ create(:ci_empty_pipeline,
+ project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ status: status)
+ end
context 'when merge requests can only be merged if the build succeeds' do
before do
@@ -27,7 +33,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature:
end
context 'when CI is running' do
- before { pipeline.update_column(:status, :running) }
+ given(:status) { :running }
it 'does not allow to merge immediately' do
visit_merge_request(merge_request)
@@ -38,7 +44,18 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature:
end
context 'when CI failed' do
- before { pipeline.update_column(:status, :failed) }
+ given(:status) { :failed }
+
+ it 'does not allow MR to be merged' do
+ visit_merge_request(merge_request)
+
+ expect(page).not_to have_button 'Accept Merge Request'
+ expect(page).to have_content('Please retry the build or push a new commit to fix the failure.')
+ end
+ end
+
+ context 'when CI canceled' do
+ given(:status) { :canceled }
it 'does not allow MR to be merged' do
visit_merge_request(merge_request)
@@ -49,7 +66,17 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature:
end
context 'when CI succeeded' do
- before { pipeline.update_column(:status, :success) }
+ given(:status) { :success }
+
+ it 'allows MR to be merged' do
+ visit_merge_request(merge_request)
+
+ expect(page).to have_button 'Accept Merge Request'
+ end
+ end
+
+ context 'when CI skipped' do
+ given(:status) { :skipped }
it 'allows MR to be merged' do
visit_merge_request(merge_request)
@@ -65,7 +92,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature:
end
context 'when CI is running' do
- before { pipeline.update_column(:status, :running) }
+ given(:status) { :running }
it 'allows MR to be merged immediately', js: true do
visit_merge_request(merge_request)
@@ -78,7 +105,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature:
end
context 'when CI failed' do
- before { pipeline.update_column(:status, :failed) }
+ given(:status) { :failed }
it 'allows MR to be merged' do
visit_merge_request(merge_request)
@@ -88,7 +115,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature:
end
context 'when CI succeeded' do
- before { pipeline.update_column(:status, :success) }
+ given(:status) { :success }
it 'allows MR to be merged' do
visit_merge_request(merge_request)
diff --git a/spec/features/merge_requests/toggle_whitespace_changes.rb b/spec/features/merge_requests/toggle_whitespace_changes_spec.rb
index 0f98737b700..0f98737b700 100644
--- a/spec/features/merge_requests/toggle_whitespace_changes.rb
+++ b/spec/features/merge_requests/toggle_whitespace_changes_spec.rb
diff --git a/spec/features/profiles/chat_names_spec.rb b/spec/features/profiles/chat_names_spec.rb
new file mode 100644
index 00000000000..6f6f7029c0b
--- /dev/null
+++ b/spec/features/profiles/chat_names_spec.rb
@@ -0,0 +1,77 @@
+require 'rails_helper'
+
+feature 'Profile > Chat', feature: true do
+ given(:user) { create(:user) }
+ given(:service) { create(:service) }
+
+ before do
+ login_as(user)
+ end
+
+ describe 'uses authorization link' do
+ given(:params) do
+ { team_id: 'T00', team_domain: 'my_chat_team', user_id: 'U01', user_name: 'my_chat_user' }
+ end
+ given!(:authorize_url) { ChatNames::AuthorizeUserService.new(service, params).execute }
+ given(:authorize_path) { URI.parse(authorize_url).request_uri }
+
+ before do
+ visit authorize_path
+ end
+
+ context 'clicks authorize' do
+ before do
+ click_button 'Authorize'
+ end
+
+ scenario 'goes to list of chat names and see chat account' do
+ expect(page.current_path).to eq(profile_chat_names_path)
+ expect(page).to have_content('my_chat_team')
+ expect(page).to have_content('my_chat_user')
+ end
+
+ scenario 'second use of link is denied' do
+ visit authorize_path
+
+ expect(page).to have_http_status(:not_found)
+ end
+ end
+
+ context 'clicks deny' do
+ before do
+ click_button 'Deny'
+ end
+
+ scenario 'goes to list of chat names and do not see chat account' do
+ expect(page.current_path).to eq(profile_chat_names_path)
+ expect(page).not_to have_content('my_chat_team')
+ expect(page).not_to have_content('my_chat_user')
+ end
+
+ scenario 'second use of link is denied' do
+ visit authorize_path
+
+ expect(page).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'visits chat accounts' do
+ given!(:chat_name) { create(:chat_name, user: user, service: service) }
+
+ before do
+ visit profile_chat_names_path
+ end
+
+ scenario 'sees chat user' do
+ expect(page).to have_content(chat_name.team_domain)
+ expect(page).to have_content(chat_name.chat_name)
+ end
+
+ scenario 'removes chat account' do
+ click_link 'Remove'
+
+ expect(page).to have_content("You don't have any active chat names.")
+ end
+ end
+end
diff --git a/spec/features/projects/commits/cherry_pick_spec.rb b/spec/features/projects/commits/cherry_pick_spec.rb
index e45e3a36d01..d46d9e9399e 100644
--- a/spec/features/projects/commits/cherry_pick_spec.rb
+++ b/spec/features/projects/commits/cherry_pick_spec.rb
@@ -64,7 +64,7 @@ describe 'Cherry-pick Commits' do
context "I cherry-pick a commit from a different branch", js: true do
it do
- find('.commit-action-buttons a.dropdown-toggle').click
+ find('.header-action-buttons a.dropdown-toggle').click
find(:css, "a[href='#modal-cherry-pick-commit']").click
page.within('#modal-cherry-pick-commit') do
diff --git a/spec/features/projects/labels/subscription_spec.rb b/spec/features/projects/labels/subscription_spec.rb
new file mode 100644
index 00000000000..3130d87fba5
--- /dev/null
+++ b/spec/features/projects/labels/subscription_spec.rb
@@ -0,0 +1,74 @@
+require 'spec_helper'
+
+feature 'Labels subscription', feature: true do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project) { create(:empty_project, :public, namespace: group) }
+ let!(:bug) { create(:label, project: project, title: 'bug') }
+ let!(:feature) { create(:group_label, group: group, title: 'feature') }
+
+ context 'when signed in' do
+ before do
+ project.team << [user, :developer]
+ login_as user
+ end
+
+ scenario 'users can subscribe/unsubscribe to labels', js: true do
+ visit namespace_project_labels_path(project.namespace, project)
+
+ expect(page).to have_content('bug')
+ expect(page).to have_content('feature')
+
+ within "#project_label_#{bug.id}" do
+ expect(page).not_to have_button 'Unsubscribe'
+
+ click_button 'Subscribe'
+
+ expect(page).not_to have_button 'Subscribe'
+ expect(page).to have_button 'Unsubscribe'
+
+ click_button 'Unsubscribe'
+
+ expect(page).to have_button 'Subscribe'
+ expect(page).not_to have_button 'Unsubscribe'
+ end
+
+ within "#group_label_#{feature.id}" do
+ expect(page).not_to have_button 'Unsubscribe'
+
+ click_link_on_dropdown('Group level')
+
+ expect(page).not_to have_selector('.dropdown-group-label')
+ expect(page).to have_button 'Unsubscribe'
+
+ click_button 'Unsubscribe'
+
+ expect(page).to have_selector('.dropdown-group-label')
+
+ click_link_on_dropdown('Project level')
+
+ expect(page).not_to have_selector('.dropdown-group-label')
+ expect(page).to have_button 'Unsubscribe'
+ end
+ end
+ end
+
+ context 'when not signed in' do
+ it 'users can not subscribe/unsubscribe to labels' do
+ visit namespace_project_labels_path(project.namespace, project)
+
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'feature'
+ expect(page).not_to have_button('Subscribe')
+ expect(page).not_to have_selector('.dropdown-group-label')
+ end
+ end
+
+ def click_link_on_dropdown(text)
+ find('.dropdown-group-label').click
+
+ page.within('.dropdown-group-label') do
+ find('a.js-subscribe-button', text: text).click
+ end
+ end
+end
diff --git a/spec/features/projects/pipelines_spec.rb b/spec/features/projects/pipelines_spec.rb
index db56a50e058..002c6f6b359 100644
--- a/spec/features/projects/pipelines_spec.rb
+++ b/spec/features/projects/pipelines_spec.rb
@@ -146,7 +146,8 @@ describe "Pipelines" do
end
describe 'GET /:project/pipelines/:id' do
- let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
+ let(:project) { create(:project) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
@success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build')
diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb
index 33b52d1547e..e2d9cfdd0b0 100644
--- a/spec/features/unsubscribe_links_spec.rb
+++ b/spec/features/unsubscribe_links_spec.rb
@@ -26,11 +26,11 @@ describe 'Unsubscribe links', feature: true do
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(issue.subscribed?(recipient)).to be_truthy
+ expect(issue.subscribed?(recipient, project)).to be_truthy
click_link 'Unsubscribe'
- expect(issue.subscribed?(recipient)).to be_falsey
+ expect(issue.subscribed?(recipient, project)).to be_falsey
expect(current_path).to eq new_user_session_path
end
@@ -38,11 +38,11 @@ describe 'Unsubscribe links', feature: true do
visit body_link
expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last)
- expect(issue.subscribed?(recipient)).to be_truthy
+ expect(issue.subscribed?(recipient, project)).to be_truthy
click_link 'Cancel'
- expect(issue.subscribed?(recipient)).to be_truthy
+ expect(issue.subscribed?(recipient, project)).to be_truthy
expect(current_path).to eq new_user_session_path
end
end
@@ -51,7 +51,7 @@ describe 'Unsubscribe links', feature: true do
visit header_link
expect(page).to have_text('unsubscribed')
- expect(issue.subscribed?(recipient)).to be_falsey
+ expect(issue.subscribed?(recipient, project)).to be_falsey
end
end
@@ -62,14 +62,14 @@ describe 'Unsubscribe links', feature: true do
visit body_link
expect(page).to have_text('unsubscribed')
- expect(issue.subscribed?(recipient)).to be_falsey
+ expect(issue.subscribed?(recipient, project)).to be_falsey
end
it 'unsubscribes from the issue when visiting the link from the header' do
visit header_link
expect(page).to have_text('unsubscribed')
- expect(issue.subscribed?(recipient)).to be_falsey
+ expect(issue.subscribed?(recipient, project)).to be_falsey
end
end
end
diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb
index 111ca7f7a70..afa98f3f715 100644
--- a/spec/features/users_spec.rb
+++ b/spec/features/users_spec.rb
@@ -74,16 +74,29 @@ feature 'Users', feature: true, js: true do
visit new_user_session_path
click_link 'Register'
end
+
+ scenario 'doesn\'t show an error border if the username is available' do
+ fill_in username_input, with: 'new-user'
+ wait_for_ajax
+ expect(find('.username')).not_to have_css '.gl-field-error-outline'
+ end
+
+ scenario 'does not show an error border if the username contains dots (.)' do
+ fill_in username_input, with: 'new.user.username'
+ wait_for_ajax
+ expect(find('.username')).not_to have_css '.gl-field-error-outline'
+ end
+
scenario 'shows an error border if the username already exists' do
fill_in username_input, with: user.username
wait_for_ajax
expect(find('.username')).to have_css '.gl-field-error-outline'
end
- scenario 'doesn\'t show an error border if the username is available' do
- fill_in username_input, with: 'new-user'
+ scenario 'shows an error border if the username contains special characters' do
+ fill_in username_input, with: 'new$user!username'
wait_for_ajax
- expect(find('#new_user_username')).not_to have_css '.gl-field-error-outline'
+ expect(find('.username')).to have_css '.gl-field-error-outline'
end
end
diff --git a/spec/finders/group_projects_finder_spec.rb b/spec/finders/group_projects_finder_spec.rb
index fbe09b28b3c..00eec3f3f4c 100644
--- a/spec/finders/group_projects_finder_spec.rb
+++ b/spec/finders/group_projects_finder_spec.rb
@@ -38,7 +38,10 @@ describe GroupProjectsFinder do
end
describe 'without group member current_user' do
- before { shared_project_2.team << [current_user, Gitlab::Access::MASTER] }
+ before do
+ shared_project_2.team << [current_user, Gitlab::Access::MASTER]
+ current_user.reload
+ end
context "only shared" do
context "without external user" do
diff --git a/spec/fixtures/emails/outlook_html.eml b/spec/fixtures/emails/outlook_html.eml
new file mode 100644
index 00000000000..506d69efe83
--- /dev/null
+++ b/spec/fixtures/emails/outlook_html.eml
@@ -0,0 +1,140 @@
+
+MIME-Version: 1.0
+Received: by 10.25.161.144 with HTTP; Tue, 7 Oct 2014 22:17:17 -0700 (PDT)
+X-Originating-IP: [117.207.85.84]
+In-Reply-To: <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail>
+References: <topic/35@discourse.techapj.com>
+ <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail>
+Date: Wed, 8 Oct 2014 10:47:17 +0530
+Delivered-To: arpit@techapj.com
+Message-ID: <CAOJeqne=SJ_LwN4sb-0Y95ejc2OpreVhdmcPn0TnmwSvTCYzzQ@mail.gmail.com>
+Subject: Re: [Discourse] [Meta] Welcome to techAPJ's Discourse!
+From: Arpit Jalan <arpit@techapj.com>
+To: Discourse <mail+e1c7f2a380e33840aeb654f075490bad@arpitjalan.com>Accept-Language: en-US
+Content-Language: en-US
+X-MS-Has-Attach:
+X-MS-TNEF-Correlator:
+x-originating-ip: [134.68.31.227]
+Content-Type: multipart/alternative;
+ boundary="_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_"
+MIME-Version: 1.0
+
+--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_
+Content-Type: text/html; charset="utf-8"
+Content-Transfer-Encoding: base64
+
+PGh0bWwgeG1sbnM6dj0idXJuOnNjaGVtYXMtbWljcm9zb2Z0LWNvbTp2bWwiIHhtbG5zOm89InVy
+bjpzY2hlbWFzLW1pY3Jvc29mdC1jb206b2ZmaWNlOm9mZmljZSIgeG1sbnM6dz0idXJuOnNjaGVt
+YXMtbWljcm9zb2Z0LWNvbTpvZmZpY2U6d29yZCIgeG1sbnM6bT0iaHR0cDovL3NjaGVtYXMubWlj
+cm9zb2Z0LmNvbS9vZmZpY2UvMjAwNC8xMi9vbW1sIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcv
+VFIvUkVDLWh0bWw0MCI+DQo8aGVhZD4NCjxtZXRhIGh0dHAtZXF1aXY9IkNvbnRlbnQtVHlwZSIg
+Y29udGVudD0idGV4dC9odG1sOyBjaGFyc2V0PXV0Zi04Ij4NCjxtZXRhIG5hbWU9IkdlbmVyYXRv
+ciIgY29udGVudD0iTWljcm9zb2Z0IFdvcmQgMTQgKGZpbHRlcmVkIG1lZGl1bSkiPg0KPCEtLVtp
+ZiAhbXNvXT48c3R5bGU+dlw6KiB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0Kb1w6KiB7
+YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0Kd1w6KiB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0
+I1ZNTCk7fQ0KLnNoYXBlIHtiZWhhdmlvcjp1cmwoI2RlZmF1bHQjVk1MKTt9DQo8L3N0eWxlPjwh
+W2VuZGlmXS0tPjxzdHlsZT48IS0tDQovKiBGb250IERlZmluaXRpb25zICovDQpAZm9udC1mYWNl
+DQoJe2ZvbnQtZmFtaWx5OkNhbGlicmk7DQoJcGFub3NlLTE6MiAxNSA1IDIgMiAyIDQgMyAyIDQ7
+fQ0KQGZvbnQtZmFjZQ0KCXtmb250LWZhbWlseTpUYWhvbWE7DQoJcGFub3NlLTE6MiAxMSA2IDQg
+MyA1IDQgNCAyIDQ7fQ0KLyogU3R5bGUgRGVmaW5pdGlvbnMgKi8NCnAuTXNvTm9ybWFsLCBsaS5N
+c29Ob3JtYWwsIGRpdi5Nc29Ob3JtYWwNCgl7bWFyZ2luOjBpbjsNCgltYXJnaW4tYm90dG9tOi4w
+MDAxcHQ7DQoJZm9udC1zaXplOjEyLjBwdDsNCglmb250LWZhbWlseToiVGltZXMgTmV3IFJvbWFu
+Iiwic2VyaWYiO30NCmE6bGluaywgc3Bhbi5Nc29IeXBlcmxpbmsNCgl7bXNvLXN0eWxlLXByaW9y
+aXR5Ojk5Ow0KCWNvbG9yOmJsdWU7DQoJdGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZTt9DQphOnZp
+c2l0ZWQsIHNwYW4uTXNvSHlwZXJsaW5rRm9sbG93ZWQNCgl7bXNvLXN0eWxlLXByaW9yaXR5Ojk5
+Ow0KCWNvbG9yOnB1cnBsZTsNCgl0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lO30NCnANCgl7bXNv
+LXN0eWxlLXByaW9yaXR5Ojk5Ow0KCW1zby1tYXJnaW4tdG9wLWFsdDphdXRvOw0KCW1hcmdpbi1y
+aWdodDowaW47DQoJbXNvLW1hcmdpbi1ib3R0b20tYWx0OmF1dG87DQoJbWFyZ2luLWxlZnQ6MGlu
+Ow0KCWZvbnQtc2l6ZToxMi4wcHQ7DQoJZm9udC1mYW1pbHk6IlRpbWVzIE5ldyBSb21hbiIsInNl
+cmlmIjt9DQpzcGFuLkVtYWlsU3R5bGUxOA0KCXttc28tc3R5bGUtdHlwZTpwZXJzb25hbC1yZXBs
+eTsNCglmb250LWZhbWlseToiQ2FsaWJyaSIsInNhbnMtc2VyaWYiOw0KCWNvbG9yOiMxRjQ5N0Q7
+fQ0KLk1zb0NocERlZmF1bHQNCgl7bXNvLXN0eWxlLXR5cGU6ZXhwb3J0LW9ubHk7DQoJZm9udC1m
+YW1pbHk6IkNhbGlicmkiLCJzYW5zLXNlcmlmIjt9DQpAcGFnZSBXb3JkU2VjdGlvbjENCgl7c2l6
+ZTo4LjVpbiAxMS4waW47DQoJbWFyZ2luOjEuMGluIDEuMGluIDEuMGluIDEuMGluO30NCmRpdi5X
+b3JkU2VjdGlvbjENCgl7cGFnZTpXb3JkU2VjdGlvbjE7fQ0KLS0+PC9zdHlsZT48IS0tW2lmIGd0
+ZSBtc28gOV0+PHhtbD4NCjxvOnNoYXBlZGVmYXVsdHMgdjpleHQ9ImVkaXQiIHNwaWRtYXg9IjEw
+MjYiIC8+DQo8L3htbD48IVtlbmRpZl0tLT48IS0tW2lmIGd0ZSBtc28gOV0+PHhtbD4NCjxvOnNo
+YXBlbGF5b3V0IHY6ZXh0PSJlZGl0Ij4NCjxvOmlkbWFwIHY6ZXh0PSJlZGl0IiBkYXRhPSIxIiAv
+Pg0KPC9vOnNoYXBlbGF5b3V0PjwveG1sPjwhW2VuZGlmXS0tPg0KPC9oZWFkPg0KPGJvZHkgbGFu
+Zz0iRU4tVVMiIGxpbms9ImJsdWUiIHZsaW5rPSJwdXJwbGUiPg0KPGRpdiBjbGFzcz0iV29yZFNl
+Y3Rpb24xIj4NCjxwIGNsYXNzPSJNc29Ob3JtYWwiPjxzcGFuIHN0eWxlPSJmb250LXNpemU6MTEu
+MHB0O2ZvbnQtZmFtaWx5OiZxdW90O0NhbGlicmkmcXVvdDssJnF1b3Q7c2Fucy1zZXJpZiZxdW90
+Oztjb2xvcjojMUY0OTdEIj5NaWNyb3NvZnQgT3V0bG9vayAyMDEwPG86cD48L286cD48L3NwYW4+
+PC9wPg0KPHAgY2xhc3M9Ik1zb05vcm1hbCI+PHNwYW4gc3R5bGU9ImZvbnQtc2l6ZToxMS4wcHQ7
+Zm9udC1mYW1pbHk6JnF1b3Q7Q2FsaWJyaSZxdW90OywmcXVvdDtzYW5zLXNlcmlmJnF1b3Q7O2Nv
+bG9yOiMxRjQ5N0QiPjxvOnA+Jm5ic3A7PC9vOnA+PC9zcGFuPjwvcD4NCjxwIGNsYXNzPSJNc29O
+b3JtYWwiPjxiPjxzcGFuIHN0eWxlPSJmb250LXNpemU6MTAuMHB0O2ZvbnQtZmFtaWx5OiZxdW90
+O1RhaG9tYSZxdW90OywmcXVvdDtzYW5zLXNlcmlmJnF1b3Q7Ij5Gcm9tOjwvc3Bhbj48L2I+PHNw
+YW4gc3R5bGU9ImZvbnQtc2l6ZToxMC4wcHQ7Zm9udC1mYW1pbHk6JnF1b3Q7VGFob21hJnF1b3Q7
+LCZxdW90O3NhbnMtc2VyaWYmcXVvdDsiPiBtaWNoYWVsIFttYWlsdG86dGFsa0BvcGVubXJzLm9y
+Z10NCjxicj4NCjxiPlNlbnQ6PC9iPiBNb25kYXksIE9jdG9iZXIgMTMsIDIwMTQgOTozOCBBTTxi
+cj4NCjxiPlRvOjwvYj4gUG93ZXIsIENocmlzPGJyPg0KPGI+U3ViamVjdDo8L2I+IFtQTV0gWW91
+ciBwb3N0IGluICZxdW90O0J1cmdlcmhhdXM6IE5ldyByZXN0YXVyYW50IC8gbHVuY2ggdmVudWUm
+cXVvdDs8bzpwPjwvbzpwPjwvc3Bhbj48L3A+DQo8cCBjbGFzcz0iTXNvTm9ybWFsIj48bzpwPiZu
+YnNwOzwvbzpwPjwvcD4NCjxkaXY+DQo8dGFibGUgY2xhc3M9Ik1zb05vcm1hbFRhYmxlIiBib3Jk
+ZXI9IjAiIGNlbGxzcGFjaW5nPSIwIiBjZWxscGFkZGluZz0iMCI+DQo8dGJvZHk+DQo8dHI+DQo8
+dGQgdmFsaWduPSJ0b3AiIHN0eWxlPSJwYWRkaW5nOjBpbiAwaW4gMGluIDBpbiI+PC90ZD4NCjx0
+ZCBzdHlsZT0icGFkZGluZzowaW4gMGluIDBpbiAwaW4iPg0KPHAgY2xhc3M9Ik1zb05vcm1hbCIg
+c3R5bGU9Im1hcmdpbi1ib3R0b206MTguNzVwdCI+PGEgaHJlZj0iaHR0cDovL2NsLm9wZW5tcnMu
+b3JnL3RyYWNrL2NsaWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5vcmc/cD1leUp6SWpvaWJHbFph
+MVYwZVhoQ1kwMU1SVEZzVURKbVl6VlFNMFpsZWpFNElpd2lkaUk2TVN3aWNDSTZJbnRjSW5WY0lq
+b3pNREF6T1Rrd05TeGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9kSFJ3Y3pwY1hGd3ZYRnhjTDNS
+aGJHc3ViM0JsYm0xeWN5NXZjbWRjWEZ3dmRYTmxjbk5jWEZ3dmJXbGphR0ZsYkZ3aUxGd2lhV1Jj
+SWpwY0ltUTFZbU13TjJOa05EUmpaRFE0TUdNNFlUZzJNemxqWldJMU56Z3pZbVkyWENJc1hDSjFj
+bXhmYVdSelhDSTZXMXdpWWpoa09EZzFNams1TnpkbVpqWTFaV1l5TlRFM09XUmlOR1l5TVdJM056
+RmpOemhqWmpoa09Gd2lYWDBpZlEiIHRhcmdldD0iX2JsYW5rIj48Yj48c3BhbiBzdHlsZT0iZm9u
+dC1zaXplOjEwLjBwdDtmb250LWZhbWlseTomcXVvdDtUYWhvbWEmcXVvdDssJnF1b3Q7c2Fucy1z
+ZXJpZiZxdW90Oztjb2xvcjojMDA2Njk5O3RleHQtZGVjb3JhdGlvbjpub25lIj5taWNoYWVsPC9z
+cGFuPjwvYj48L2E+PGJyPg0KPHNwYW4gc3R5bGU9ImZvbnQtc2l6ZTo4LjVwdDtmb250LWZhbWls
+eTomcXVvdDtUYWhvbWEmcXVvdDssJnF1b3Q7c2Fucy1zZXJpZiZxdW90Oztjb2xvcjojOTk5OTk5
+Ij5PY3RvYmVyIDEzPC9zcGFuPg0KPG86cD48L286cD48L3A+DQo8L3RkPg0KPC90cj4NCjx0cj4N
+Cjx0ZCBjb2xzcGFuPSIyIiBzdHlsZT0icGFkZGluZzozLjc1cHQgMGluIDBpbiAwaW4iPg0KPHAg
+Y2xhc3M9Ik1zb05vcm1hbCIgc3R5bGU9Im1hcmdpbi1ib3R0b206MTguNzVwdCI+PGEgaHJlZj0i
+aHR0cDovL2NsLm9wZW5tcnMub3JnL3RyYWNrL2NsaWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5v
+cmc/cD1leUp6SWpvaVVFUklTVU55UjNsVk1EZEJWVmhwV25SM1dXeDRNV05zVFc1Wklpd2lkaUk2
+TVN3aWNDSTZJbnRjSW5WY0lqb3pNREF6T1Rrd05TeGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9k
+SFJ3Y3pwY1hGd3ZYRnhjTDNSaGJHc3ViM0JsYm0xeWN5NXZjbWRjWEZ3dmRGeGNYQzlpZFhKblpY
+Sm9ZWFZ6TFc1bGR5MXlaWE4wWVhWeVlXNTBMV3gxYm1Ob0xYWmxiblZsWEZ4Y0x6WTNNbHhjWEM4
+elhDSXNYQ0pwWkZ3aU9sd2laRFZpWXpBM1kyUTBOR05rTkRnd1l6aGhPRFl6T1dObFlqVTNPRE5p
+WmpaY0lpeGNJblZ5YkY5cFpITmNJanBiWENKaU56WmlZamswWlRGaU56STVaVGsyWlRSbFpXTTRO
+R1JtTWpRNE1ETXdZall5WVdZeU1HTTBYQ0pkZlNKOSI+PGI+PHNwYW4gc3R5bGU9ImNvbG9yOiMw
+MDY2OTk7dGV4dC1kZWNvcmF0aW9uOm5vbmUiPmh0dHBzOi8vdGFsay5vcGVubXJzLm9yZy90L2J1
+cmdlcmhhdXMtbmV3LXJlc3RhdXJhbnQtbHVuY2gtdmVudWUvNjcyLzM8L3NwYW4+PC9iPjwvYT4N
+CjxvOnA+PC9vOnA+PC9wPg0KPHAgc3R5bGU9Im1hcmdpbi10b3A6MGluIj5Mb29rcyBsaWtlIHlv
+dXIgcmVwbHktYnktZW1haWwgd2Fzbid0IHByb2Nlc3NlZCBjb3JyZWN0bHkgYnkgb3VyIHNvZnR3
+YXJlLiBDYW4geW91IGxldCBtZSBrbm93IHdoYXQgdmVyc2lvbi9PUyBvZiB3aGF0IGVtYWlsIHBy
+b2dyYW0geW91J3JlIHVzaW5nPyBXZSB3aWxsIHdhbnQgdG8gdHJ5IHRvIGZpeCB0aGUgYnVnLiA6
+c21pbGU6PG86cD48L286cD48L3A+DQo8cCBzdHlsZT0ibWFyZ2luLXRvcDowaW4iPlRoYW5rcyE8
+bzpwPjwvbzpwPjwvcD4NCjwvdGQ+DQo8L3RyPg0KPC90Ym9keT4NCjwvdGFibGU+DQo8ZGl2IGNs
+YXNzPSJNc29Ob3JtYWwiIGFsaWduPSJjZW50ZXIiIHN0eWxlPSJ0ZXh0LWFsaWduOmNlbnRlciI+
+DQo8aHIgc2l6ZT0iMSIgd2lkdGg9IjEwMCUiIGFsaWduPSJjZW50ZXIiPg0KPC9kaXY+DQo8ZGl2
+Pg0KPHA+PHNwYW4gc3R5bGU9ImNvbG9yOiM2NjY2NjYiPlRvIHJlc3BvbmQsIHJlcGx5IHRvIHRo
+aXMgZW1haWwgb3IgdmlzaXQgPGEgaHJlZj0iaHR0cDovL2NsLm9wZW5tcnMub3JnL3RyYWNrL2Ns
+aWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5vcmc/cD1leUp6SWpvaWVYaDJWbnBGTUhSMU1uRm5a
+RWR1TlhFd01GcFFPVlp0VFZvNElpd2lkaUk2TVN3aWNDSTZJbnRjSW5WY0lqb3pNREF6T1Rrd05T
+eGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9kSFJ3Y3pwY1hGd3ZYRnhjTDNSaGJHc3ViM0JsYm0x
+eWN5NXZjbWRjWEZ3dmRGeGNYQzk1YjNWeUxYQnZjM1F0YVc0dFluVnlaMlZ5YUdGMWN5MXVaWGN0
+Y21WemRHRjFjbUZ1ZEMxc2RXNWphQzEyWlc1MVpWeGNYQzgyTnpSY1hGd3ZNVndpTEZ3aWFXUmNJ
+anBjSW1RMVltTXdOMk5rTkRSalpEUTRNR000WVRnMk16bGpaV0kxTnpnelltWTJYQ0lzWENKMWNt
+eGZhV1J6WENJNlcxd2lZamMyWW1JNU5HVXhZamN5T1dVNU5tVTBaV1ZqT0RSa1pqSTBPREF6TUdJ
+Mk1tRm1NakJqTkZ3aVhYMGlmUSI+DQo8Yj48c3BhbiBzdHlsZT0iY29sb3I6IzAwNjY5OTt0ZXh0
+LWRlY29yYXRpb246bm9uZSI+aHR0cHM6Ly90YWxrLm9wZW5tcnMub3JnL3QveW91ci1wb3N0LWlu
+LWJ1cmdlcmhhdXMtbmV3LXJlc3RhdXJhbnQtbHVuY2gtdmVudWUvNjc0LzE8L3NwYW4+PC9iPjwv
+YT4gaW4geW91ciBicm93c2VyLjxvOnA+PC9vOnA+PC9zcGFuPjwvcD4NCjwvZGl2Pg0KPGRpdj4N
+CjxwPjxzcGFuIHN0eWxlPSJjb2xvcjojNjY2NjY2Ij5UbyB1bnN1YnNjcmliZSBmcm9tIHRoZXNl
+IGVtYWlscywgdmlzaXQgeW91ciA8YSBocmVmPSJodHRwOi8vY2wub3Blbm1ycy5vcmcvdHJhY2sv
+Y2xpY2svMzAwMzk5MDUvdGFsay5vcGVubXJzLm9yZz9wPWV5SnpJam9pZFV4dVdsZzVWRmMwT1da
+V1MwWTRiRmRMZG1seVdHc3hUVjl6SWl3aWRpSTZNU3dpY0NJNkludGNJblZjSWpvek1EQXpPVGt3
+TlN4Y0luWmNJam94TEZ3aWRYSnNYQ0k2WENKb2RIUndjenBjWEZ3dlhGeGNMM1JoYkdzdWIzQmxi
+bTF5Y3k1dmNtZGNYRnd2YlhsY1hGd3ZjSEpsWm1WeVpXNWpaWE5jSWl4Y0ltbGtYQ0k2WENKa05X
+SmpNRGRqWkRRMFkyUTBPREJqT0dFNE5qTTVZMlZpTlRjNE0ySm1ObHdpTEZ3aWRYSnNYMmxrYzF3
+aU9sdGNJbUk0TVdVd1pqQTFORFk1TkRNME56Z3lNMkZtTWpBMk5qRmpaamMzWkdOaU4yTmhZemRt
+TWpKY0lsMTlJbjAiPg0KPGI+PHNwYW4gc3R5bGU9ImNvbG9yOiMwMDY2OTk7dGV4dC1kZWNvcmF0
+aW9uOm5vbmUiPnVzZXIgcHJlZmVyZW5jZXM8L3NwYW4+PC9iPjwvYT4uPG86cD48L286cD48L3Nw
+YW4+PC9wPg0KPC9kaXY+DQo8L2Rpdj4NCjxwIGNsYXNzPSJNc29Ob3JtYWwiPjxpbWcgYm9yZGVy
+PSIwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBpZD0iX3gwMDAwX2kxMDI2IiBzcmM9Imh0dHA6Ly9j
+bC5vcGVubXJzLm9yZy90cmFjay9vcGVuLnBocD91PTMwMDM5OTA1JmFtcDtpZD1kNWJjMDdjZDQ0
+Y2Q0ODBjOGE4NjM5Y2ViNTc4M2JmNiI+PG86cD48L286cD48L3A+DQo8L2Rpdj4NCjwvYm9keT4N
+CjwvaHRtbD4NCg==
+
+--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_--
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index c706e418d26..15863d444f8 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -57,7 +57,7 @@ describe ApplicationHelper do
it 'returns an url for the avatar' do
project = create(:project, avatar: File.open(avatar_file_path))
- avatar_url = "http://localhost/uploads/project/avatar/#{project.id}/banana_sample.gif"
+ avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/project/avatar/#{project.id}/banana_sample.gif"
expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s).
to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />"
end
@@ -67,7 +67,7 @@ describe ApplicationHelper do
allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true)
- avatar_url = 'http://localhost' + namespace_project_avatar_path(project.namespace, project)
+ avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}"
expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s).to match(
image_tag(avatar_url))
end
diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb
index 5368e5fab06..1d494edcd3b 100644
--- a/spec/helpers/gitlab_markdown_helper_spec.rb
+++ b/spec/helpers/gitlab_markdown_helper_spec.rb
@@ -113,7 +113,7 @@ describe GitlabMarkdownHelper do
it 'replaces commit message with emoji to link' do
actual = link_to_gfm(':book:Book', '/foo')
expect(actual).
- to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://localhost/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>)
+ to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://#{Gitlab.config.gitlab.host}/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>)
end
end
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index 2f9291afc3f..77841e85223 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -85,4 +85,45 @@ describe PreferencesHelper do
and_return(double('user', messages))
end
end
+
+ describe '#default_project_view' do
+ context 'user not signed in' do
+ before do
+ helper.instance_variable_set(:@project, project)
+ stub_user
+ end
+
+ context 'when repository is empty' do
+ let(:project) { create(:project_empty_repo, :public) }
+
+ it 'returns activity if user has repository access' do
+ allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(true)
+
+ expect(helper.default_project_view).to eq('activity')
+ end
+
+ it 'returns activity if user does not have repository access' do
+ allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(false)
+
+ expect(helper.default_project_view).to eq('activity')
+ end
+ end
+
+ context 'when repository is not empty' do
+ let(:project) { create(:project, :public) }
+
+ it 'returns readme if user has repository access' do
+ allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(true)
+
+ expect(helper.default_project_view).to eq('readme')
+ end
+
+ it 'returns activity if user does not have repository access' do
+ allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(false)
+
+ expect(helper.default_project_view).to eq('activity')
+ end
+ end
+ end
+ end
end
diff --git a/spec/javascripts/activities_spec.js.es6 b/spec/javascripts/activities_spec.js.es6
index 9d855ef1060..8640cd44085 100644
--- a/spec/javascripts/activities_spec.js.es6
+++ b/spec/javascripts/activities_spec.js.es6
@@ -35,7 +35,7 @@
describe('Activities', () => {
beforeEach(() => {
fixture.load(fixtureTemplate);
- new Activities();
+ new gl.Activities();
});
for(let i = 0; i < filters.length; i++) {
diff --git a/spec/javascripts/build_spec.js.es6 b/spec/javascripts/build_spec.js.es6
index 370944b6a8c..e21e5844a26 100644
--- a/spec/javascripts/build_spec.js.es6
+++ b/spec/javascripts/build_spec.js.es6
@@ -1,5 +1,7 @@
/* global Build */
/* eslint-disable no-new */
+//= require lib/utils/timeago
+//= require lib/utils/datetime_utility
//= require build
//= require breakpoints
//= require jquery.nicescroll
@@ -24,7 +26,15 @@
});
describe('setup', function () {
+ const removeDate = new Date();
+ removeDate.setUTCFullYear(removeDate.getUTCFullYear() + 1);
+ // give the test three days to run
+ removeDate.setTime(removeDate.getTime() + (3 * 24 * 60 * 60 * 1000));
+
beforeEach(function () {
+ const removeDateElement = document.querySelector('.js-artifacts-remove');
+ removeDateElement.innerText = removeDate.toString();
+
this.build = new Build();
});
@@ -54,6 +64,11 @@
expect($('.build-job[data-stage="test"]').is(':visible')).toBe(false);
expect($('.build-job[data-stage="deploy"]').is(':visible')).toBe(false);
});
+
+ it('displays the remove date correctly', function () {
+ const removeDateElement = document.querySelector('.js-artifacts-remove');
+ expect(removeDateElement.innerText.trim()).toBe('1 year');
+ });
});
describe('initial build trace', function () {
diff --git a/spec/javascripts/environments/environment_actions_spec.js.es6 b/spec/javascripts/environments/environment_actions_spec.js.es6
new file mode 100644
index 00000000000..c9ac7a73fd0
--- /dev/null
+++ b/spec/javascripts/environments/environment_actions_spec.js.es6
@@ -0,0 +1,37 @@
+//= require vue
+//= require environments/components/environment_actions
+
+describe('Actions Component', () => {
+ fixture.preload('environments/element.html');
+
+ beforeEach(() => {
+ fixture.load('environments/element.html');
+ });
+
+ it('Should render a dropdown with the provided actions', () => {
+ const actionsMock = [
+ {
+ name: 'bar',
+ play_path: 'https://gitlab.com/play',
+ },
+ {
+ name: 'foo',
+ play_path: '#',
+ },
+ ];
+
+ const component = new window.gl.environmentsList.ActionsComponent({
+ el: document.querySelector('.test-dom-element'),
+ propsData: {
+ actions: actionsMock,
+ },
+ });
+
+ expect(
+ component.$el.querySelectorAll('.dropdown-menu li').length
+ ).toEqual(actionsMock.length);
+ expect(
+ component.$el.querySelector('.dropdown-menu li a').getAttribute('href')
+ ).toEqual(actionsMock[0].play_path);
+ });
+});
diff --git a/spec/javascripts/environments/environment_external_url_spec.js.es6 b/spec/javascripts/environments/environment_external_url_spec.js.es6
new file mode 100644
index 00000000000..156506ef28f
--- /dev/null
+++ b/spec/javascripts/environments/environment_external_url_spec.js.es6
@@ -0,0 +1,22 @@
+//= require vue
+//= require environments/components/environment_external_url
+
+describe('External URL Component', () => {
+ fixture.preload('environments/element.html');
+ beforeEach(() => {
+ fixture.load('environments/element.html');
+ });
+
+ it('should link to the provided external_url', () => {
+ const externalURL = 'https://gitlab.com';
+ const component = new window.gl.environmentsList.ExternalUrlComponent({
+ el: document.querySelector('.test-dom-element'),
+ propsData: {
+ external_url: externalURL,
+ },
+ });
+
+ expect(component.$el.getAttribute('href')).toEqual(externalURL);
+ expect(component.$el.querySelector('fa-external-link')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/environments/environment_item_spec.js.es6 b/spec/javascripts/environments/environment_item_spec.js.es6
new file mode 100644
index 00000000000..3c15e3b7719
--- /dev/null
+++ b/spec/javascripts/environments/environment_item_spec.js.es6
@@ -0,0 +1,215 @@
+//= require vue
+//= require environments/components/environment_item
+
+describe('Environment item', () => {
+ fixture.preload('environments/table.html');
+ beforeEach(() => {
+ fixture.load('environments/table.html');
+ });
+
+ describe('When item is folder', () => {
+ let mockItem;
+ let component;
+
+ beforeEach(() => {
+ mockItem = {
+ name: 'review',
+ children: [
+ {
+ name: 'review-app',
+ id: 1,
+ state: 'available',
+ external_url: '',
+ last_deployment: {},
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-10T15:55:58.778Z',
+ },
+ {
+ name: 'production',
+ id: 2,
+ state: 'available',
+ external_url: '',
+ last_deployment: {},
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-10T15:55:58.778Z',
+ },
+ ],
+ };
+
+ component = new window.gl.environmentsList.EnvironmentItem({
+ el: document.querySelector('tr#environment-row'),
+ propsData: {
+ model: mockItem,
+ toggleRow: () => {},
+ canCreateDeployment: false,
+ canReadEnvironment: true,
+ },
+ });
+ });
+
+ it('Should render folder icon and name', () => {
+ expect(component.$el.querySelector('.folder-name').textContent).toContain(mockItem.name);
+ expect(component.$el.querySelector('.folder-icon')).toBeDefined();
+ });
+
+ it('Should render the number of children in a badge', () => {
+ expect(component.$el.querySelector('.folder-name .badge').textContent).toContain(mockItem.children.length);
+ });
+ });
+
+ describe('when item is not folder', () => {
+ let environment;
+ let component;
+
+ beforeEach(() => {
+ environment = {
+ id: 31,
+ name: 'production',
+ state: 'stopped',
+ external_url: 'http://external.com',
+ environment_type: null,
+ last_deployment: {
+ id: 66,
+ iid: 6,
+ sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ ref: {
+ name: 'master',
+ ref_path: 'root/ci-folders/tree/master',
+ },
+ tag: true,
+ 'last?': true,
+ user: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit: {
+ id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ short_id: '500aabcb',
+ title: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ created_at: '2016-11-07T18:28:13.000+00:00',
+ message: 'Update .gitlab-ci.yml',
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ },
+ deployable: {
+ id: 1279,
+ name: 'deploy',
+ build_path: '/root/ci-folders/builds/1279',
+ retry_path: '/root/ci-folders/builds/1279/retry',
+ },
+ manual_actions: [
+ {
+ name: 'action',
+ play_path: '/play',
+ },
+ ],
+ },
+ 'stoppable?': true,
+ environment_path: 'root/ci-folders/environments/31',
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-10T15:55:58.778Z',
+ };
+
+ component = new window.gl.environmentsList.EnvironmentItem({
+ el: document.querySelector('tr#environment-row'),
+ propsData: {
+ model: environment,
+ toggleRow: () => {},
+ canCreateDeployment: true,
+ canReadEnvironment: true,
+ },
+ });
+ });
+
+ it('should render environment name', () => {
+ expect(component.$el.querySelector('.environment-name').textContent).toContain(environment.name);
+ });
+
+ describe('With deployment', () => {
+ it('should render deployment internal id', () => {
+ expect(
+ component.$el.querySelector('.deployment-column span').textContent
+ ).toContain(environment.last_deployment.iid);
+
+ expect(
+ component.$el.querySelector('.deployment-column span').textContent
+ ).toContain('#');
+ });
+
+ describe('With user information', () => {
+ it('should render user avatar with link to profile', () => {
+ expect(
+ component.$el.querySelector('.js-deploy-user-container').getAttribute('href')
+ ).toEqual(environment.last_deployment.user.web_url);
+ });
+ });
+
+ describe('With build url', () => {
+ it('Should link to build url provided', () => {
+ expect(
+ component.$el.querySelector('.build-link').getAttribute('href')
+ ).toEqual(environment.last_deployment.deployable.build_path);
+ });
+
+ it('Should render deployable name and id', () => {
+ expect(
+ component.$el.querySelector('.build-link').getAttribute('href')
+ ).toEqual(environment.last_deployment.deployable.build_path);
+ });
+ });
+
+ describe('With commit information', () => {
+ it('should render commit component', () => {
+ expect(
+ component.$el.querySelector('.js-commit-component')
+ ).toBeDefined();
+ });
+ });
+ });
+
+ describe('With manual actions', () => {
+ it('Should render actions component', () => {
+ expect(
+ component.$el.querySelector('.js-manual-actions-container')
+ ).toBeDefined();
+ });
+ });
+
+ describe('With external URL', () => {
+ it('should render external url component', () => {
+ expect(
+ component.$el.querySelector('.js-external-url-container')
+ ).toBeDefined();
+ });
+ });
+
+ describe('With stop action', () => {
+ it('Should render stop action component', () => {
+ expect(
+ component.$el.querySelector('.js-stop-component-container')
+ ).toBeDefined();
+ });
+ });
+
+ describe('With retry action', () => {
+ it('Should render rollback component', () => {
+ expect(
+ component.$el.querySelector('.js-rollback-component-container')
+ ).toBeDefined();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/environments/environment_rollback_spec.js.es6 b/spec/javascripts/environments/environment_rollback_spec.js.es6
new file mode 100644
index 00000000000..29449bbbd9e
--- /dev/null
+++ b/spec/javascripts/environments/environment_rollback_spec.js.es6
@@ -0,0 +1,48 @@
+//= require vue
+//= require environments/components/environment_rollback
+describe('Rollback Component', () => {
+ fixture.preload('environments/element.html');
+
+ const retryURL = 'https://gitlab.com/retry';
+
+ beforeEach(() => {
+ fixture.load('environments/element.html');
+ });
+
+ it('Should link to the provided retry_url', () => {
+ const component = new window.gl.environmentsList.RollbackComponent({
+ el: document.querySelector('.test-dom-element'),
+ propsData: {
+ retry_url: retryURL,
+ is_last_deployment: true,
+ },
+ });
+
+ expect(component.$el.getAttribute('href')).toEqual(retryURL);
+ });
+
+ it('Should render Re-deploy label when is_last_deployment is true', () => {
+ const component = new window.gl.environmentsList.RollbackComponent({
+ el: document.querySelector('.test-dom-element'),
+ propsData: {
+ retry_url: retryURL,
+ is_last_deployment: true,
+ },
+ });
+
+ expect(component.$el.querySelector('span').textContent).toContain('Re-deploy');
+ });
+
+
+ it('Should render Rollback label when is_last_deployment is false', () => {
+ const component = new window.gl.environmentsList.RollbackComponent({
+ el: document.querySelector('.test-dom-element'),
+ propsData: {
+ retry_url: retryURL,
+ is_last_deployment: false,
+ },
+ });
+
+ expect(component.$el.querySelector('span').textContent).toContain('Rollback');
+ });
+});
diff --git a/spec/javascripts/environments/environment_stop_spec.js.es6 b/spec/javascripts/environments/environment_stop_spec.js.es6
new file mode 100644
index 00000000000..b842be4da61
--- /dev/null
+++ b/spec/javascripts/environments/environment_stop_spec.js.es6
@@ -0,0 +1,28 @@
+//= require vue
+//= require environments/components/environment_stop
+describe('Stop Component', () => {
+ fixture.preload('environments/element.html');
+
+ let stopURL;
+ let component;
+
+ beforeEach(() => {
+ fixture.load('environments/element.html');
+
+ stopURL = '/stop';
+ component = new window.gl.environmentsList.StopComponent({
+ el: document.querySelector('.test-dom-element'),
+ propsData: {
+ stop_url: stopURL,
+ },
+ });
+ });
+
+ it('should link to the provided URL', () => {
+ expect(component.$el.getAttribute('href')).toEqual(stopURL);
+ });
+
+ it('should have a data-confirm attribute', () => {
+ expect(component.$el.getAttribute('data-confirm')).toEqual('Are you sure you want to stop this environment?');
+ });
+});
diff --git a/spec/javascripts/environments/environments_store_spec.js.es6 b/spec/javascripts/environments/environments_store_spec.js.es6
new file mode 100644
index 00000000000..82d9599f372
--- /dev/null
+++ b/spec/javascripts/environments/environments_store_spec.js.es6
@@ -0,0 +1,69 @@
+//= require vue
+//= require environments/stores/environments_store
+//= require ./mock_data
+/* globals environmentsList */
+(() => {
+ beforeEach(() => {
+ gl.environmentsList.EnvironmentsStore.create();
+ });
+
+ describe('Store', () => {
+ it('should start with a blank state', () => {
+ expect(gl.environmentsList.EnvironmentsStore.state.environments.length).toBe(0);
+ expect(gl.environmentsList.EnvironmentsStore.state.stoppedCounter).toBe(0);
+ expect(gl.environmentsList.EnvironmentsStore.state.availableCounter).toBe(0);
+ });
+
+ describe('store environments', () => {
+ beforeEach(() => {
+ gl.environmentsList.EnvironmentsStore.storeEnvironments(environmentsList);
+ });
+
+ it('should count stopped environments and save the count in the state', () => {
+ expect(gl.environmentsList.EnvironmentsStore.state.stoppedCounter).toBe(1);
+ });
+
+ it('should count available environments and save the count in the state', () => {
+ expect(gl.environmentsList.EnvironmentsStore.state.availableCounter).toBe(3);
+ });
+
+ it('should store environments with same environment_type as sibilings', () => {
+ expect(gl.environmentsList.EnvironmentsStore.state.environments.length).toBe(3);
+
+ const parentFolder = gl.environmentsList.EnvironmentsStore.state.environments
+ .filter(env => env.children && env.children.length > 0);
+
+ expect(parentFolder[0].children.length).toBe(2);
+ expect(parentFolder[0].children[0].environment_type).toBe('review');
+ expect(parentFolder[0].children[1].environment_type).toBe('review');
+ expect(parentFolder[0].children[0].name).toBe('test-environment');
+ expect(parentFolder[0].children[1].name).toBe('test-environment-1');
+ });
+
+ it('should sort the environments alphabetically', () => {
+ const { environments } = gl.environmentsList.EnvironmentsStore.state;
+
+ expect(environments[0].name).toBe('production');
+ expect(environments[1].name).toBe('review');
+ expect(environments[1].children[0].name).toBe('test-environment');
+ expect(environments[1].children[1].name).toBe('test-environment-1');
+ expect(environments[2].name).toBe('review_app');
+ });
+ });
+
+ describe('toggleFolder', () => {
+ beforeEach(() => {
+ gl.environmentsList.EnvironmentsStore.storeEnvironments(environmentsList);
+ });
+
+ it('should toggle the open property for the given environment', () => {
+ gl.environmentsList.EnvironmentsStore.toggleFolder('review');
+
+ const { environments } = gl.environmentsList.EnvironmentsStore.state;
+ const environment = environments.filter(env => env['vue-isChildren'] === true && env.name === 'review');
+
+ expect(environment[0].isOpen).toBe(true);
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/environments/mock_data.js.es6 b/spec/javascripts/environments/mock_data.js.es6
new file mode 100644
index 00000000000..9e16bc3e6a5
--- /dev/null
+++ b/spec/javascripts/environments/mock_data.js.es6
@@ -0,0 +1,135 @@
+/* eslint-disable no-unused-vars */
+const environmentsList = [
+ {
+ id: 31,
+ name: 'production',
+ state: 'available',
+ external_url: 'https://www.gitlab.com',
+ environment_type: null,
+ last_deployment: {
+ id: 64,
+ iid: 5,
+ sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ ref: {
+ name: 'master',
+ ref_url: 'http://localhost:3000/root/ci-folders/tree/master',
+ },
+ tag: false,
+ 'last?': true,
+ user: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit: {
+ id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ short_id: '500aabcb',
+ title: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ created_at: '2016-11-07T18:28:13.000+00:00',
+ message: 'Update .gitlab-ci.yml',
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ },
+ deployable: {
+ id: 1278,
+ name: 'build',
+ build_path: '/root/ci-folders/builds/1278',
+ retry_path: '/root/ci-folders/builds/1278/retry',
+ },
+ manual_actions: [],
+ },
+ 'stoppable?': true,
+ environment_path: '/root/ci-folders/environments/31',
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-07T11:11:16.525Z',
+ },
+ {
+ id: 32,
+ name: 'review_app',
+ state: 'stopped',
+ external_url: 'https://www.gitlab.com',
+ environment_type: null,
+ last_deployment: {
+ id: 64,
+ iid: 5,
+ sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ ref: {
+ name: 'master',
+ ref_url: 'http://localhost:3000/root/ci-folders/tree/master',
+ },
+ tag: false,
+ 'last?': true,
+ user: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit: {
+ id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ short_id: '500aabcb',
+ title: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ created_at: '2016-11-07T18:28:13.000+00:00',
+ message: 'Update .gitlab-ci.yml',
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ },
+ deployable: {
+ id: 1278,
+ name: 'build',
+ build_path: '/root/ci-folders/builds/1278',
+ retry_path: '/root/ci-folders/builds/1278/retry',
+ },
+ manual_actions: [],
+ },
+ 'stoppable?': false,
+ environment_path: '/root/ci-folders/environments/31',
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-07T11:11:16.525Z',
+ },
+ {
+ id: 33,
+ name: 'test-environment',
+ state: 'available',
+ environment_type: 'review',
+ last_deployment: null,
+ 'stoppable?': true,
+ environment_path: '/root/ci-folders/environments/31',
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-07T11:11:16.525Z',
+ },
+ {
+ id: 34,
+ name: 'test-environment-1',
+ state: 'available',
+ environment_type: 'review',
+ last_deployment: null,
+ 'stoppable?': true,
+ environment_path: '/root/ci-folders/environments/31',
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-07T11:11:16.525Z',
+ },
+];
diff --git a/spec/javascripts/fixtures/build.html.haml b/spec/javascripts/fixtures/build.html.haml
index a2bc81c6be7..27136beb14c 100644
--- a/spec/javascripts/fixtures/build.html.haml
+++ b/spec/javascripts/fixtures/build.html.haml
@@ -55,3 +55,8 @@
build_status: 'passed',
build_stage: 'test',
state1: 'buildstate' }}
+
+%p.build-detail-row
+ The artifacts will be removed in
+ %span.js-artifacts-remove
+ 2016-12-19 09:02:12 UTC
diff --git a/spec/javascripts/fixtures/environments/element.html.haml b/spec/javascripts/fixtures/environments/element.html.haml
new file mode 100644
index 00000000000..8d7aeb23356
--- /dev/null
+++ b/spec/javascripts/fixtures/environments/element.html.haml
@@ -0,0 +1 @@
+.test-dom-element
diff --git a/spec/javascripts/fixtures/environments/environments.html.haml b/spec/javascripts/fixtures/environments/environments.html.haml
new file mode 100644
index 00000000000..d89bc50c1f0
--- /dev/null
+++ b/spec/javascripts/fixtures/environments/environments.html.haml
@@ -0,0 +1,9 @@
+%div
+ #environments-list-view{ data: { environments_data: "https://gitlab.com/foo/environments",
+ "can-create-deployment" => "true",
+ "can-read-environment" => "true",
+ "can-create-environment" => "true",
+ "project-environments-path" => "https://gitlab.com/foo/environments",
+ "project-stopped-environments-path" => "https://gitlab.com/foo/environments?scope=stopped",
+ "new-environment-path" => "https://gitlab.com/foo/environments/new",
+ "help-page-path" => "https://gitlab.com/help_page"}}
diff --git a/spec/javascripts/fixtures/environments/table.html.haml b/spec/javascripts/fixtures/environments/table.html.haml
new file mode 100644
index 00000000000..1ea1725c561
--- /dev/null
+++ b/spec/javascripts/fixtures/environments/table.html.haml
@@ -0,0 +1,11 @@
+%table
+ %thead
+ %tr
+ %th Environment
+ %th Last deployment
+ %th Build
+ %th Commit
+ %th
+ %th
+ %tbody
+ %tr#environment-row
diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js
index 8c5afc2ff3c..f38e9cb8ef5 100644
--- a/spec/javascripts/merge_request_widget_spec.js
+++ b/spec/javascripts/merge_request_widget_spec.js
@@ -1,6 +1,7 @@
/* eslint-disable space-before-function-paren, quotes, comma-dangle, dot-notation, indent, quote-props, no-var, padded-blocks, max-len */
/*= require merge_request_widget */
-/*= require lib/utils/timeago.js */
+/*= require lib/utils/timeago */
+/*= require lib/utils/datetime_utility */
(function() {
describe('MergeRequestWidget', function() {
@@ -35,9 +36,9 @@
external_url_formatted: 'test-url.com'
}];
- spyOn(jQuery, 'getJSON').and.callFake((req, cb) => {
+ spyOn(jQuery, 'getJSON').and.callFake(function(req, cb) {
cb(this.ciEnvironmentsStatusData);
- });
+ }.bind(this));
});
it('should call renderEnvironments when the environments property is set', function() {
@@ -54,6 +55,57 @@
});
});
+ describe('renderEnvironments', function() {
+ describe('should render correct timeago', function() {
+ beforeEach(function() {
+ this.environments = [{
+ id: 'test-environment-id',
+ url: 'testurl',
+ deployed_at: new Date().toISOString(),
+ deployed_at_formatted: true
+ }];
+ });
+
+ function getTimeagoText(template) {
+ var el = document.createElement('html');
+ el.innerHTML = template;
+ return el.querySelector('.js-environment-timeago').innerText.trim();
+ }
+
+ it('should render less than a minute ago text', function() {
+ spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
+ expect(getTimeagoText(template)).toBe('less than a minute ago.');
+ });
+
+ this.class.renderEnvironments(this.environments);
+ });
+
+ it('should render about an hour ago text', function() {
+ var oneHourAgo = new Date();
+ oneHourAgo.setHours(oneHourAgo.getHours() - 1);
+
+ this.environments[0].deployed_at = oneHourAgo.toISOString();
+ spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
+ expect(getTimeagoText(template)).toBe('about an hour ago.');
+ });
+
+ this.class.renderEnvironments(this.environments);
+ });
+
+ it('should render about 2 hours ago text', function() {
+ var twoHoursAgo = new Date();
+ twoHoursAgo.setHours(twoHoursAgo.getHours() - 2);
+
+ this.environments[0].deployed_at = twoHoursAgo.toISOString();
+ spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
+ expect(getTimeagoText(template)).toBe('about 2 hours ago.');
+ });
+
+ this.class.renderEnvironments(this.environments);
+ });
+ });
+ });
+
return describe('getCIStatus', function() {
beforeEach(function() {
this.ciStatusData = {
diff --git a/spec/javascripts/pretty_time_spec.js.es6 b/spec/javascripts/pretty_time_spec.js.es6
new file mode 100644
index 00000000000..2e12d45f7a7
--- /dev/null
+++ b/spec/javascripts/pretty_time_spec.js.es6
@@ -0,0 +1,134 @@
+//= require lib/utils/pretty_time
+
+(() => {
+ const PrettyTime = gl.PrettyTime;
+
+ describe('PrettyTime methods', function () {
+ describe('parseSeconds', function () {
+ it('should correctly parse a negative value', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const zeroSeconds = parser(-1000);
+
+ expect(zeroSeconds.minutes).toBe(16);
+ expect(zeroSeconds.hours).toBe(0);
+ expect(zeroSeconds.days).toBe(0);
+ expect(zeroSeconds.weeks).toBe(0);
+ });
+
+ it('should correctly parse a zero value', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const zeroSeconds = parser(0);
+
+ expect(zeroSeconds.minutes).toBe(0);
+ expect(zeroSeconds.hours).toBe(0);
+ expect(zeroSeconds.days).toBe(0);
+ expect(zeroSeconds.weeks).toBe(0);
+ });
+
+ it('should correctly parse a small non-zero second values', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const subOneMinute = parser(10);
+
+ expect(subOneMinute.minutes).toBe(0);
+ expect(subOneMinute.hours).toBe(0);
+ expect(subOneMinute.days).toBe(0);
+ expect(subOneMinute.weeks).toBe(0);
+
+ const aboveOneMinute = parser(100);
+
+ expect(aboveOneMinute.minutes).toBe(1);
+ expect(aboveOneMinute.hours).toBe(0);
+ expect(aboveOneMinute.days).toBe(0);
+ expect(aboveOneMinute.weeks).toBe(0);
+
+ const manyMinutes = parser(1000);
+
+ expect(manyMinutes.minutes).toBe(16);
+ expect(manyMinutes.hours).toBe(0);
+ expect(manyMinutes.days).toBe(0);
+ expect(manyMinutes.weeks).toBe(0);
+ });
+
+ it('should correctly parse large second values', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const aboveOneHour = parser(4800);
+
+ expect(aboveOneHour.minutes).toBe(20);
+ expect(aboveOneHour.hours).toBe(1);
+ expect(aboveOneHour.days).toBe(0);
+ expect(aboveOneHour.weeks).toBe(0);
+
+ const aboveOneDay = parser(110000);
+
+ expect(aboveOneDay.minutes).toBe(33);
+ expect(aboveOneDay.hours).toBe(6);
+ expect(aboveOneDay.days).toBe(3);
+ expect(aboveOneDay.weeks).toBe(0);
+
+ const aboveOneWeek = parser(25000000);
+
+ expect(aboveOneWeek.minutes).toBe(26);
+ expect(aboveOneWeek.hours).toBe(0);
+ expect(aboveOneWeek.days).toBe(3);
+ expect(aboveOneWeek.weeks).toBe(173);
+ });
+ });
+
+ describe('stringifyTime', function () {
+ it('should stringify values with all non-zero units', function () {
+ const timeObject = {
+ weeks: 1,
+ days: 4,
+ hours: 7,
+ minutes: 20,
+ };
+
+ const timeString = PrettyTime.stringifyTime(timeObject);
+
+ expect(timeString).toBe('1w 4d 7h 20m');
+ });
+
+ it('should stringify values with some non-zero units', function () {
+ const timeObject = {
+ weeks: 0,
+ days: 4,
+ hours: 0,
+ minutes: 20,
+ };
+
+ const timeString = PrettyTime.stringifyTime(timeObject);
+
+ expect(timeString).toBe('4d 20m');
+ });
+
+ it('should stringify values with no non-zero units', function () {
+ const timeObject = {
+ weeks: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ };
+
+ const timeString = PrettyTime.stringifyTime(timeObject);
+
+ expect(timeString).toBe('0m');
+ });
+ });
+
+ describe('abbreviateTime', function () {
+ it('should abbreviate stringified times for weeks', function () {
+ const fullTimeString = '1w 3d 4h 5m';
+ expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('1w');
+ });
+
+ it('should abbreviate stringified times for non-weeks', function () {
+ const fullTimeString = '0w 3d 4h 5m';
+ expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('3d');
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/smart_interval_spec.js.es6 b/spec/javascripts/smart_interval_spec.js.es6
new file mode 100644
index 00000000000..651d1f0f975
--- /dev/null
+++ b/spec/javascripts/smart_interval_spec.js.es6
@@ -0,0 +1,159 @@
+//= require jquery
+//= require smart_interval
+
+(() => {
+ const DEFAULT_MAX_INTERVAL = 100;
+ const DEFAULT_STARTING_INTERVAL = 5;
+ const DEFAULT_SHORT_TIMEOUT = 75;
+ const DEFAULT_LONG_TIMEOUT = 1000;
+ const DEFAULT_INCREMENT_FACTOR = 2;
+
+ function createDefaultSmartInterval(config) {
+ const defaultParams = {
+ callback: () => {},
+ startingInterval: DEFAULT_STARTING_INTERVAL,
+ maxInterval: DEFAULT_MAX_INTERVAL,
+ incrementByFactorOf: DEFAULT_INCREMENT_FACTOR,
+ delayStartBy: 0,
+ lazyStart: false,
+ };
+
+ if (config) {
+ _.extend(defaultParams, config);
+ }
+
+ return new gl.SmartInterval(defaultParams);
+ }
+
+ describe('SmartInterval', function () {
+ describe('Increment Interval', function () {
+ beforeEach(function () {
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should increment the interval delay', function (done) {
+ const interval = this.smartInterval;
+ setTimeout(() => {
+ const intervalConfig = this.smartInterval.cfg;
+ const iterationCount = 4;
+ const maxIntervalAfterIterations = intervalConfig.startingInterval *
+ Math.pow(intervalConfig.incrementByFactorOf, (iterationCount - 1)); // 40
+ const currentInterval = interval.getCurrentInterval();
+
+ // Provide some flexibility for performance of testing environment
+ expect(currentInterval).toBeGreaterThan(intervalConfig.startingInterval);
+ expect(currentInterval <= maxIntervalAfterIterations).toBeTruthy();
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT); // 4 iterations, increment by 2x = (5 + 10 + 20 + 40)
+ });
+
+ it('should not increment past maxInterval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ const currentInterval = interval.getCurrentInterval();
+ expect(currentInterval).toBe(interval.cfg.maxInterval);
+
+ done();
+ }, DEFAULT_LONG_TIMEOUT);
+ });
+ });
+
+ describe('Public methods', function () {
+ beforeEach(function () {
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should cancel an interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ interval.cancel();
+
+ const intervalId = interval.state.intervalId;
+ const currentInterval = interval.getCurrentInterval();
+ const intervalLowerLimit = interval.cfg.startingInterval;
+
+ expect(intervalId).toBeUndefined();
+ expect(currentInterval).toBe(intervalLowerLimit);
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should resume an interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ interval.cancel();
+
+ interval.resume();
+
+ const intervalId = interval.state.intervalId;
+
+ expect(intervalId).toBeTruthy();
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+ });
+
+ describe('DOM Events', function () {
+ beforeEach(function () {
+ // This ensures DOM and DOM events are initialized for these specs.
+ fixture.set('<div></div>');
+
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should pause when page is not visible', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ expect(interval.state.intervalId).toBeTruthy();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'hidden';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeUndefined();
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should resume when page is becomes visible at the previous interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ expect(interval.state.intervalId).toBeTruthy();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'hidden';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeUndefined();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'visible';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeTruthy();
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should cancel on page unload', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ $(document).trigger('page:before-unload');
+ expect(interval.state.intervalId).toBeUndefined();
+ expect(interval.getCurrentInterval()).toBe(interval.cfg.startingInterval);
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/subbable_resource_spec.js.es6 b/spec/javascripts/subbable_resource_spec.js.es6
new file mode 100644
index 00000000000..df395296791
--- /dev/null
+++ b/spec/javascripts/subbable_resource_spec.js.es6
@@ -0,0 +1,65 @@
+/* eslint-disable */
+//= vue
+//= vue-resource
+//= require jquery
+//= require subbable_resource
+
+/*
+* Test that each rest verb calls the publish and subscribe function and passes the correct value back
+*
+*
+* */
+((global) => {
+ describe('Subbable Resource', function () {
+ describe('PubSub', function () {
+ beforeEach(function () {
+ this.MockResource = new global.SubbableResource('https://example.com');
+ });
+ it('should successfully add a single subscriber', function () {
+ const callback = () => {};
+ this.MockResource.subscribe(callback);
+
+ expect(this.MockResource.subscribers.length).toBe(1);
+ expect(this.MockResource.subscribers[0]).toBe(callback);
+ });
+
+ it('should successfully add multiple subscribers', function () {
+ const callbackOne = () => {};
+ const callbackTwo = () => {};
+ const callbackThree = () => {};
+
+ this.MockResource.subscribe(callbackOne);
+ this.MockResource.subscribe(callbackTwo);
+ this.MockResource.subscribe(callbackThree);
+
+ expect(this.MockResource.subscribers.length).toBe(3);
+ });
+
+ it('should successfully publish an update to a single subscriber', function () {
+ const state = { myprop: 1 };
+
+ const callbacks = {
+ one: (data) => expect(data.myprop).toBe(2),
+ two: (data) => expect(data.myprop).toBe(2),
+ three: (data) => expect(data.myprop).toBe(2)
+ };
+
+ const spyOne = spyOn(callbacks, 'one');
+ const spyTwo = spyOn(callbacks, 'two');
+ const spyThree = spyOn(callbacks, 'three');
+
+ this.MockResource.subscribe(callbacks.one);
+ this.MockResource.subscribe(callbacks.two);
+ this.MockResource.subscribe(callbacks.three);
+
+ state.myprop++;
+
+ this.MockResource.publish(state);
+
+ expect(spyOne).toHaveBeenCalled();
+ expect(spyTwo).toHaveBeenCalled();
+ expect(spyThree).toHaveBeenCalled();
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/vue_common_components/commit_spec.js.es6 b/spec/javascripts/vue_common_components/commit_spec.js.es6
new file mode 100644
index 00000000000..0e3b82967c1
--- /dev/null
+++ b/spec/javascripts/vue_common_components/commit_spec.js.es6
@@ -0,0 +1,126 @@
+//= require vue_common_component/commit
+
+describe('Commit component', () => {
+ let props;
+ let component;
+
+ it('should render a code-fork icon if it does not represent a tag', () => {
+ fixture.set('<div class="test-commit-container"></div>');
+ component = new window.gl.CommitComponent({
+ el: document.querySelector('.test-commit-container'),
+ propsData: {
+ tag: false,
+ ref: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ short_sha: 'b7836edd',
+ title: 'Commit message',
+ author: {
+ avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
+ web_url: 'https://gitlab.com/jschatz1',
+ username: 'jschatz1',
+ },
+ },
+ });
+
+ expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork');
+ });
+
+ describe('Given all the props', () => {
+ beforeEach(() => {
+ fixture.set('<div class="test-commit-container"></div>');
+
+ props = {
+ tag: true,
+ ref: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ short_sha: 'b7836edd',
+ title: 'Commit message',
+ author: {
+ avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
+ web_url: 'https://gitlab.com/jschatz1',
+ username: 'jschatz1',
+ },
+ };
+
+ component = new window.gl.CommitComponent({
+ el: document.querySelector('.test-commit-container'),
+ propsData: props,
+ });
+ });
+
+ it('should render a tag icon if it represents a tag', () => {
+ expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-tag');
+ });
+
+ it('should render a link to the ref url', () => {
+ expect(component.$el.querySelector('.branch-name').getAttribute('href')).toEqual(props.ref.ref_url);
+ });
+
+ it('should render the ref name', () => {
+ expect(component.$el.querySelector('.branch-name').textContent).toContain(props.ref.name);
+ });
+
+ it('should render the commit short sha with a link to the commit url', () => {
+ expect(component.$el.querySelector('.commit-id').getAttribute('href')).toEqual(props.commit_url);
+ expect(component.$el.querySelector('.commit-id').textContent).toContain(props.short_sha);
+ });
+
+ describe('Given commit title and author props', () => {
+ it('Should render a link to the author profile', () => {
+ expect(
+ component.$el.querySelector('.commit-title .avatar-image-container').getAttribute('href')
+ ).toEqual(props.author.web_url);
+ });
+
+ it('Should render the author avatar with title and alt attributes', () => {
+ expect(
+ component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('title')
+ ).toContain(props.author.username);
+ expect(
+ component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('alt')
+ ).toContain(`${props.author.username}'s avatar`);
+ });
+ });
+
+ it('should render the commit title', () => {
+ expect(
+ component.$el.querySelector('a.commit-row-message').getAttribute('href')
+ ).toEqual(props.commit_url);
+ expect(
+ component.$el.querySelector('a.commit-row-message').textContent
+ ).toContain(props.title);
+ });
+ });
+
+ describe('When commit title is not provided', () => {
+ it('Should render default message', () => {
+ fixture.set('<div class="test-commit-container"></div>');
+ props = {
+ tag: false,
+ ref: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ short_sha: 'b7836edd',
+ title: null,
+ author: {},
+ };
+
+ component = new window.gl.CommitComponent({
+ el: document.querySelector('.test-commit-container'),
+ propsData: props,
+ });
+
+ expect(
+ component.$el.querySelector('.commit-title span').textContent
+ ).toContain('Cant find HEAD commit for this branch');
+ });
+ });
+});
diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
index 84f21631719..ff5dcc06ab3 100644
--- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
+++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
@@ -1124,8 +1124,8 @@ EOT
end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra config should be a hash")
end
- it "returns errors if there are unknown parameters that are hashes, but doesn't have a script" do
- config = YAML.dump({ extra: { services: "test" } })
+ it "returns errors if services configuration is not correct" do
+ config = YAML.dump({ extra: { script: 'rspec', services: "test" } })
expect do
GitlabCiYamlProcessor.new(config, path)
end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra:services config should be an array of strings")
diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb
new file mode 100644
index 00000000000..bfc6818ac08
--- /dev/null
+++ b/spec/lib/gitlab/chat_commands/command_spec.rb
@@ -0,0 +1,94 @@
+require 'spec_helper'
+
+describe Gitlab::ChatCommands::Command, service: true do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+
+ describe '#execute' do
+ subject { described_class.new(project, user, params).execute }
+
+ context 'when no command is available' do
+ let(:params) { { text: 'issue show 1' } }
+ let(:project) { create(:project, has_external_issue_tracker: true) }
+
+ it 'displays 404 messages' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('404 not found')
+ end
+ end
+
+ context 'when an unknown command is triggered' do
+ let(:params) { { command: '/gitlab', text: "unknown command 123" } }
+
+ it 'displays the help message' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('Available commands')
+ expect(subject[:text]).to match('/gitlab issue show')
+ end
+ end
+
+ context 'the user can not create an issue' do
+ let(:params) { { text: "issue create my new issue" } }
+
+ it 'rejects the actions' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('Whoops! That action is not allowed')
+ end
+ end
+
+ context 'issue is successfully created' do
+ let(:params) { { text: "issue create my new issue" } }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ it 'presents the issue' do
+ expect(subject[:text]).to match("my new issue")
+ end
+
+ it 'shows a link to the new issue' do
+ expect(subject[:text]).to match(/\/issues\/\d+/)
+ end
+ end
+
+ context 'when trying to do deployment' do
+ let(:params) { { text: 'deploy staging to production' } }
+ let!(:build) { create(:ci_build, project: project) }
+ let!(:staging) { create(:environment, name: 'staging', project: project) }
+ let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
+ let!(:manual) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production')
+ end
+
+ context 'and user can not create deployment' do
+ it 'returns action' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('Whoops! That action is not allowed')
+ end
+ end
+
+ context 'and user does have deployment permission' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ it 'returns action' do
+ expect(subject[:text]).to include('Deployment from staging to production started')
+ expect(subject[:response_type]).to be(:in_channel)
+ end
+
+ context 'when duplicate action exists' do
+ let!(:manual2) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production')
+ end
+
+ it 'returns error' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to include('Too many actions defined')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb
new file mode 100644
index 00000000000..bd8099c92da
--- /dev/null
+++ b/spec/lib/gitlab/chat_commands/deploy_spec.rb
@@ -0,0 +1,79 @@
+require 'spec_helper'
+
+describe Gitlab::ChatCommands::Deploy, service: true do
+ describe '#execute' do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+ let(:regex_match) { described_class.match('deploy staging to production') }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'if no environment is defined' do
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'with environment' do
+ let!(:staging) { create(:environment, name: 'staging', project: project) }
+ let!(:build) { create(:ci_build, project: project) }
+ let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
+
+ context 'without actions' do
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'with action' do
+ let!(:manual1) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production')
+ end
+
+ it 'returns success result' do
+ expect(subject.type).to eq(:success)
+ expect(subject.message).to include('Deployment from staging to production started')
+ end
+
+ context 'when duplicate action exists' do
+ let!(:manual2) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production')
+ end
+
+ it 'returns error' do
+ expect(subject.type).to eq(:error)
+ expect(subject.message).to include('Too many actions defined')
+ end
+ end
+
+ context 'when teardown action exists' do
+ let!(:teardown) do
+ create(:ci_build, :manual, :teardown_environment,
+ project: project, pipeline: build.pipeline,
+ name: 'teardown', environment: 'production')
+ end
+
+ it 'returns success result' do
+ expect(subject.type).to eq(:success)
+ expect(subject.message).to include('Deployment from staging to production started')
+ end
+ end
+ end
+ end
+ end
+
+ describe 'self.match' do
+ it 'matches the environment' do
+ match = described_class.match('deploy staging to production')
+
+ expect(match[:from]).to eq('staging')
+ expect(match[:to]).to eq('production')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/chat_commands/issue_create_spec.rb b/spec/lib/gitlab/chat_commands/issue_create_spec.rb
new file mode 100644
index 00000000000..df0c317ccea
--- /dev/null
+++ b/spec/lib/gitlab/chat_commands/issue_create_spec.rb
@@ -0,0 +1,52 @@
+require 'spec_helper'
+
+describe Gitlab::ChatCommands::IssueCreate, service: true do
+ describe '#execute' do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+ let(:regex_match) { described_class.match("issue create bird is the word") }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'without description' do
+ it 'creates the issue' do
+ expect { subject }.to change { project.issues.count }.by(1)
+
+ expect(subject.title).to eq('bird is the word')
+ end
+ end
+
+ context 'with description' do
+ let(:description) { "Surfin bird" }
+ let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") }
+
+ it 'creates the issue with description' do
+ subject
+
+ expect(Issue.last.description).to eq(description)
+ end
+ end
+ end
+
+ describe '.match' do
+ it 'matches the title without description' do
+ match = described_class.match("issue create my title")
+
+ expect(match[:title]).to eq('my title')
+ expect(match[:description]).to eq("")
+ end
+
+ it 'matches the title with description' do
+ match = described_class.match("issue create my title\n\ndescription")
+
+ expect(match[:title]).to eq('my title')
+ expect(match[:description]).to eq('description')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/chat_commands/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/issue_show_spec.rb
new file mode 100644
index 00000000000..331a4604e9b
--- /dev/null
+++ b/spec/lib/gitlab/chat_commands/issue_show_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Gitlab::ChatCommands::IssueShow, service: true do
+ describe '#execute' do
+ let(:issue) { create(:issue) }
+ let(:project) { issue.project }
+ let(:user) { issue.author }
+ let(:regex_match) { described_class.match("issue show #{issue.iid}") }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'the issue exists' do
+ it 'returns the issue' do
+ expect(subject.iid).to be issue.iid
+ end
+ end
+
+ context 'the issue does not exist' do
+ let(:regex_match) { described_class.match("issue show 2343242") }
+
+ it "returns nil" do
+ expect(subject).to be_nil
+ end
+ end
+ end
+
+ describe 'self.match' do
+ it 'matches the iid' do
+ match = described_class.match("issue show 123")
+
+ expect(match[:iid]).to eq("123")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/chat_name_token_spec.rb b/spec/lib/gitlab/chat_name_token_spec.rb
new file mode 100644
index 00000000000..8c1e6efa9db
--- /dev/null
+++ b/spec/lib/gitlab/chat_name_token_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+
+describe Gitlab::ChatNameToken, lib: true do
+ context 'when using unknown token' do
+ let(:token) { }
+
+ subject { described_class.new(token).get }
+
+ it 'returns empty data' do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'when storing data' do
+ let(:data) { { key: 'value' } }
+
+ subject { described_class.new(@token) }
+
+ before do
+ @token = described_class.new.store!(data)
+ end
+
+ it 'returns stored data' do
+ expect(subject.get).to eq(data)
+ end
+
+ context 'and after deleting them' do
+ before do
+ subject.delete
+ end
+
+ it 'data are removed' do
+ expect(subject.get).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/credentials/factory_spec.rb b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb
new file mode 100644
index 00000000000..10b4b7a8826
--- /dev/null
+++ b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Build::Credentials::Factory do
+ let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) }
+
+ subject { Gitlab::Ci::Build::Credentials::Factory.new(build).create! }
+
+ class TestProvider
+ def initialize(build); end
+ end
+
+ before do
+ allow_any_instance_of(Gitlab::Ci::Build::Credentials::Factory).to receive(:providers).and_return([TestProvider])
+ end
+
+ context 'when provider is valid' do
+ before do
+ allow_any_instance_of(TestProvider).to receive(:valid?).and_return(true)
+ end
+
+ it 'generates an array of credentials objects' do
+ is_expected.to be_kind_of(Array)
+ is_expected.not_to be_empty
+ expect(subject.first).to be_kind_of(TestProvider)
+ end
+ end
+
+ context 'when provider is not valid' do
+ before do
+ allow_any_instance_of(TestProvider).to receive(:valid?).and_return(false)
+ end
+
+ it 'generates an array without specific credential object' do
+ is_expected.to be_kind_of(Array)
+ is_expected.to be_empty
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/credentials/registry_spec.rb b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb
new file mode 100644
index 00000000000..84e44dd53e2
--- /dev/null
+++ b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Build::Credentials::Registry do
+ let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) }
+ let(:registry_url) { 'registry.example.com:5005' }
+
+ subject { Gitlab::Ci::Build::Credentials::Registry.new(build) }
+
+ before do
+ stub_container_registry_config(host_port: registry_url)
+ end
+
+ it 'contains valid DockerRegistry credentials' do
+ expect(subject).to be_kind_of(Gitlab::Ci::Build::Credentials::Registry)
+
+ expect(subject.username).to eq 'gitlab-ci-token'
+ expect(subject.password).to eq build.token
+ expect(subject.url).to eq registry_url
+ expect(subject.type).to eq 'registry'
+ end
+
+ describe '.valid?' do
+ subject { Gitlab::Ci::Build::Credentials::Registry.new(build).valid? }
+
+ context 'when registry is enabled' do
+ before do
+ stub_container_registry_config(enabled: true)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when registry is disabled' do
+ before do
+ stub_container_registry_config(enabled: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb
index c7726adfd27..5e5c5dcc385 100644
--- a/spec/lib/gitlab/ci/config/entry/global_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb
@@ -60,9 +60,9 @@ describe Gitlab::Ci::Config::Entry::Global do
end
context 'when not composed' do
- describe '#before_script' do
+ describe '#before_script_value' do
it 'returns nil' do
- expect(global.before_script).to be nil
+ expect(global.before_script_value).to be nil
end
end
@@ -82,40 +82,40 @@ describe Gitlab::Ci::Config::Entry::Global do
end
end
- describe '#before_script' do
+ describe '#before_script_value' do
it 'returns correct script' do
- expect(global.before_script).to eq ['ls', 'pwd']
+ expect(global.before_script_value).to eq ['ls', 'pwd']
end
end
- describe '#image' do
+ describe '#image_value' do
it 'returns valid image' do
- expect(global.image).to eq 'ruby:2.2'
+ expect(global.image_value).to eq 'ruby:2.2'
end
end
- describe '#services' do
+ describe '#services_value' do
it 'returns array of services' do
- expect(global.services).to eq ['postgres:9.1', 'mysql:5.5']
+ expect(global.services_value).to eq ['postgres:9.1', 'mysql:5.5']
end
end
- describe '#after_script' do
+ describe '#after_script_value' do
it 'returns after script' do
- expect(global.after_script).to eq ['make clean']
+ expect(global.after_script_value).to eq ['make clean']
end
end
- describe '#variables' do
+ describe '#variables_value' do
it 'returns variables' do
- expect(global.variables).to eq(VAR: 'value')
+ expect(global.variables_value).to eq(VAR: 'value')
end
end
- describe '#stages' do
+ describe '#stages_value' do
context 'when stages key defined' do
it 'returns array of stages' do
- expect(global.stages).to eq %w[build pages]
+ expect(global.stages_value).to eq %w[build pages]
end
end
@@ -126,21 +126,21 @@ describe Gitlab::Ci::Config::Entry::Global do
end
it 'returns array of types as stages' do
- expect(global.stages).to eq %w[test deploy]
+ expect(global.stages_value).to eq %w[test deploy]
end
end
end
- describe '#cache' do
+ describe '#cache_value' do
it 'returns cache configuration' do
- expect(global.cache)
+ expect(global.cache_value)
.to eq(key: 'k', untracked: true, paths: ['public/'])
end
end
- describe '#jobs' do
+ describe '#jobs_value' do
it 'returns jobs configuration' do
- expect(global.jobs).to eq(
+ expect(global.jobs_value).to eq(
rspec: { name: :rspec,
script: %w[rspec ls],
before_script: ['ls', 'pwd'],
@@ -185,21 +185,21 @@ describe Gitlab::Ci::Config::Entry::Global do
end
end
- describe '#variables' do
+ describe '#variables_value' do
it 'returns default value for variables' do
- expect(global.variables).to eq({})
+ expect(global.variables_value).to eq({})
end
end
- describe '#stages' do
+ describe '#stages_value' do
it 'returns an array of default stages' do
- expect(global.stages).to eq %w[build test deploy]
+ expect(global.stages_value).to eq %w[build test deploy]
end
end
- describe '#cache' do
+ describe '#cache_value' do
it 'returns correct cache definition' do
- expect(global.cache).to eq(key: 'a')
+ expect(global.cache_value).to eq(key: 'a')
end
end
end
@@ -217,9 +217,9 @@ describe Gitlab::Ci::Config::Entry::Global do
{ variables: nil, rspec: { script: 'rspec' } }
end
- describe '#variables' do
+ describe '#variables_value' do
it 'undefined entry returns a default value' do
- expect(global.variables).to eq({})
+ expect(global.variables_value).to eq({})
end
end
end
@@ -245,9 +245,9 @@ describe Gitlab::Ci::Config::Entry::Global do
end
end
- describe '#before_script' do
+ describe '#before_script_value' do
it 'returns nil' do
- expect(global.before_script).to be_nil
+ expect(global.before_script_value).to be_nil
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index c05711b6338..fc9b8b86dc4 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -19,8 +19,7 @@ describe Gitlab::Ci::Config::Entry::Job do
let(:entry) { described_class.new(config, name: ''.to_sym) }
it 'reports error' do
- expect(entry.errors)
- .to include "job name can't be blank"
+ expect(entry.errors).to include "job name can't be blank"
end
end
end
@@ -56,6 +55,15 @@ describe Gitlab::Ci::Config::Entry::Job do
end
end
end
+
+ context 'when script is not provided' do
+ let(:config) { { stage: 'test' } }
+
+ it 'returns error about missing script entry' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include "job script can't be blank"
+ end
+ end
end
end
@@ -78,7 +86,7 @@ describe Gitlab::Ci::Config::Entry::Job do
before { entry.compose!(deps) }
let(:config) do
- { image: 'some_image', cache: { key: 'test' } }
+ { script: 'rspec', image: 'some_image', cache: { key: 'test' } }
end
it 'overrides global config' do
diff --git a/spec/lib/gitlab/cycle_analytics/code_event_spec.rb b/spec/lib/gitlab/cycle_analytics/code_event_spec.rb
new file mode 100644
index 00000000000..43f42d1bde8
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/code_event_spec.rb
@@ -0,0 +1,10 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::CodeEvent do
+ it_behaves_like 'default query config' do
+ it 'does not have the default order' do
+ expect(event.order).not_to eq(event.start_time_attrs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb
new file mode 100644
index 00000000000..9aeaa6b3ee8
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb
@@ -0,0 +1,326 @@
+require 'spec_helper'
+
+describe Gitlab::CycleAnalytics::Events do
+ let(:project) { create(:project) }
+ let(:from_date) { 10.days.ago }
+ let(:user) { create(:user, :admin) }
+ let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
+
+ subject { described_class.new(project: project, options: { from: from_date, current_user: user }) }
+
+ before do
+ allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context])
+
+ setup(context)
+ end
+
+ describe '#issue_events' do
+ it 'has the total time' do
+ expect(subject.issue_events.first[:total_time]).not_to be_empty
+ end
+
+ it 'has a title' do
+ expect(subject.issue_events.first[:title]).to eq(context.title)
+ end
+
+ it 'has the URL' do
+ expect(subject.issue_events.first[:url]).not_to be_nil
+ end
+
+ it 'has an iid' do
+ expect(subject.issue_events.first[:iid]).to eq(context.iid.to_s)
+ end
+
+ it 'has a created_at timestamp' do
+ expect(subject.issue_events.first[:created_at]).to end_with('ago')
+ end
+
+ it "has the author's URL" do
+ expect(subject.issue_events.first[:author][:web_url]).not_to be_nil
+ end
+
+ it "has the author's avatar URL" do
+ expect(subject.issue_events.first[:author][:avatar_url]).not_to be_nil
+ end
+
+ it "has the author's name" do
+ expect(subject.issue_events.first[:author][:name]).to eq(context.author.name)
+ end
+ end
+
+ describe '#plan_events' do
+ it 'has a title' do
+ expect(subject.plan_events.first[:title]).not_to be_nil
+ end
+
+ it 'has a sha short ID' do
+ expect(subject.plan_events.first[:short_sha]).not_to be_nil
+ end
+
+ it 'has the URL' do
+ expect(subject.plan_events.first[:commit_url]).not_to be_nil
+ end
+
+ it 'has the total time' do
+ expect(subject.plan_events.first[:total_time]).not_to be_empty
+ end
+
+ it "has the author's URL" do
+ expect(subject.plan_events.first[:author][:web_url]).not_to be_nil
+ end
+
+ it "has the author's avatar URL" do
+ expect(subject.plan_events.first[:author][:avatar_url]).not_to be_nil
+ end
+
+ it "has the author's name" do
+ expect(subject.plan_events.first[:author][:name]).not_to be_nil
+ end
+ end
+
+ describe '#code_events' do
+ before do
+ create_commit_referencing_issue(context)
+ end
+
+ it 'has the total time' do
+ expect(subject.code_events.first[:total_time]).not_to be_empty
+ end
+
+ it 'has a title' do
+ expect(subject.code_events.first[:title]).to eq('Awesome merge_request')
+ end
+
+ it 'has an iid' do
+ expect(subject.code_events.first[:iid]).to eq(context.iid.to_s)
+ end
+
+ it 'has a created_at timestamp' do
+ expect(subject.code_events.first[:created_at]).to end_with('ago')
+ end
+
+ it "has the author's URL" do
+ expect(subject.code_events.first[:author][:web_url]).not_to be_nil
+ end
+
+ it "has the author's avatar URL" do
+ expect(subject.code_events.first[:author][:avatar_url]).not_to be_nil
+ end
+
+ it "has the author's name" do
+ expect(subject.code_events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ end
+ end
+
+ describe '#test_events' do
+ let(:merge_request) { MergeRequest.first }
+ let!(:pipeline) do
+ create(:ci_pipeline,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha,
+ project: context.project)
+ end
+
+ before do
+ create(:ci_build, pipeline: pipeline, status: :success, author: user)
+ create(:ci_build, pipeline: pipeline, status: :success, author: user)
+
+ pipeline.run!
+ pipeline.succeed!
+ end
+
+ it 'has the name' do
+ expect(subject.test_events.first[:name]).not_to be_nil
+ end
+
+ it 'has the ID' do
+ expect(subject.test_events.first[:id]).not_to be_nil
+ end
+
+ it 'has the URL' do
+ expect(subject.test_events.first[:url]).not_to be_nil
+ end
+
+ it 'has the branch name' do
+ expect(subject.test_events.first[:branch]).not_to be_nil
+ end
+
+ it 'has the branch URL' do
+ expect(subject.test_events.first[:branch][:url]).not_to be_nil
+ end
+
+ it 'has the short SHA' do
+ expect(subject.test_events.first[:short_sha]).not_to be_nil
+ end
+
+ it 'has the commit URL' do
+ expect(subject.test_events.first[:commit_url]).not_to be_nil
+ end
+
+ it 'has the date' do
+ expect(subject.test_events.first[:date]).not_to be_nil
+ end
+
+ it 'has the total time' do
+ expect(subject.test_events.first[:total_time]).not_to be_empty
+ end
+ end
+
+ describe '#review_events' do
+ let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
+
+ it 'has the total time' do
+ expect(subject.review_events.first[:total_time]).not_to be_empty
+ end
+
+ it 'has a title' do
+ expect(subject.review_events.first[:title]).to eq('Awesome merge_request')
+ end
+
+ it 'has an iid' do
+ expect(subject.review_events.first[:iid]).to eq(context.iid.to_s)
+ end
+
+ it 'has the URL' do
+ expect(subject.review_events.first[:url]).not_to be_nil
+ end
+
+ it 'has a state' do
+ expect(subject.review_events.first[:state]).not_to be_nil
+ end
+
+ it 'has a created_at timestamp' do
+ expect(subject.review_events.first[:created_at]).not_to be_nil
+ end
+
+ it "has the author's URL" do
+ expect(subject.review_events.first[:author][:web_url]).not_to be_nil
+ end
+
+ it "has the author's avatar URL" do
+ expect(subject.review_events.first[:author][:avatar_url]).not_to be_nil
+ end
+
+ it "has the author's name" do
+ expect(subject.review_events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ end
+ end
+
+ describe '#staging_events' do
+ let(:merge_request) { MergeRequest.first }
+ let!(:pipeline) do
+ create(:ci_pipeline,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha,
+ project: context.project)
+ end
+
+ before do
+ create(:ci_build, pipeline: pipeline, status: :success, author: user)
+ create(:ci_build, pipeline: pipeline, status: :success, author: user)
+
+ pipeline.run!
+ pipeline.succeed!
+
+ merge_merge_requests_closing_issue(context)
+ deploy_master
+ end
+
+ it 'has the name' do
+ expect(subject.staging_events.first[:name]).not_to be_nil
+ end
+
+ it 'has the ID' do
+ expect(subject.staging_events.first[:id]).not_to be_nil
+ end
+
+ it 'has the URL' do
+ expect(subject.staging_events.first[:url]).not_to be_nil
+ end
+
+ it 'has the branch name' do
+ expect(subject.staging_events.first[:branch]).not_to be_nil
+ end
+
+ it 'has the branch URL' do
+ expect(subject.staging_events.first[:branch][:url]).not_to be_nil
+ end
+
+ it 'has the short SHA' do
+ expect(subject.staging_events.first[:short_sha]).not_to be_nil
+ end
+
+ it 'has the commit URL' do
+ expect(subject.staging_events.first[:commit_url]).not_to be_nil
+ end
+
+ it 'has the date' do
+ expect(subject.staging_events.first[:date]).not_to be_nil
+ end
+
+ it 'has the total time' do
+ expect(subject.staging_events.first[:total_time]).not_to be_empty
+ end
+
+ it "has the author's URL" do
+ expect(subject.staging_events.first[:author][:web_url]).not_to be_nil
+ end
+
+ it "has the author's avatar URL" do
+ expect(subject.staging_events.first[:author][:avatar_url]).not_to be_nil
+ end
+
+ it "has the author's name" do
+ expect(subject.staging_events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ end
+ end
+
+ describe '#production_events' do
+ let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
+
+ before do
+ merge_merge_requests_closing_issue(context)
+ deploy_master
+ end
+
+ it 'has the total time' do
+ expect(subject.production_events.first[:total_time]).not_to be_empty
+ end
+
+ it 'has a title' do
+ expect(subject.production_events.first[:title]).to eq(context.title)
+ end
+
+ it 'has the URL' do
+ expect(subject.production_events.first[:url]).not_to be_nil
+ end
+
+ it 'has an iid' do
+ expect(subject.production_events.first[:iid]).to eq(context.iid.to_s)
+ end
+
+ it 'has a created_at timestamp' do
+ expect(subject.production_events.first[:created_at]).to end_with('ago')
+ end
+
+ it "has the author's URL" do
+ expect(subject.production_events.first[:author][:web_url]).not_to be_nil
+ end
+
+ it "has the author's avatar URL" do
+ expect(subject.production_events.first[:author][:avatar_url]).not_to be_nil
+ end
+
+ it "has the author's name" do
+ expect(subject.production_events.first[:author][:name]).to eq(context.author.name)
+ end
+ end
+
+ def setup(context)
+ milestone = create(:milestone, project: project)
+ context.update(milestone: milestone)
+ mr = create_merge_request_closing_issue(context)
+
+ ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.sha)
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb
new file mode 100644
index 00000000000..1c5c308da7d
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb
@@ -0,0 +1,10 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::IssueEvent do
+ it_behaves_like 'default query config' do
+ it 'has the default order' do
+ expect(event.order).to eq(event.start_time_attrs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/permissions_spec.rb b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
new file mode 100644
index 00000000000..dc4f7dc69db
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
@@ -0,0 +1,127 @@
+require 'spec_helper'
+
+describe Gitlab::CycleAnalytics::Permissions do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+
+ subject { described_class.get(user: user, project: project) }
+
+ context 'user with no relation to the project' do
+ it 'has no permissions to issue stage' do
+ expect(subject[:issue]).to eq(false)
+ end
+
+ it 'has no permissions to test stage' do
+ expect(subject[:test]).to eq(false)
+ end
+
+ it 'has no permissions to staging stage' do
+ expect(subject[:staging]).to eq(false)
+ end
+
+ it 'has no permissions to production stage' do
+ expect(subject[:production]).to eq(false)
+ end
+
+ it 'has no permissions to code stage' do
+ expect(subject[:code]).to eq(false)
+ end
+
+ it 'has no permissions to review stage' do
+ expect(subject[:review]).to eq(false)
+ end
+
+ it 'has no permissions to plan stage' do
+ expect(subject[:plan]).to eq(false)
+ end
+ end
+
+ context 'user is master' do
+ before do
+ project.team << [user, :master]
+ end
+
+ it 'has permissions to issue stage' do
+ expect(subject[:issue]).to eq(true)
+ end
+
+ it 'has permissions to test stage' do
+ expect(subject[:test]).to eq(true)
+ end
+
+ it 'has permissions to staging stage' do
+ expect(subject[:staging]).to eq(true)
+ end
+
+ it 'has permissions to production stage' do
+ expect(subject[:production]).to eq(true)
+ end
+
+ it 'has permissions to code stage' do
+ expect(subject[:code]).to eq(true)
+ end
+
+ it 'has permissions to review stage' do
+ expect(subject[:review]).to eq(true)
+ end
+
+ it 'has permissions to plan stage' do
+ expect(subject[:plan]).to eq(true)
+ end
+ end
+
+ context 'user has no build permissions' do
+ before do
+ project.team << [user, :guest]
+ end
+
+ it 'has permissions to issue stage' do
+ expect(subject[:issue]).to eq(true)
+ end
+
+ it 'has no permissions to test stage' do
+ expect(subject[:test]).to eq(false)
+ end
+
+ it 'has no permissions to staging stage' do
+ expect(subject[:staging]).to eq(false)
+ end
+ end
+
+ context 'user has no merge request permissions' do
+ before do
+ project.team << [user, :guest]
+ end
+
+ it 'has permissions to issue stage' do
+ expect(subject[:issue]).to eq(true)
+ end
+
+ it 'has no permissions to code stage' do
+ expect(subject[:code]).to eq(false)
+ end
+
+ it 'has no permissions to review stage' do
+ expect(subject[:review]).to eq(false)
+ end
+ end
+
+ context 'user has no issue permissions' do
+ before do
+ project.team << [user, :developer]
+ project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
+ end
+
+ it 'has permissions to code stage' do
+ expect(subject[:code]).to eq(true)
+ end
+
+ it 'has no permissions to issue stage' do
+ expect(subject[:issue]).to eq(false)
+ end
+
+ it 'has no permissions to production stage' do
+ expect(subject[:production]).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb b/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb
new file mode 100644
index 00000000000..d76a255acf5
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb
@@ -0,0 +1,10 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::PlanEvent do
+ it_behaves_like 'default query config' do
+ it 'has the default order' do
+ expect(event.order).to eq(event.start_time_attrs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/production_event_spec.rb b/spec/lib/gitlab/cycle_analytics/production_event_spec.rb
new file mode 100644
index 00000000000..ac17e3b4287
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/production_event_spec.rb
@@ -0,0 +1,10 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::ProductionEvent do
+ it_behaves_like 'default query config' do
+ it 'has the default order' do
+ expect(event.order).to eq(event.start_time_attrs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/review_event_spec.rb b/spec/lib/gitlab/cycle_analytics/review_event_spec.rb
new file mode 100644
index 00000000000..1ff53aa0227
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/review_event_spec.rb
@@ -0,0 +1,10 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::ReviewEvent do
+ it_behaves_like 'default query config' do
+ it 'has the default order' do
+ expect(event.order).to eq(event.start_time_attrs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
new file mode 100644
index 00000000000..7019e4c3351
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+shared_examples 'default query config' do
+ let(:event) { described_class.new(project: double, options: {}) }
+
+ it 'has the start attributes' do
+ expect(event.start_time_attrs).not_to be_nil
+ end
+
+ it 'has the stage attribute' do
+ expect(event.stage).not_to be_nil
+ end
+
+ it 'has the end attributes' do
+ expect(event.end_time_attrs).not_to be_nil
+ end
+
+ it 'has the projection attributes' do
+ expect(event.projections).not_to be_nil
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb b/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb
new file mode 100644
index 00000000000..4862d4765f2
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb
@@ -0,0 +1,10 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::StagingEvent do
+ it_behaves_like 'default query config' do
+ it 'does not have the default order' do
+ expect(event.order).not_to eq(event.start_time_attrs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/test_event_spec.rb b/spec/lib/gitlab/cycle_analytics/test_event_spec.rb
new file mode 100644
index 00000000000..e249db69fc6
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/test_event_spec.rb
@@ -0,0 +1,10 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::TestEvent do
+ it_behaves_like 'default query config' do
+ it 'does not have the default order' do
+ expect(event.order).not_to eq(event.start_time_attrs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/updater_spec.rb b/spec/lib/gitlab/cycle_analytics/updater_spec.rb
new file mode 100644
index 00000000000..eff54cd3692
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/updater_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe Gitlab::CycleAnalytics::Updater do
+ describe 'updates authors' do
+ let(:user) { create(:user) }
+ let(:events) { [{ 'author_id' => user.id }] }
+
+ it 'maps the correct user' do
+ described_class.update!(events, from: 'author_id', to: 'author', klass: User)
+
+ expect(events.first['author']).to eq(user)
+ end
+ end
+
+ describe 'updates builds' do
+ let(:build) { create(:ci_build) }
+ let(:events) { [{ 'id' => build.id }] }
+
+ it 'maps the correct build' do
+ described_class.update!(events, from: 'id', to: 'build', klass: ::Ci::Build)
+
+ expect(events.first['build']).to eq(build)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/email/reply_parser_spec.rb b/spec/lib/gitlab/email/reply_parser_spec.rb
index 6f8e9a4be64..c7a0139d32a 100644
--- a/spec/lib/gitlab/email/reply_parser_spec.rb
+++ b/spec/lib/gitlab/email/reply_parser_spec.rb
@@ -206,5 +206,9 @@ describe Gitlab::Email::ReplyParser, lib: true do
it "properly renders email reply from MS Outlook client" do
expect(test_parse_body(fixture_file("emails/outlook.eml"))).to eq("Microsoft Outlook 2010")
end
+
+ it "properly renders html-only email from MS Outlook" do
+ expect(test_parse_body(fixture_file("emails/outlook_html.eml"))).to eq("Microsoft Outlook 2010")
+ end
end
end
diff --git a/spec/lib/gitlab/file_detector_spec.rb b/spec/lib/gitlab/file_detector_spec.rb
new file mode 100644
index 00000000000..e5ba13bbaf8
--- /dev/null
+++ b/spec/lib/gitlab/file_detector_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe Gitlab::FileDetector do
+ describe '.types_in_paths' do
+ it 'returns the file types for the given paths' do
+ expect(described_class.types_in_paths(%w(README.md CHANGELOG VERSION VERSION))).
+ to eq(%i{readme changelog version})
+ end
+
+ it 'does not include unrecognized file paths' do
+ expect(described_class.types_in_paths(%w(README.md foo.txt))).
+ to eq(%i{readme})
+ end
+ end
+
+ describe '.type_of' do
+ it 'returns the type of a README file' do
+ expect(described_class.type_of('README.md')).to eq(:readme)
+ end
+
+ it 'returns the type of a changelog file' do
+ %w(CHANGELOG HISTORY CHANGES NEWS).each do |file|
+ expect(described_class.type_of(file)).to eq(:changelog)
+ end
+ end
+
+ it 'returns the type of a license file' do
+ %w(LICENSE LICENCE COPYING).each do |file|
+ expect(described_class.type_of(file)).to eq(:license)
+ end
+ end
+
+ it 'returns the type of a version file' do
+ expect(described_class.type_of('VERSION')).to eq(:version)
+ end
+
+ it 'returns the type of a .gitignore file' do
+ expect(described_class.type_of('.gitignore')).to eq(:gitignore)
+ end
+
+ it 'returns the type of a Koding config file' do
+ expect(described_class.type_of('.koding.yml')).to eq(:koding)
+ end
+
+ it 'returns the type of a GitLab CI config file' do
+ expect(described_class.type_of('.gitlab-ci.yml')).to eq(:gitlab_ci)
+ end
+
+ it 'returns the type of an avatar' do
+ %w(logo.gif logo.png logo.jpg).each do |file|
+ expect(described_class.type_of(file)).to eq(:avatar)
+ end
+ end
+
+ it 'returns nil for an unknown file' do
+ expect(described_class.type_of('foo.txt')).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer_spec.rb b/spec/lib/gitlab/github_import/importer_spec.rb
index 7478f86bd28..000b9aa6f83 100644
--- a/spec/lib/gitlab/github_import/importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer_spec.rb
@@ -101,7 +101,6 @@ describe Gitlab::GithubImport::Importer, lib: true do
closed_at: nil,
merged_at: nil,
url: 'https://api.github.com/repos/octocat/Hello-World/pulls/1347',
- labels: [double(name: 'Label #3')],
)
end
@@ -157,8 +156,6 @@ describe Gitlab::GithubImport::Importer, lib: true do
errors: [
{ type: :label, url: "https://api.github.com/repos/octocat/Hello-World/labels/bug", errors: "Validation failed: Title can't be blank, Title is invalid" },
{ type: :issue, url: "https://api.github.com/repos/octocat/Hello-World/issues/1348", errors: "Validation failed: Title can't be blank, Title is too short (minimum is 0 characters)" },
- { type: :pull_request, url: "https://api.github.com/repos/octocat/Hello-World/pulls/1347", errors: "Invalid Repository. Use user/repo format." },
- { type: :pull_request, url: "https://api.github.com/repos/octocat/Hello-World/pulls/1347", errors: "Invalid Repository. Use user/repo format." },
{ type: :wiki, errors: "Gitlab::Shell::Error" },
{ type: :release, url: 'https://api.github.com/repos/octocat/Hello-World/releases/2', errors: "Validation failed: Description can't be blank" }
]
diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb
index c2f1f6b91a1..95339e2f128 100644
--- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb
@@ -144,20 +144,20 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do
end
end
- describe '#valid?' do
+ describe '#pull_request?' do
context 'when mention a pull request' do
let(:raw_data) { double(base_data.merge(pull_request: double)) }
- it 'returns false' do
- expect(issue.valid?).to eq false
+ it 'returns true' do
+ expect(issue.pull_request?).to eq true
end
end
context 'when does not mention a pull request' do
let(:raw_data) { double(base_data.merge(pull_request: nil)) }
- it 'returns true' do
- expect(issue.valid?).to eq true
+ it 'returns false' do
+ expect(issue.pull_request?).to eq false
end
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 02b11bd999a..fe3c39e38db 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -116,6 +116,7 @@ project:
- base_tags
- tag_taggings
- tags
+- chat_services
- creator
- group
- namespace
@@ -127,6 +128,7 @@ project:
- emails_on_push_service
- builds_email_service
- pipelines_email_service
+- mattermost_slash_commands_service
- irker_service
- pivotaltracker_service
- hipchat_service
@@ -188,4 +190,4 @@ award_emoji:
- awardable
- user
priorities:
-- label \ No newline at end of file
+- label
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index aac61d404a8..f8531bf715b 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -259,6 +259,7 @@ Service:
- template
- push_events
- issues_events
+- commit_events
- merge_requests_events
- tag_push_events
- note_events
diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb
index 117a15264da..fd3769d75b5 100644
--- a/spec/lib/gitlab/middleware/go_spec.rb
+++ b/spec/lib/gitlab/middleware/go_spec.rb
@@ -22,7 +22,7 @@ describe Gitlab::Middleware::Go, lib: true do
resp = middleware.call(env)
expect(resp[0]).to eq(200)
expect(resp[1]['Content-Type']).to eq('text/html')
- expected_body = "<!DOCTYPE html><html><head><meta content='localhost/group/project git http://localhost/group/project.git' name='go-import'></head></html>\n"
+ expected_body = "<!DOCTYPE html><html><head><meta content='#{Gitlab.config.gitlab.host}/group/project git http://#{Gitlab.config.gitlab.host}/group/project.git' name='go-import'></head></html>\n"
expect(resp[2].body).to eq([expected_body])
end
end
diff --git a/spec/lib/light_url_builder_spec.rb b/spec/lib/light_url_builder_spec.rb
new file mode 100644
index 00000000000..a826b24419a
--- /dev/null
+++ b/spec/lib/light_url_builder_spec.rb
@@ -0,0 +1,119 @@
+require 'spec_helper'
+
+describe Gitlab::UrlBuilder, lib: true do
+ describe '.build' do
+ context 'when passing a Commit' do
+ it 'returns a proper URL' do
+ commit = build_stubbed(:commit)
+
+ url = described_class.build(commit)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{commit.project.path_with_namespace}/commit/#{commit.id}"
+ end
+ end
+
+ context 'when passing an Issue' do
+ it 'returns a proper URL' do
+ issue = build_stubbed(:issue, iid: 42)
+
+ url = described_class.build(issue)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}"
+ end
+ end
+
+ context 'when passing a MergeRequest' do
+ it 'returns a proper URL' do
+ merge_request = build_stubbed(:merge_request, iid: 42)
+
+ url = described_class.build(merge_request)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}"
+ end
+ end
+
+ context 'when passing a Note' do
+ context 'on a Commit' do
+ it 'returns a proper URL' do
+ note = build_stubbed(:note_on_commit)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}"
+ end
+ end
+
+ context 'on a Commit Diff' do
+ it 'returns a proper URL' do
+ note = build_stubbed(:diff_note_on_commit)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}"
+ end
+ end
+
+ context 'on an Issue' do
+ it 'returns a proper URL' do
+ issue = create(:issue, iid: 42)
+ note = build_stubbed(:note_on_issue, noteable: issue)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}#note_#{note.id}"
+ end
+ end
+
+ context 'on a MergeRequest' do
+ it 'returns a proper URL' do
+ merge_request = create(:merge_request, iid: 42)
+ note = build_stubbed(:note_on_merge_request, noteable: merge_request)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}"
+ end
+ end
+
+ context 'on a MergeRequest Diff' do
+ it 'returns a proper URL' do
+ merge_request = create(:merge_request, iid: 42)
+ note = build_stubbed(:diff_note_on_merge_request, noteable: merge_request)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}"
+ end
+ end
+
+ context 'on a ProjectSnippet' do
+ it 'returns a proper URL' do
+ project_snippet = create(:project_snippet)
+ note = build_stubbed(:note_on_project_snippet, noteable: project_snippet)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{project_snippet.project.path_with_namespace}/snippets/#{note.noteable_id}#note_#{note.id}"
+ end
+ end
+
+ context 'on another object' do
+ it 'returns a proper URL' do
+ project = build_stubbed(:project)
+
+ expect { described_class.build(project) }.
+ to raise_error(NotImplementedError, 'No URL builder defined for Project')
+ end
+ end
+ end
+
+ context 'when passing a WikiPage' do
+ it 'returns a proper URL' do
+ wiki_page = build(:wiki_page)
+ url = described_class.build(wiki_page)
+
+ expect(url).to eq "#{Gitlab.config.gitlab.url}#{wiki_page.wiki.wiki_base_path}/#{wiki_page.slug}"
+ end
+ end
+ end
+end
diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb
index 14bc062ef12..e1877d5fde0 100644
--- a/spec/mailers/emails/profile_spec.rb
+++ b/spec/mailers/emails/profile_spec.rb
@@ -25,7 +25,7 @@ describe Notify do
it 'includes a link for user to set password' do
params = "reset_password_token=#{token}"
is_expected.to have_body_text(
- %r{http://localhost(:\d+)?/users/password/edit\?#{params}}
+ %r{http://#{Gitlab.config.gitlab.host}(:\d+)?/users/password/edit\?#{params}}
)
end
diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index 02d6263094a..219db365a91 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe BroadcastMessage, models: true do
- subject { create(:broadcast_message) }
+ subject { build(:broadcast_message) }
it { is_expected.to be_valid }
diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb
index ae185de9ca3..ef07f2275b1 100644
--- a/spec/models/build_spec.rb
+++ b/spec/models/build_spec.rb
@@ -1052,4 +1052,132 @@ describe Ci::Build, models: true do
end
end
end
+
+ describe '#has_environment?' do
+ subject { build.has_environment? }
+
+ context 'when environment is defined' do
+ before do
+ build.update(environment: 'review')
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when environment is not defined' do
+ before do
+ build.update(environment: nil)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#starts_environment?' do
+ subject { build.starts_environment? }
+
+ context 'when environment is defined' do
+ before do
+ build.update(environment: 'review')
+ end
+
+ context 'no action is defined' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'and start action is defined' do
+ before do
+ build.update(options: { environment: { action: 'start' } } )
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ context 'when environment is not defined' do
+ before do
+ build.update(environment: nil)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#stops_environment?' do
+ subject { build.stops_environment? }
+
+ context 'when environment is defined' do
+ before do
+ build.update(environment: 'review')
+ end
+
+ context 'no action is defined' do
+ it { is_expected.to be_falsey }
+ end
+
+ context 'and stop action is defined' do
+ before do
+ build.update(options: { environment: { action: 'stop' } } )
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ context 'when environment is not defined' do
+ before do
+ build.update(environment: nil)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#last_deployment' do
+ subject { build.last_deployment }
+
+ context 'when multiple deployments are created' do
+ let!(:deployment1) { create(:deployment, deployable: build) }
+ let!(:deployment2) { create(:deployment, deployable: build) }
+
+ it 'returns the latest one' do
+ is_expected.to eq(deployment2)
+ end
+ end
+ end
+
+ describe '#outdated_deployment?' do
+ subject { build.outdated_deployment? }
+
+ context 'when build succeeded' do
+ let(:build) { create(:ci_build, :success) }
+ let!(:deployment) { create(:deployment, deployable: build) }
+
+ context 'current deployment is latest' do
+ it { is_expected.to be_falsey }
+ end
+
+ context 'current deployment is not latest on environment' do
+ let!(:deployment2) { create(:deployment, environment: deployment.environment) }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ context 'when build failed' do
+ let(:build) { create(:ci_build, :failed) }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#expanded_environment_name' do
+ subject { build.expanded_environment_name }
+
+ context 'when environment uses variables' do
+ let(:build) { create(:ci_build, ref: 'master', environment: 'review/$CI_BUILD_REF_NAME') }
+
+ it { is_expected.to eq('review/master') }
+ end
+ end
end
diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb
new file mode 100644
index 00000000000..b02971cab82
--- /dev/null
+++ b/spec/models/chat_name_spec.rb
@@ -0,0 +1,16 @@
+require 'spec_helper'
+
+describe ChatName, models: true do
+ subject { create(:chat_name) }
+
+ it { is_expected.to belong_to(:service) }
+ it { is_expected.to belong_to(:user) }
+
+ it { is_expected.to validate_presence_of(:user) }
+ it { is_expected.to validate_presence_of(:service) }
+ it { is_expected.to validate_presence_of(:team_id) }
+ it { is_expected.to validate_presence_of(:chat_id) }
+
+ it { is_expected.to validate_uniqueness_of(:user_id).scoped_to(:service_id) }
+ it { is_expected.to validate_uniqueness_of(:chat_id).scoped_to(:service_id, :team_id) }
+end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index a37a00f461a..a7e90c8a381 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -4,6 +4,12 @@ describe Ci::Build, models: true do
let(:build) { create(:ci_build) }
let(:test_trace) { 'This is a test' }
+ it { is_expected.to belong_to(:runner) }
+ it { is_expected.to belong_to(:trigger_request) }
+ it { is_expected.to belong_to(:erased_by) }
+
+ it { is_expected.to have_many(:deployments) }
+
describe '#trace' do
it 'obfuscates project runners token' do
allow(build).to receive(:raw_trace).and_return("Test: #{build.project.runners_token}")
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 6e987967ca5..6f84bffe046 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -176,23 +176,25 @@ describe Issue, "Issuable" do
end
describe '#subscribed?' do
+ let(:project) { issue.project }
+
context 'user is not a participant in the issue' do
before { allow(issue).to receive(:participants).with(user).and_return([]) }
it 'returns false when no subcription exists' do
- expect(issue.subscribed?(user)).to be_falsey
+ expect(issue.subscribed?(user, project)).to be_falsey
end
it 'returns true when a subcription exists and subscribed is true' do
- issue.subscriptions.create(user: user, subscribed: true)
+ issue.subscriptions.create(user: user, project: project, subscribed: true)
- expect(issue.subscribed?(user)).to be_truthy
+ expect(issue.subscribed?(user, project)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
- issue.subscriptions.create(user: user, subscribed: false)
+ issue.subscriptions.create(user: user, project: project, subscribed: false)
- expect(issue.subscribed?(user)).to be_falsey
+ expect(issue.subscribed?(user, project)).to be_falsey
end
end
@@ -200,19 +202,19 @@ describe Issue, "Issuable" do
before { allow(issue).to receive(:participants).with(user).and_return([user]) }
it 'returns false when no subcription exists' do
- expect(issue.subscribed?(user)).to be_truthy
+ expect(issue.subscribed?(user, project)).to be_truthy
end
it 'returns true when a subcription exists and subscribed is true' do
- issue.subscriptions.create(user: user, subscribed: true)
+ issue.subscriptions.create(user: user, project: project, subscribed: true)
- expect(issue.subscribed?(user)).to be_truthy
+ expect(issue.subscribed?(user, project)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
- issue.subscriptions.create(user: user, subscribed: false)
+ issue.subscriptions.create(user: user, project: project, subscribed: false)
- expect(issue.subscribed?(user)).to be_falsey
+ expect(issue.subscribed?(user, project)).to be_falsey
end
end
end
diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb
index b7fc5a92497..58f5c164116 100644
--- a/spec/models/concerns/subscribable_spec.rb
+++ b/spec/models/concerns/subscribable_spec.rb
@@ -1,67 +1,128 @@
require 'spec_helper'
describe Subscribable, 'Subscribable' do
- let(:resource) { create(:issue) }
- let(:user) { create(:user) }
+ let(:project) { create(:empty_project) }
+ let(:resource) { create(:issue, project: project) }
+ let(:user_1) { create(:user) }
describe '#subscribed?' do
- it 'returns false when no subcription exists' do
- expect(resource.subscribed?(user)).to be_falsey
- end
+ context 'without project' do
+ it 'returns false when no subscription exists' do
+ expect(resource.subscribed?(user_1)).to be_falsey
+ end
+
+ it 'returns true when a subcription exists and subscribed is true' do
+ resource.subscriptions.create(user: user_1, subscribed: true)
+
+ expect(resource.subscribed?(user_1)).to be_truthy
+ end
- it 'returns true when a subcription exists and subscribed is true' do
- resource.subscriptions.create(user: user, subscribed: true)
+ it 'returns false when a subcription exists and subscribed is false' do
+ resource.subscriptions.create(user: user_1, subscribed: false)
- expect(resource.subscribed?(user)).to be_truthy
+ expect(resource.subscribed?(user_1)).to be_falsey
+ end
end
- it 'returns false when a subcription exists and subscribed is false' do
- resource.subscriptions.create(user: user, subscribed: false)
+ context 'with project' do
+ it 'returns false when no subscription exists' do
+ expect(resource.subscribed?(user_1, project)).to be_falsey
+ end
+
+ it 'returns true when a subcription exists and subscribed is true' do
+ resource.subscriptions.create(user: user_1, project: project, subscribed: true)
+
+ expect(resource.subscribed?(user_1, project)).to be_truthy
+ end
- expect(resource.subscribed?(user)).to be_falsey
+ it 'returns false when a subcription exists and subscribed is false' do
+ resource.subscriptions.create(user: user_1, project: project, subscribed: false)
+
+ expect(resource.subscribed?(user_1, project)).to be_falsey
+ end
end
end
+
describe '#subscribers' do
it 'returns [] when no subcribers exists' do
- expect(resource.subscribers).to be_empty
+ expect(resource.subscribers(project)).to be_empty
end
it 'returns the subscribed users' do
- resource.subscriptions.create(user: user, subscribed: true)
- resource.subscriptions.create(user: create(:user), subscribed: false)
+ user_2 = create(:user)
+ resource.subscriptions.create(user: user_1, subscribed: true)
+ resource.subscriptions.create(user: user_2, project: project, subscribed: true)
+ resource.subscriptions.create(user: create(:user), project: project, subscribed: false)
- expect(resource.subscribers).to eq [user]
+ expect(resource.subscribers(project)).to contain_exactly(user_1, user_2)
end
end
describe '#toggle_subscription' do
- it 'toggles the current subscription state for the given user' do
- expect(resource.subscribed?(user)).to be_falsey
+ context 'without project' do
+ it 'toggles the current subscription state for the given user' do
+ expect(resource.subscribed?(user_1)).to be_falsey
- resource.toggle_subscription(user)
+ resource.toggle_subscription(user_1)
- expect(resource.subscribed?(user)).to be_truthy
+ expect(resource.subscribed?(user_1)).to be_truthy
+ end
+ end
+
+ context 'with project' do
+ it 'toggles the current subscription state for the given user' do
+ expect(resource.subscribed?(user_1, project)).to be_falsey
+
+ resource.toggle_subscription(user_1, project)
+
+ expect(resource.subscribed?(user_1, project)).to be_truthy
+ end
end
end
describe '#subscribe' do
- it 'subscribes the given user' do
- expect(resource.subscribed?(user)).to be_falsey
+ context 'without project' do
+ it 'subscribes the given user' do
+ expect(resource.subscribed?(user_1)).to be_falsey
+
+ resource.subscribe(user_1)
+
+ expect(resource.subscribed?(user_1)).to be_truthy
+ end
+ end
+
+ context 'with project' do
+ it 'subscribes the given user' do
+ expect(resource.subscribed?(user_1, project)).to be_falsey
- resource.subscribe(user)
+ resource.subscribe(user_1, project)
- expect(resource.subscribed?(user)).to be_truthy
+ expect(resource.subscribed?(user_1, project)).to be_truthy
+ end
end
end
describe '#unsubscribe' do
- it 'unsubscribes the given current user' do
- resource.subscriptions.create(user: user, subscribed: true)
- expect(resource.subscribed?(user)).to be_truthy
+ context 'without project' do
+ it 'unsubscribes the given current user' do
+ resource.subscriptions.create(user: user_1, subscribed: true)
+ expect(resource.subscribed?(user_1)).to be_truthy
+
+ resource.unsubscribe(user_1)
+
+ expect(resource.subscribed?(user_1)).to be_falsey
+ end
+ end
+
+ context 'with project' do
+ it 'unsubscribes the given current user' do
+ resource.subscriptions.create(user: user_1, project: project, subscribed: true)
+ expect(resource.subscribed?(user_1, project)).to be_truthy
- resource.unsubscribe(user)
+ resource.unsubscribe(user_1, project)
- expect(resource.subscribed?(user)).to be_falsey
+ expect(resource.subscribed?(user_1, project)).to be_falsey
+ end
end
end
end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 60bbe3fcd72..d06665197db 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -9,6 +9,7 @@ describe Environment, models: true do
it { is_expected.to delegate_method(:last_deployment).to(:deployments).as(:last) }
it { is_expected.to delegate_method(:stop_action).to(:last_deployment) }
+ it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
@@ -187,4 +188,15 @@ describe Environment, models: true do
it { is_expected.to be false }
end
end
+
+ describe '#actions_for' do
+ let(:deployment) { create(:deployment, environment: environment) }
+ let(:pipeline) { deployment.deployable.pipeline }
+ let!(:review_action) { create(:ci_build, :manual, name: 'review-apps', pipeline: pipeline, environment: 'review/$CI_BUILD_REF_NAME' )}
+ let!(:production_action) { create(:ci_build, :manual, name: 'production', pipeline: pipeline, environment: 'production' )}
+
+ it 'returns a list of actions with matching environment' do
+ expect(environment.actions_for('review/master')).to contain_exactly(review_action)
+ end
+ end
end
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index 1260f0b5f42..0c0444e1f2a 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -19,7 +19,7 @@ describe Key, models: true do
describe "#publishable_keys" do
it 'replaces SSH key comment with simple identifier of username + hostname' do
- expect(build(:key, user: user).publishable_key).to include("#{user.name} (localhost)")
+ expect(build(:key, user: user).publishable_key).to include("#{user.name} (#{Gitlab.config.gitlab.host})")
end
end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 12419d6fd5a..4f7c8a36cb5 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -443,6 +443,16 @@ describe Member, models: true do
member.accept_invite!(user)
end
+
+ it "refreshes user's authorized projects", truncate: true do
+ project = member.source
+
+ expect(user.authorized_projects).not_to include(project)
+
+ member.accept_invite!(user)
+
+ expect(user.authorized_projects.reload).to include(project)
+ end
end
describe "#decline_invite!" do
@@ -468,4 +478,16 @@ describe Member, models: true do
expect { member.generate_invite_token }.to change { member.invite_token}
end
end
+
+ describe "destroying a record", truncate: true do
+ it "refreshes user's authorized projects" do
+ project = create(:project, :private)
+ user = create(:user)
+ member = project.team << [user, :reporter]
+
+ member.destroy
+
+ expect(user.authorized_projects).not_to include(project)
+ end
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index fb032a89d50..0b4277b1edd 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -856,13 +856,31 @@ describe MergeRequest, models: true do
context 'when it is only allowed to merge when build is green' do
context 'and a failed pipeline is associated' do
before do
- pipeline.statuses << create(:commit_status, status: 'failed', project: project)
+ pipeline.update(status: 'failed')
allow(subject).to receive(:pipeline) { pipeline }
end
it { expect(subject.mergeable_ci_state?).to be_falsey }
end
+ context 'and a successful pipeline is associated' do
+ before do
+ pipeline.update(status: 'success')
+ allow(subject).to receive(:pipeline) { pipeline }
+ end
+
+ it { expect(subject.mergeable_ci_state?).to be_truthy }
+ end
+
+ context 'and a skipped pipeline is associated' do
+ before do
+ pipeline.update(status: 'skipped')
+ allow(subject).to receive(:pipeline) { pipeline }
+ end
+
+ it { expect(subject.mergeable_ci_state?).to be_truthy }
+ end
+
context 'when no pipeline is associated' do
before do
allow(subject).to receive(:pipeline) { nil }
diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb
index c5ff1941378..47397a822c1 100644
--- a/spec/models/project_group_link_spec.rb
+++ b/spec/models/project_group_link_spec.rb
@@ -14,4 +14,20 @@ describe ProjectGroupLink do
it { should validate_presence_of(:group) }
it { should validate_presence_of(:group_access) }
end
+
+ describe "destroying a record", truncate: true do
+ it "refreshes group users' authorized projects" do
+ project = create(:project, :private)
+ group = create(:group)
+ reporter = create(:user)
+ group_users = group.users
+
+ group.add_reporter(reporter)
+ project.project_group_links.create(group: group)
+ group_users.each { |user| expect(user.authorized_projects).to include(project) }
+
+ project.project_group_links.destroy_all
+ group_users.each { |user| expect(user.authorized_projects).not_to include(project) }
+ end
+ end
end
diff --git a/spec/models/project_services/chat_service_spec.rb b/spec/models/project_services/chat_service_spec.rb
new file mode 100644
index 00000000000..c6a45a3e1be
--- /dev/null
+++ b/spec/models/project_services/chat_service_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe ChatService, models: true do
+ describe "Associations" do
+ it { is_expected.to have_many :chat_names }
+ end
+
+ describe '#valid_token?' do
+ subject { described_class.new }
+
+ it 'is false as it has no token' do
+ expect(subject.valid_token?('wer')).to be_falsey
+ end
+ end
+end
diff --git a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb
index 652804fb444..9b80f0e7296 100644
--- a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb
+++ b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb
@@ -35,9 +35,9 @@ describe GitlabIssueTrackerService, models: true do
end
it 'gives the correct path' do
- expect(@service.project_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues")
- expect(@service.new_issue_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/new")
- expect(@service.issue_url(432)).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/432")
+ expect(@service.project_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues")
+ expect(@service.new_issue_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/new")
+ expect(@service.issue_url(432)).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/432")
end
end
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index 05ee4a08391..f5da967cd14 100644
--- a/spec/models/project_services/jira_service_spec.rb
+++ b/spec/models/project_services/jira_service_spec.rb
@@ -69,6 +69,7 @@ describe JiraService, models: true do
end
describe "Execute" do
+ let(:custom_base_url) { 'http://custom_url' }
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:merge_request) { create(:merge_request) }
@@ -82,20 +83,34 @@ describe JiraService, models: true do
url: 'http://jira.example.com',
username: 'gitlab_jira_username',
password: 'gitlab_jira_password',
- project_key: 'GitLabProject'
+ project_key: 'GitLabProject',
+ jira_issue_transition_id: "custom-id"
)
+ # These stubs are needed to test JiraService#close_issue.
+ # We close the issue then do another request to API to check if it got closed.
+ # Here is stubbed the API return with a closed and an opened issues.
+ open_issue = JIRA::Resource::Issue.new(@jira_service.client, attrs: { "id" => "JIRA-123" })
+ closed_issue = open_issue.dup
+ allow(open_issue).to receive(:resolution).and_return(false)
+ allow(closed_issue).to receive(:resolution).and_return(true)
+ allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue)
+
+ allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-123")
+
@jira_service.save
project_issues_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123'
@project_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/project/GitLabProject'
@transitions_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions'
@comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment'
+ @remote_link_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/remotelink'
WebMock.stub_request(:get, @project_url)
WebMock.stub_request(:get, project_issues_url)
WebMock.stub_request(:post, @transitions_url)
WebMock.stub_request(:post, @comment_url)
+ WebMock.stub_request(:post, @remote_link_url)
end
it "calls JIRA API" do
@@ -106,11 +121,44 @@ describe JiraService, models: true do
).once
end
+ # Check https://developer.atlassian.com/jiradev/jira-platform/guides/other/guide-jira-remote-issue-links/fields-in-remote-issue-links
+ # for more information
+ it "creates Remote Link reference in JIRA for comment" do
+ @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project))
+
+ # Creates comment
+ expect(WebMock).to have_requested(:post, @comment_url)
+
+ # Creates Remote Link in JIRA issue fields
+ expect(WebMock).to have_requested(:post, @remote_link_url).with(
+ body: hash_including(
+ GlobalID: "GitLab",
+ object: {
+ url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/#{merge_request.diff_head_sha}",
+ title: "GitLab: Solved by commit #{merge_request.diff_head_sha}.",
+ icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ status: { resolved: true, icon: { url16x16: "http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png", title: "Closed" } }
+ }
+ )
+ ).once
+ end
+
+ it "does not send comment or remote links to issues already closed" do
+ allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(true)
+
+ @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project))
+
+ expect(WebMock).not_to have_requested(:post, @comment_url)
+ expect(WebMock).not_to have_requested(:post, @remote_link_url)
+ end
+
it "references the GitLab commit/merge request" do
+ stub_config_setting(base_url: custom_base_url)
+
@jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project))
expect(WebMock).to have_requested(:post, @comment_url).with(
- body: /#{Gitlab.config.gitlab.url}\/#{project.path_with_namespace}\/commit\/#{merge_request.diff_head_sha}/
+ body: /#{custom_base_url}\/#{project.path_with_namespace}\/commit\/#{merge_request.diff_head_sha}/
).once
end
@@ -130,11 +178,10 @@ describe JiraService, models: true do
end
it "calls the api with jira_issue_transition_id" do
- @jira_service.jira_issue_transition_id = 'this-is-a-custom-id'
@jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project))
expect(WebMock).to have_requested(:post, @transitions_url).with(
- body: /this-is-a-custom-id/
+ body: /custom-id/
).once
end
diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb
new file mode 100644
index 00000000000..4a1037e950b
--- /dev/null
+++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb
@@ -0,0 +1,99 @@
+require 'spec_helper'
+
+describe MattermostSlashCommandsService, models: true do
+ describe "Associations" do
+ it { is_expected.to respond_to :token }
+ end
+
+ describe '#valid_token?' do
+ subject { described_class.new }
+
+ context 'when the token is empty' do
+ it 'is false' do
+ expect(subject.valid_token?('wer')).to be_falsey
+ end
+ end
+
+ context 'when there is a token' do
+ before do
+ subject.token = '123'
+ end
+
+ it 'accepts equal tokens' do
+ expect(subject.valid_token?('123')).to be_truthy
+ end
+ end
+ end
+
+ describe '#trigger' do
+ subject { described_class.new }
+
+ context 'no token is passed' do
+ let(:params) { Hash.new }
+
+ it 'returns nil' do
+ expect(subject.trigger(params)).to be_nil
+ end
+ end
+
+ context 'with a token passed' do
+ let(:project) { create(:empty_project) }
+ let(:params) { { token: 'token' } }
+
+ before do
+ allow(subject).to receive(:token).and_return('token')
+ end
+
+ context 'no user can be found' do
+ context 'when no url can be generated' do
+ it 'responds with the authorize url' do
+ response = subject.trigger(params)
+
+ expect(response[:response_type]).to eq :ephemeral
+ expect(response[:text]).to start_with ":sweat_smile: Couldn't identify you"
+ end
+ end
+
+ context 'when an auth url can be generated' do
+ let(:params) do
+ {
+ team_domain: 'http://domain.tld',
+ team_id: 'T3423423',
+ user_id: 'U234234',
+ user_name: 'mepmep',
+ token: 'token'
+ }
+ end
+
+ let(:service) do
+ project.create_mattermost_slash_commands_service(
+ properties: { token: 'token' }
+ )
+ end
+
+ it 'generates the url' do
+ response = service.trigger(params)
+
+ expect(response[:text]).to start_with(':wave: Hi there!')
+ end
+ end
+ end
+
+ context 'when the user is authenticated' do
+ let!(:chat_name) { create(:chat_name, service: service) }
+ let(:service) do
+ project.create_mattermost_slash_commands_service(
+ properties: { token: 'token' }
+ )
+ end
+ let(:params) { { token: 'token', team_id: chat_name.team_id, user_id: chat_name.chat_id } }
+
+ it 'triggers the command' do
+ expect_any_instance_of(Gitlab::ChatCommands::Command).to receive(:execute)
+
+ service.trigger(params)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/project_services/slack_service/pipeline_message_spec.rb b/spec/models/project_services/slack_service/pipeline_message_spec.rb
index babb3909f56..363138a9454 100644
--- a/spec/models/project_services/slack_service/pipeline_message_spec.rb
+++ b/spec/models/project_services/slack_service/pipeline_message_spec.rb
@@ -15,7 +15,7 @@ describe SlackService::PipelineMessage do
},
project: { path_with_namespace: 'project_name',
web_url: 'example.gitlab.com' },
- commit: { author_name: 'hacker' }
+ user: { name: 'hacker' }
}
end
@@ -48,7 +48,7 @@ describe SlackService::PipelineMessage do
def build_message(status_text = status)
"<example.gitlab.com|project_name>:" \
- " Pipeline <example.gitlab.com/pipelines/123|97de212e>" \
+ " Pipeline <example.gitlab.com/pipelines/123|#123>" \
" of <example.gitlab.com/commits/develop|develop> branch" \
" by hacker #{status_text} in #{duration} #{'second'.pluralize(duration)}"
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 46fa00a79c4..e183fa88873 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -20,6 +20,7 @@ describe Project, models: true do
it { is_expected.to have_many(:deploy_keys) }
it { is_expected.to have_many(:hooks).dependent(:destroy) }
it { is_expected.to have_many(:protected_branches).dependent(:destroy) }
+ it { is_expected.to have_many(:chat_services) }
it { is_expected.to have_one(:forked_project_link).dependent(:destroy) }
it { is_expected.to have_one(:slack_service).dependent(:destroy) }
it { is_expected.to have_one(:pushover_service).dependent(:destroy) }
@@ -35,6 +36,7 @@ describe Project, models: true do
it { is_expected.to have_one(:hipchat_service).dependent(:destroy) }
it { is_expected.to have_one(:flowdock_service).dependent(:destroy) }
it { is_expected.to have_one(:assembla_service).dependent(:destroy) }
+ it { is_expected.to have_one(:mattermost_slash_commands_service).dependent(:destroy) }
it { is_expected.to have_one(:gemnasium_service).dependent(:destroy) }
it { is_expected.to have_one(:buildkite_service).dependent(:destroy) }
it { is_expected.to have_one(:bamboo_service).dependent(:destroy) }
@@ -700,7 +702,7 @@ describe Project, models: true do
"/uploads/project/avatar/#{project.id}/uploads/avatar.png"
end
- it { should eq "http://localhost#{avatar_path}" }
+ it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" }
end
context 'When avatar file in git' do
@@ -712,7 +714,7 @@ describe Project, models: true do
"/#{project.namespace.name}/#{project.path}/avatar"
end
- it { should eq "http://localhost#{avatar_path}" }
+ it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" }
end
context 'when git repo is empty' do
@@ -1514,7 +1516,7 @@ describe Project, models: true do
members_project.team << [developer, :developer]
members_project.team << [master, :master]
- create(:project_group_link, project: shared_project, group: group)
+ create(:project_group_link, project: shared_project, group: group, group_access: Gitlab::Access::DEVELOPER)
end
it 'returns false for no user' do
@@ -1543,7 +1545,9 @@ describe Project, models: true do
expect(members_project.authorized_for_user?(developer, Gitlab::Access::MASTER)).to be(false)
expect(members_project.authorized_for_user?(master, Gitlab::Access::MASTER)).to be(true)
expect(shared_project.authorized_for_user?(developer, Gitlab::Access::MASTER)).to be(false)
- expect(shared_project.authorized_for_user?(master, Gitlab::Access::MASTER)).to be(true)
+ expect(shared_project.authorized_for_user?(master, Gitlab::Access::MASTER)).to be(false)
+ expect(shared_project.authorized_for_user?(developer, Gitlab::Access::DEVELOPER)).to be(true)
+ expect(shared_project.authorized_for_user?(master, Gitlab::Access::DEVELOPER)).to be(true)
end
end
@@ -1568,7 +1572,7 @@ describe Project, models: true do
end
it 'expires the avatar cache' do
- expect(project.repository).to receive(:expire_avatar_cache).with(project.default_branch)
+ expect(project.repository).to receive(:expire_avatar_cache)
project.change_head(project.default_branch)
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 2470d504c68..04afb8ebc98 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -464,11 +464,7 @@ describe Repository, models: true do
end
end
- describe "#changelog" do
- before do
- repository.send(:cache).expire(:changelog)
- end
-
+ describe "#changelog", caching: true do
it 'accepts changelog' do
expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changelog')])
@@ -500,17 +496,16 @@ describe Repository, models: true do
end
end
- describe "#license_blob" do
+ describe "#license_blob", caching: true do
before do
- repository.send(:cache).expire(:license_blob)
repository.remove_file(user, 'LICENSE', 'Remove LICENSE', 'master')
end
it 'handles when HEAD points to non-existent ref' do
repository.commit_file(user, 'LICENSE', 'Copyright!', 'Add LICENSE', 'master', false)
- rugged = double('rugged')
- expect(rugged).to receive(:head_unborn?).and_return(true)
- expect(repository).to receive(:rugged).and_return(rugged)
+
+ allow(repository).to receive(:file_on_head).
+ and_raise(Rugged::ReferenceError)
expect(repository.license_blob).to be_nil
end
@@ -537,22 +532,18 @@ describe Repository, models: true do
end
end
- describe '#license_key' do
+ describe '#license_key', caching: true do
before do
- repository.send(:cache).expire(:license_key)
repository.remove_file(user, 'LICENSE', 'Remove LICENSE', 'master')
end
- it 'handles when HEAD points to non-existent ref' do
- repository.commit_file(user, 'LICENSE', 'Copyright!', 'Add LICENSE', 'master', false)
- rugged = double('rugged')
- expect(rugged).to receive(:head_unborn?).and_return(true)
- expect(repository).to receive(:rugged).and_return(rugged)
-
+ it 'returns nil when no license is detected' do
expect(repository.license_key).to be_nil
end
- it 'returns nil when no license is detected' do
+ it 'returns nil when the repository does not exist' do
+ expect(repository).to receive(:exists?).and_return(false)
+
expect(repository.license_key).to be_nil
end
@@ -569,7 +560,7 @@ describe Repository, models: true do
end
end
- describe "#gitlab_ci_yml" do
+ describe "#gitlab_ci_yml", caching: true do
it 'returns valid file' do
files = [TestBlob.new('file'), TestBlob.new('.gitlab-ci.yml'), TestBlob.new('copying')]
expect(repository.tree).to receive(:blobs).and_return(files)
@@ -583,7 +574,7 @@ describe Repository, models: true do
end
it 'returns nil for empty repository' do
- expect(repository).to receive(:empty?).and_return(true)
+ allow(repository).to receive(:file_on_head).and_raise(Rugged::ReferenceError)
expect(repository.gitlab_ci_yml).to be_nil
end
end
@@ -778,7 +769,6 @@ describe Repository, models: true do
expect(repository).not_to receive(:expire_emptiness_caches)
expect(repository).to receive(:expire_branches_cache)
expect(repository).to receive(:expire_has_visible_content_cache)
- expect(repository).to receive(:expire_branch_count_cache)
repository.update_branch_with_hooks(user, 'new-feature') { new_rev }
end
@@ -797,7 +787,6 @@ describe Repository, models: true do
expect(empty_repository).to receive(:expire_emptiness_caches)
expect(empty_repository).to receive(:expire_branches_cache)
expect(empty_repository).to receive(:expire_has_visible_content_cache)
- expect(empty_repository).to receive(:expire_branch_count_cache)
empty_repository.commit_file(user, 'CHANGELOG', 'Changelog!',
'Updates file content', 'master', false)
@@ -811,8 +800,7 @@ describe Repository, models: true do
end
it 'returns false when a repository does not exist' do
- expect(repository.raw_repository).to receive(:rugged).
- and_raise(Gitlab::Git::Repository::NoRepository)
+ allow(repository).to receive(:refs_directory_exists?).and_return(false)
expect(repository.exists?).to eq(false)
end
@@ -916,34 +904,6 @@ describe Repository, models: true do
end
end
- describe '#expire_cache' do
- it 'expires all caches' do
- expect(repository).to receive(:expire_branch_cache)
-
- repository.expire_cache
- end
-
- it 'expires the caches for a specific branch' do
- expect(repository).to receive(:expire_branch_cache).with('master')
-
- repository.expire_cache('master')
- end
-
- it 'expires the emptiness caches for an empty repository' do
- expect(repository).to receive(:empty?).and_return(true)
- expect(repository).to receive(:expire_emptiness_caches)
-
- repository.expire_cache
- end
-
- it 'does not expire the emptiness caches for a non-empty repository' do
- expect(repository).to receive(:empty?).and_return(false)
- expect(repository).not_to receive(:expire_emptiness_caches)
-
- repository.expire_cache
- end
- end
-
describe '#expire_root_ref_cache' do
it 'expires the root reference cache' do
repository.root_ref
@@ -1003,12 +963,23 @@ describe Repository, models: true do
describe '#expire_emptiness_caches' do
let(:cache) { repository.send(:cache) }
- it 'expires the caches' do
+ it 'expires the caches for an empty repository' do
+ allow(repository).to receive(:empty?).and_return(true)
+
expect(cache).to receive(:expire).with(:empty?)
expect(repository).to receive(:expire_has_visible_content_cache)
repository.expire_emptiness_caches
end
+
+ it 'does not expire the cache for a non-empty repository' do
+ allow(repository).to receive(:empty?).and_return(false)
+
+ expect(cache).not_to receive(:expire).with(:empty?)
+ expect(repository).not_to receive(:expire_has_visible_content_cache)
+
+ repository.expire_emptiness_caches
+ end
end
describe :skip_merged_commit do
@@ -1120,24 +1091,12 @@ describe Repository, models: true do
repository.before_delete
end
- it 'flushes the tag count cache' do
- expect(repository).to receive(:expire_tag_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the branches cache' do
expect(repository).to receive(:expire_branches_cache)
repository.before_delete
end
- it 'flushes the branch count cache' do
- expect(repository).to receive(:expire_branch_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the root ref cache' do
expect(repository).to receive(:expire_root_ref_cache)
@@ -1162,36 +1121,18 @@ describe Repository, models: true do
allow(repository).to receive(:exists?).and_return(true)
end
- it 'flushes the caches that depend on repository data' do
- expect(repository).to receive(:expire_cache)
-
- repository.before_delete
- end
-
it 'flushes the tags cache' do
expect(repository).to receive(:expire_tags_cache)
repository.before_delete
end
- it 'flushes the tag count cache' do
- expect(repository).to receive(:expire_tag_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the branches cache' do
expect(repository).to receive(:expire_branches_cache)
repository.before_delete
end
- it 'flushes the branch count cache' do
- expect(repository).to receive(:expire_branch_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the root ref cache' do
expect(repository).to receive(:expire_root_ref_cache)
@@ -1222,8 +1163,9 @@ describe Repository, models: true do
describe '#before_push_tag' do
it 'flushes the cache' do
- expect(repository).to receive(:expire_cache)
- expect(repository).to receive(:expire_tag_count_cache)
+ expect(repository).to receive(:expire_statistics_caches)
+ expect(repository).to receive(:expire_emptiness_caches)
+ expect(repository).to receive(:expire_tags_cache)
repository.before_push_tag
end
@@ -1240,17 +1182,23 @@ describe Repository, models: true do
describe '#after_import' do
it 'flushes and builds the cache' do
expect(repository).to receive(:expire_content_cache)
- expect(repository).to receive(:build_cache)
+ expect(repository).to receive(:expire_tags_cache)
+ expect(repository).to receive(:expire_branches_cache)
repository.after_import
end
end
describe '#after_push_commit' do
- it 'flushes the cache' do
- expect(repository).to receive(:expire_cache).with('master', '123')
+ it 'expires statistics caches' do
+ expect(repository).to receive(:expire_statistics_caches).
+ and_call_original
+
+ expect(repository).to receive(:expire_branch_cache).
+ with('master').
+ and_call_original
- repository.after_push_commit('master', '123')
+ repository.after_push_commit('master')
end
end
@@ -1302,7 +1250,8 @@ describe Repository, models: true do
describe '#before_remove_tag' do
it 'flushes the tag cache' do
- expect(repository).to receive(:expire_tag_count_cache)
+ expect(repository).to receive(:expire_tags_cache).and_call_original
+ expect(repository).to receive(:expire_statistics_caches).and_call_original
repository.before_remove_tag
end
@@ -1320,23 +1269,23 @@ describe Repository, models: true do
end
end
- describe '#expire_branch_count_cache' do
- let(:cache) { repository.send(:cache) }
-
+ describe '#expire_branches_cache' do
it 'expires the cache' do
- expect(cache).to receive(:expire).with(:branch_count)
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(branch_names branch_count)).
+ and_call_original
- repository.expire_branch_count_cache
+ repository.expire_branches_cache
end
end
- describe '#expire_tag_count_cache' do
- let(:cache) { repository.send(:cache) }
-
+ describe '#expire_tags_cache' do
it 'expires the cache' do
- expect(cache).to receive(:expire).with(:tag_count)
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(tag_names tag_count)).
+ and_call_original
- repository.expire_tag_count_cache
+ repository.expire_tags_cache
end
end
@@ -1354,6 +1303,28 @@ describe Repository, models: true do
repository.add_tag(user, '8.5', 'master', 'foo')
end
+ it 'does not create a tag when a pre-hook fails' do
+ allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, ''])
+
+ expect do
+ repository.add_tag(user, '8.5', 'master', 'foo')
+ end.to raise_error(GitHooksService::PreReceiveError)
+
+ repository.expire_tags_cache
+ expect(repository.find_tag('8.5')).to be_nil
+ end
+
+ it 'passes tag SHA to hooks' do
+ spy = GitHooksService.new
+ allow(GitHooksService).to receive(:new).and_return(spy)
+ allow(spy).to receive(:execute).and_call_original
+
+ tag = repository.add_tag(user, '8.5', 'master', 'foo')
+
+ expect(spy).to have_received(:execute).
+ with(anything, anything, anything, tag.target, anything)
+ end
+
it 'returns a Gitlab::Git::Tag object' do
tag = repository.add_tag(user, '8.5', 'master', 'foo')
@@ -1390,170 +1361,316 @@ describe Repository, models: true do
describe '#avatar' do
it 'returns nil if repo does not exist' do
- expect(repository).to receive(:exists?).and_return(false)
+ expect(repository).to receive(:file_on_head).
+ and_raise(Rugged::ReferenceError)
expect(repository.avatar).to eq(nil)
end
it 'returns the first avatar file found in the repository' do
- expect(repository).to receive(:blob_at_branch).
- with('master', 'logo.png').
- and_return(true)
+ expect(repository).to receive(:file_on_head).
+ with(:avatar).
+ and_return(double(:tree, path: 'logo.png'))
expect(repository.avatar).to eq('logo.png')
end
it 'caches the output' do
- allow(repository).to receive(:blob_at_branch).
- with('master', 'logo.png').
- and_return(true)
-
- expect(repository.avatar).to eq('logo.png')
+ expect(repository).to receive(:file_on_head).
+ with(:avatar).
+ once.
+ and_return(double(:tree, path: 'logo.png'))
- expect(repository).not_to receive(:blob_at_branch)
- expect(repository.avatar).to eq('logo.png')
+ 2.times { expect(repository.avatar).to eq('logo.png') }
end
end
- describe '#expire_avatar_cache' do
+ describe '#expire_exists_cache' do
let(:cache) { repository.send(:cache) }
- before do
- allow(repository).to receive(:cache).and_return(cache)
+ it 'expires the cache' do
+ expect(cache).to receive(:expire).with(:exists?)
+
+ repository.expire_exists_cache
end
+ end
- context 'without a branch or revision' do
- it 'flushes the cache' do
- expect(cache).to receive(:expire).with(:avatar)
+ describe "#keep_around" do
+ it "does not fail if we attempt to reference bad commit" do
+ expect(repository.kept_around?('abc1234')).to be_falsey
+ end
- repository.expire_avatar_cache
- end
+ it "stores a reference to the specified commit sha so it isn't garbage collected" do
+ repository.keep_around(sample_commit.id)
+
+ expect(repository.kept_around?(sample_commit.id)).to be_truthy
+ end
+
+ it "attempting to call keep_around on truncated ref does not fail" do
+ repository.keep_around(sample_commit.id)
+ ref = repository.send(:keep_around_ref_name, sample_commit.id)
+ path = File.join(repository.path, ref)
+ # Corrupt the reference
+ File.truncate(path, 0)
+
+ expect(repository.kept_around?(sample_commit.id)).to be_falsey
+
+ repository.keep_around(sample_commit.id)
+
+ expect(repository.kept_around?(sample_commit.id)).to be_falsey
+
+ File.delete(path)
+ end
+ end
+
+ describe '#update_ref!' do
+ it 'can create a ref' do
+ repository.update_ref!('refs/heads/foobar', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
+
+ expect(repository.find_branch('foobar')).not_to be_nil
+ end
+
+ it 'raises CommitError when the ref update fails' do
+ expect do
+ repository.update_ref!('refs/heads/master', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
+ end.to raise_error(Repository::CommitError)
end
+ end
- context 'with a branch' do
- it 'does not flush the cache if the branch is not the default branch' do
- expect(cache).not_to receive(:expire)
+ describe '#contribution_guide', caching: true do
+ it 'returns and caches the output' do
+ expect(repository).to receive(:file_on_head).
+ with(:contributing).
+ and_return(Gitlab::Git::Tree.new(path: 'CONTRIBUTING.md')).
+ once
- repository.expire_avatar_cache('cats')
+ 2.times do
+ expect(repository.contribution_guide).
+ to be_an_instance_of(Gitlab::Git::Tree)
end
+ end
+ end
- it 'flushes the cache if the branch equals the default branch' do
- expect(cache).to receive(:expire).with(:avatar)
+ describe '#gitignore', caching: true do
+ it 'returns and caches the output' do
+ expect(repository).to receive(:file_on_head).
+ with(:gitignore).
+ and_return(Gitlab::Git::Tree.new(path: '.gitignore')).
+ once
- repository.expire_avatar_cache(repository.root_ref)
+ 2.times do
+ expect(repository.gitignore).to be_an_instance_of(Gitlab::Git::Tree)
end
end
+ end
- context 'with a branch and revision' do
- let(:commit) { double(:commit) }
+ describe '#koding_yml', caching: true do
+ it 'returns and caches the output' do
+ expect(repository).to receive(:file_on_head).
+ with(:koding).
+ and_return(Gitlab::Git::Tree.new(path: '.koding.yml')).
+ once
- before do
- allow(repository).to receive(:commit).and_return(commit)
+ 2.times do
+ expect(repository.koding_yml).to be_an_instance_of(Gitlab::Git::Tree)
end
+ end
+ end
- it 'does not flush the cache if the commit does not change any logos' do
- diff = double(:diff, new_path: 'test.txt')
+ describe '#readme', caching: true do
+ context 'with a non-existing repository' do
+ it 'returns nil' do
+ expect(repository).to receive(:tree).with(:head).and_return(nil)
- expect(commit).to receive(:raw_diffs).and_return([diff])
- expect(cache).not_to receive(:expire)
+ expect(repository.readme).to be_nil
+ end
+ end
- repository.expire_avatar_cache(repository.root_ref, '123')
+ context 'with an existing repository' do
+ it 'returns the README' do
+ expect(repository.readme).to be_an_instance_of(Gitlab::Git::Blob)
end
+ end
+ end
+
+ describe '#expire_statistics_caches' do
+ it 'expires the caches' do
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(size commit_count))
- it 'flushes the cache if the commit changes any of the logos' do
- diff = double(:diff, new_path: Repository::AVATAR_FILES[0])
+ repository.expire_statistics_caches
+ end
+ end
- expect(commit).to receive(:raw_diffs).and_return([diff])
- expect(cache).to receive(:expire).with(:avatar)
+ describe '#expire_method_caches' do
+ it 'expires the caches of the given methods' do
+ expect_any_instance_of(RepositoryCache).to receive(:expire).with(:readme)
+ expect_any_instance_of(RepositoryCache).to receive(:expire).with(:gitignore)
- repository.expire_avatar_cache(repository.root_ref, '123')
- end
+ repository.expire_method_caches(%i(readme gitignore))
end
end
- describe '#expire_exists_cache' do
- let(:cache) { repository.send(:cache) }
+ describe '#expire_all_method_caches' do
+ it 'expires the caches of all methods' do
+ expect(repository).to receive(:expire_method_caches).
+ with(Repository::CACHED_METHODS)
+
+ repository.expire_all_method_caches
+ end
+ end
+ describe '#expire_avatar_cache' do
it 'expires the cache' do
- expect(cache).to receive(:expire).with(:exists?)
+ expect(repository).to receive(:expire_method_caches).with(%i(avatar))
- repository.expire_exists_cache
+ repository.expire_avatar_cache
end
end
- describe '#build_cache' do
- let(:cache) { repository.send(:cache) }
+ describe '#file_on_head' do
+ context 'with a non-existing repository' do
+ it 'returns nil' do
+ expect(repository).to receive(:tree).with(:head).and_return(nil)
- it 'builds the caches if they do not already exist' do
- cache_keys = repository.cache_keys + repository.cache_keys_for_branches_and_tags
+ expect(repository.file_on_head(:readme)).to be_nil
+ end
+ end
- expect(cache).to receive(:exist?).
- exactly(cache_keys.length).
- times.
- and_return(false)
+ context 'with a repository that has no blobs' do
+ it 'returns nil' do
+ expect_any_instance_of(Tree).to receive(:blobs).and_return([])
+
+ expect(repository.file_on_head(:readme)).to be_nil
+ end
+ end
+
+ context 'with an existing repository' do
+ it 'returns a Gitlab::Git::Tree' do
+ expect(repository.file_on_head(:readme)).
+ to be_an_instance_of(Gitlab::Git::Tree)
+ end
+ end
+ end
+
+ describe '#head_tree' do
+ context 'with an existing repository' do
+ it 'returns a Tree' do
+ expect(repository.head_tree).to be_an_instance_of(Tree)
+ end
+ end
- cache_keys.each do |key|
- expect(repository).to receive(key)
+ context 'with a non-existing repository' do
+ it 'returns nil' do
+ expect(repository).to receive(:head_commit).and_return(nil)
+
+ expect(repository.head_tree).to be_nil
+ end
+ end
+ end
+
+ describe '#tree' do
+ context 'using a non-existing repository' do
+ before do
+ allow(repository).to receive(:head_commit).and_return(nil)
end
- repository.build_cache
+ it 'returns nil' do
+ expect(repository.tree(:head)).to be_nil
+ end
+
+ it 'returns nil when using a path' do
+ expect(repository.tree(:head, 'README.md')).to be_nil
+ end
end
- it 'does not build any caches that already exist' do
- cache_keys = repository.cache_keys + repository.cache_keys_for_branches_and_tags
+ context 'using an existing repository' do
+ it 'returns a Tree' do
+ expect(repository.tree(:head)).to be_an_instance_of(Tree)
+ end
+ end
+ end
- expect(cache).to receive(:exist?).
- exactly(cache_keys.length).
- times.
- and_return(true)
+ describe '#size' do
+ context 'with a non-existing repository' do
+ it 'returns 0' do
+ expect(repository).to receive(:exists?).and_return(false)
- cache_keys.each do |key|
- expect(repository).not_to receive(key)
+ expect(repository.size).to eq(0.0)
end
+ end
- repository.build_cache
+ context 'with an existing repository' do
+ it 'returns the repository size as a Float' do
+ expect(repository.size).to be_an_instance_of(Float)
+ end
end
end
- describe "#keep_around" do
- it "does not fail if we attempt to reference bad commit" do
- expect(repository.kept_around?('abc1234')).to be_falsey
+ describe '#commit_count' do
+ context 'with a non-existing repository' do
+ it 'returns 0' do
+ expect(repository).to receive(:root_ref).and_return(nil)
+
+ expect(repository.commit_count).to eq(0)
+ end
end
- it "stores a reference to the specified commit sha so it isn't garbage collected" do
- repository.keep_around(sample_commit.id)
+ context 'with an existing repository' do
+ it 'returns the commit count' do
+ expect(repository.commit_count).to be_an_instance_of(Fixnum)
+ end
+ end
+ end
- expect(repository.kept_around?(sample_commit.id)).to be_truthy
+ describe '#cache_method_output', caching: true do
+ context 'with a non-existing repository' do
+ let(:value) do
+ repository.cache_method_output(:cats, fallback: 10) do
+ raise Rugged::ReferenceError
+ end
+ end
+
+ it 'returns a fallback value' do
+ expect(value).to eq(10)
+ end
+
+ it 'does not cache the data' do
+ value
+
+ expect(repository.instance_variable_defined?(:@cats)).to eq(false)
+ expect(repository.send(:cache).exist?(:cats)).to eq(false)
+ end
end
- it "attempting to call keep_around on truncated ref does not fail" do
- repository.keep_around(sample_commit.id)
- ref = repository.send(:keep_around_ref_name, sample_commit.id)
- path = File.join(repository.path, ref)
- # Corrupt the reference
- File.truncate(path, 0)
+ context 'with an existing repository' do
+ it 'caches the output' do
+ object = double
- expect(repository.kept_around?(sample_commit.id)).to be_falsey
+ expect(object).to receive(:number).once.and_return(10)
- repository.keep_around(sample_commit.id)
+ 2.times do
+ val = repository.cache_method_output(:cats) { object.number }
- expect(repository.kept_around?(sample_commit.id)).to be_falsey
+ expect(val).to eq(10)
+ end
- File.delete(path)
+ expect(repository.send(:cache).exist?(:cats)).to eq(true)
+ expect(repository.instance_variable_get(:@cats)).to eq(10)
+ end
end
end
- describe '#update_ref!' do
- it 'can create a ref' do
- repository.update_ref!('refs/heads/foobar', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
+ describe '#refresh_method_caches' do
+ it 'refreshes the caches of the given types' do
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(readme license_blob license_key))
- expect(repository.find_branch('foobar')).not_to be_nil
- end
+ expect(repository).to receive(:readme)
+ expect(repository).to receive(:license_blob)
+ expect(repository).to receive(:license_key)
- it 'raises CommitError when the ref update fails' do
- expect do
- repository.update_ref!('refs/heads/master', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
- end.to raise_error(Repository::CommitError)
+ repository.refresh_method_caches(%i(readme license))
end
end
end
diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb
new file mode 100644
index 00000000000..9ab112bb2ee
--- /dev/null
+++ b/spec/models/subscription_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Subscription, models: true do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:subscribable) }
+ it { is_expected.to belong_to(:user) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:subscribable) }
+ it { is_expected.to validate_presence_of(:user) }
+
+ it 'validates uniqueness of project_id scoped to subscribable_id, subscribable_type, and user_id' do
+ create(:subscription)
+
+ expect(subject).to validate_uniqueness_of(:project_id).scoped_to([:subscribable_id, :subscribable_type, :user_id])
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 580ce4a9e0a..e84042f8063 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -33,6 +33,7 @@ describe User, models: true do
it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
it { is_expected.to have_many(:builds).dependent(:nullify) }
it { is_expected.to have_many(:pipelines).dependent(:nullify) }
+ it { is_expected.to have_many(:chat_names).dependent(:destroy) }
describe '#group_members' do
it 'does not include group memberships for which user is a requester' do
@@ -1072,7 +1073,7 @@ describe User, models: true do
it { is_expected.to eq([private_group]) }
end
- describe '#authorized_projects' do
+ describe '#authorized_projects', truncate: true do
context 'with a minimum access level' do
it 'includes projects for which the user is an owner' do
user = create(:user)
@@ -1092,6 +1093,80 @@ describe User, models: true do
.to contain_exactly(project)
end
end
+
+ it "includes user's personal projects" do
+ user = create(:user)
+ project = create(:project, :private, namespace: user.namespace)
+
+ expect(user.authorized_projects).to include(project)
+ end
+
+ it "includes personal projects user has been given access to" do
+ user1 = create(:user)
+ user2 = create(:user)
+ project = create(:project, :private, namespace: user1.namespace)
+
+ project.team << [user2, Gitlab::Access::DEVELOPER]
+
+ expect(user2.authorized_projects).to include(project)
+ end
+
+ it "includes projects of groups user has been added to" do
+ group = create(:group)
+ project = create(:project, group: group)
+ user = create(:user)
+
+ group.add_developer(user)
+
+ expect(user.authorized_projects).to include(project)
+ end
+
+ it "does not include projects of groups user has been removed from" do
+ group = create(:group)
+ project = create(:project, group: group)
+ user = create(:user)
+
+ member = group.add_developer(user)
+ expect(user.authorized_projects).to include(project)
+
+ member.destroy
+ expect(user.authorized_projects).not_to include(project)
+ end
+
+ it "includes projects shared with user's group" do
+ user = create(:user)
+ project = create(:project, :private)
+ group = create(:group)
+
+ group.add_reporter(user)
+ project.project_group_links.create(group: group)
+
+ expect(user.authorized_projects).to include(project)
+ end
+
+ it "does not include destroyed projects user had access to" do
+ user1 = create(:user)
+ user2 = create(:user)
+ project = create(:project, :private, namespace: user1.namespace)
+
+ project.team << [user2, Gitlab::Access::DEVELOPER]
+ expect(user2.authorized_projects).to include(project)
+
+ project.destroy
+ expect(user2.authorized_projects).not_to include(project)
+ end
+
+ it "does not include projects of destroyed groups user had access to" do
+ group = create(:group)
+ project = create(:project, namespace: group)
+ user = create(:user)
+
+ group.add_developer(user)
+ expect(user.authorized_projects).to include(project)
+
+ group.destroy
+ expect(user.authorized_projects).not_to include(project)
+ end
end
describe '#projects_where_can_admin_issues' do
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 8f605757186..fe6b875b997 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -14,7 +14,7 @@ describe API::API, api: true do
describe "GET /projects/:id/repository/branches" do
it "returns an array of project branches" do
- project.repository.expire_cache
+ project.repository.expire_all_method_caches
get api("/projects/#{project.id}/repository/branches", user)
expect(response).to have_http_status(200)
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index d79b204a24e..d9fdafde05e 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -57,13 +57,48 @@ describe API::API, api: true do
end
context "when using all_available in request" do
+ let(:response_groups) { json_response.map { |group| group['name'] } }
+
it "returns all groups you have access to" do
public_group = create :group, :public
get api("/groups", user1), all_available: true
expect(response).to have_http_status(200)
expect(json_response).to be_an Array
- expect(json_response.first['name']).to eq(public_group.name)
+ expect(response_groups).to contain_exactly(public_group.name, group1.name)
+ end
+ end
+
+ context "when using sorting" do
+ let(:group3) { create(:group, name: "a#{group1.name}", path: "z#{group1.path}") }
+ let(:response_groups) { json_response.map { |group| group['name'] } }
+
+ before do
+ group3.add_owner(user1)
+ end
+
+ it "sorts by name ascending by default" do
+ get api("/groups", user1)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(response_groups).to eq([group3.name, group1.name])
+ end
+
+ it "sorts in descending order when passed" do
+ get api("/groups", user1), sort: "desc"
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(response_groups).to eq([group1.name, group3.name])
+ end
+
+ it "sorts by the order_by param" do
+ get api("/groups", user1), order_by: "path"
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(response_groups).to eq([group1.name, group3.name])
end
end
end
diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb
index 8f1a1f9e827..e88a7e27d45 100644
--- a/spec/requests/api/internal_spec.rb
+++ b/spec/requests/api/internal_spec.rb
@@ -5,7 +5,7 @@ describe API::API, api: true do
let(:user) { create(:user) }
let(:key) { create(:key, user: user) }
let(:project) { create(:project) }
- let(:secret_token) { File.read Gitlab.config.gitlab_shell.secret_file }
+ let(:secret_token) { Gitlab::Shell.secret_token }
describe "GET /internal/check", no_db: true do
it do
@@ -406,7 +406,7 @@ describe API::API, api: true do
it 'returns link to create new merge request' do
expect(json_response).to match [{
"branch_name" => "new_branch",
- "url" => "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch",
+ "url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch",
"new_merge_request" => true
}]
end
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index beed53d1e5c..7bae055b241 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -637,7 +637,7 @@ describe API::API, api: true do
it "sends notifications for subscribers of newly added labels" do
label = project.labels.first
- label.toggle_subscription(user2)
+ label.toggle_subscription(user2, project)
perform_enqueued_jobs do
post api("/projects/#{project.id}/issues", user),
@@ -828,7 +828,7 @@ describe API::API, api: true do
it "sends notifications for subscribers of newly added labels when issue is updated" do
label = create(:label, title: 'foo', color: '#FFAABB', project: project)
- label.toggle_subscription(user2)
+ label.toggle_subscription(user2, project)
perform_enqueued_jobs do
put api("/projects/#{project.id}/issues/#{issue.id}", user),
diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb
index 77dfebf1a98..aaf41639277 100644
--- a/spec/requests/api/labels_spec.rb
+++ b/spec/requests/api/labels_spec.rb
@@ -339,7 +339,7 @@ describe API::API, api: true do
end
context "when user is already subscribed to label" do
- before { label1.subscribe(user) }
+ before { label1.subscribe(user, project) }
it "returns 304" do
post api("/projects/#{project.id}/labels/#{label1.id}/subscription", user)
@@ -358,7 +358,7 @@ describe API::API, api: true do
end
describe "DELETE /projects/:id/labels/:label_id/subscription" do
- before { label1.subscribe(user) }
+ before { label1.subscribe(user, project) }
context "when label_id is a label title" do
it "unsubscribes from the label" do
@@ -381,7 +381,7 @@ describe API::API, api: true do
end
context "when user is already unsubscribed from label" do
- before { label1.unsubscribe(user) }
+ before { label1.unsubscribe(user, project) }
it "returns 304" do
delete api("/projects/#{project.id}/labels/#{label1.id}/subscription", user)
diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb
index 7011bdc9ec0..d83f7883c78 100644
--- a/spec/requests/api/pipelines_spec.rb
+++ b/spec/requests/api/pipelines_spec.rb
@@ -41,6 +41,52 @@ describe API::API, api: true do
end
end
+ describe 'POST /projects/:id/pipeline ' do
+ context 'authorized user' do
+ context 'with gitlab-ci.yml' do
+ before { stub_ci_pipeline_to_return_yaml_file }
+
+ it 'creates and returns a new pipeline' do
+ expect do
+ post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch
+ end.to change { Ci::Pipeline.count }.by(1)
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to be_a Hash
+ expect(json_response['sha']).to eq project.commit.id
+ end
+
+ it 'fails when using an invalid ref' do
+ post api("/projects/#{project.id}/pipeline", user), ref: 'invalid_ref'
+
+ expect(response).to have_http_status(400)
+ expect(json_response['message']['base'].first).to eq 'Reference not found'
+ expect(json_response).not_to be_an Array
+ end
+ end
+
+ context 'without gitlab-ci.yml' do
+ it 'fails to create pipeline' do
+ post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch
+
+ expect(response).to have_http_status(400)
+ expect(json_response['message']['base'].first).to eq 'Missing .gitlab-ci.yml file'
+ expect(json_response).not_to be_an Array
+ end
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not create pipeline' do
+ post api("/projects/#{project.id}/pipeline", non_member), ref: project.default_branch
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(json_response).not_to be_an Array
+ end
+ end
+ end
+
describe 'GET /projects/:id/pipelines/:pipeline_id' do
context 'authorized user' do
it 'returns project pipelines' do
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index 01148f0a05e..1c25fd04339 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -3,10 +3,12 @@ require 'rails_helper'
describe API::API, api: true do
include ApiHelpers
+ let(:project) { create(:empty_project, :public) }
+ let(:admin) { create(:admin) }
+
describe 'GET /projects/:project_id/snippets/:id' do
# TODO (rspeicher): Deprecated; remove in 9.0
it 'always exposes expires_at as nil' do
- admin = create(:admin)
snippet = create(:project_snippet, author: admin)
get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}", admin)
@@ -17,9 +19,9 @@ describe API::API, api: true do
end
describe 'GET /projects/:project_id/snippets/' do
+ let(:user) { create(:user) }
+
it 'returns all snippets available to team member' do
- project = create(:project, :public)
- user = create(:user)
project.team << [user, :developer]
public_snippet = create(:project_snippet, :public, project: project)
internal_snippet = create(:project_snippet, :internal, project: project)
@@ -34,8 +36,6 @@ describe API::API, api: true do
end
it 'hides private snippets from regular user' do
- project = create(:project, :public)
- user = create(:user)
create(:project_snippet, :private, project: project)
get api("/projects/#{project.id}/snippets/", user)
@@ -45,16 +45,16 @@ describe API::API, api: true do
end
describe 'POST /projects/:project_id/snippets/' do
- it 'creates a new snippet' do
- admin = create(:admin)
- project = create(:project)
- params = {
+ let(:params) do
+ {
title: 'Test Title',
file_name: 'test.rb',
code: 'puts "hello world"',
visibility_level: Gitlab::VisibilityLevel::PUBLIC
}
+ end
+ it 'creates a new snippet' do
post api("/projects/#{project.id}/snippets/", admin), params
expect(response).to have_http_status(201)
@@ -64,12 +64,20 @@ describe API::API, api: true do
expect(snippet.file_name).to eq(params[:file_name])
expect(snippet.visibility_level).to eq(params[:visibility_level])
end
+
+ it 'returns 400 for missing parameters' do
+ params.delete(:title)
+
+ post api("/projects/#{project.id}/snippets/", admin), params
+
+ expect(response).to have_http_status(400)
+ end
end
describe 'PUT /projects/:project_id/snippets/:id/' do
+ let(:snippet) { create(:project_snippet, author: admin) }
+
it 'updates snippet' do
- admin = create(:admin)
- snippet = create(:project_snippet, author: admin)
new_content = 'New content'
put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content
@@ -78,9 +86,24 @@ describe API::API, api: true do
snippet.reload
expect(snippet.content).to eq(new_content)
end
+
+ it 'returns 404 for invalid snippet id' do
+ put api("/projects/#{snippet.project.id}/snippets/1234", admin), title: 'foo'
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
+
+ it 'returns 400 for missing parameters' do
+ put api("/projects/#{project.id}/snippets/1234", admin)
+
+ expect(response).to have_http_status(400)
+ end
end
describe 'DELETE /projects/:project_id/snippets/:id/' do
+ let(:snippet) { create(:project_snippet, author: admin) }
+
it 'deletes snippet' do
admin = create(:admin)
snippet = create(:project_snippet, author: admin)
@@ -89,18 +112,31 @@ describe API::API, api: true do
expect(response).to have_http_status(200)
end
+
+ it 'returns 404 for invalid snippet id' do
+ delete api("/projects/#{snippet.project.id}/snippets/1234", admin)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
end
describe 'GET /projects/:project_id/snippets/:id/raw' do
- it 'returns raw text' do
- admin = create(:admin)
- snippet = create(:project_snippet, author: admin)
+ let(:snippet) { create(:project_snippet, author: admin) }
+ it 'returns raw text' do
get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw", admin)
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'text/plain'
expect(response.body).to eq(snippet.content)
end
+
+ it 'returns 404 for invalid snippet id' do
+ delete api("/projects/#{snippet.project.id}/snippets/1234", admin)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
end
end
diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb
index 2aadab3cbe1..ce9c96ace21 100644
--- a/spec/requests/api/services_spec.rb
+++ b/spec/requests/api/services_spec.rb
@@ -88,4 +88,61 @@ describe API::API, api: true do
end
end
end
+
+ describe 'POST /projects/:id/services/:slug/trigger' do
+ let!(:project) { create(:empty_project) }
+ let(:service_name) { 'mattermost_slash_commands' }
+
+ context 'no service is available' do
+ it 'returns a not found message' do
+ post api("/projects/#{project.id}/services/idonotexist/trigger")
+
+ expect(response).to have_http_status(404)
+ expect(json_response["message"]).to eq("404 Service Not Found")
+ end
+ end
+
+ context 'the service exists' do
+ let(:params) { { token: 'token' } }
+
+ context 'the service is not active' do
+ let!(:inactive_service) do
+ project.create_mattermost_slash_commands_service(
+ active: false,
+ properties: { token: 'token' }
+ )
+ end
+
+ it 'when the service is inactive' do
+ post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger")
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'the service is active' do
+ let!(:active_service) do
+ project.create_mattermost_slash_commands_service(
+ active: true,
+ properties: { token: 'token' }
+ )
+ end
+
+ it 'retusn status 200' do
+ post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger"), params
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when the project can not be found' do
+ it 'returns a generic 404' do
+ post api("/projects/404/services/mattermost_slash_commands/trigger"), params
+
+ expect(response).to have_http_status(404)
+ expect(json_response["message"]).to eq("404 Service Not Found")
+ end
+ end
+ end
+ end
end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 34d1f557e4b..1a6e7716b2f 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -108,7 +108,7 @@ describe API::API, api: true do
it "returns a 404 error if user id not found" do
get api("/users/9999", user)
expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Not found')
+ expect(json_response['message']).to eq('404 User Not Found')
end
it "returns a 404 for invalid ID" do
@@ -359,7 +359,7 @@ describe API::API, api: true do
it "returns 404 for non-existing user" do
put api("/users/999999", admin), { bio: 'update should fail' }
expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Not found')
+ expect(json_response['message']).to eq('404 User Not Found')
end
it "returns a 404 if invalid ID" do
@@ -387,6 +387,18 @@ describe API::API, api: true do
to eq([Gitlab::Regex.namespace_regex_message])
end
+ it 'returns 400 if provider is missing for identity update' do
+ put api("/users/#{omniauth_user.id}", admin), extern_uid: '654321'
+
+ expect(response).to have_http_status(400)
+ end
+
+ it 'returns 400 if external UID is missing for identity update' do
+ put api("/users/#{omniauth_user.id}", admin), provider: 'ldap'
+
+ expect(response).to have_http_status(400)
+ end
+
context "with existing user" do
before do
post api("/users", admin), { email: 'test@example.com', password: 'password', username: 'test', name: 'test' }
@@ -414,14 +426,16 @@ describe API::API, api: true do
it "does not create invalid ssh key" do
post api("/users/#{user.id}/keys", admin), { title: "invalid key" }
+
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('400 (Bad request) "key" not given')
+ expect(json_response['error']).to eq('key is missing')
end
it 'does not create key without title' do
post api("/users/#{user.id}/keys", admin), key: 'some key'
+
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('400 (Bad request) "title" not given')
+ expect(json_response['error']).to eq('title is missing')
end
it "creates ssh key" do
@@ -437,7 +451,7 @@ describe API::API, api: true do
end
end
- describe 'GET /user/:uid/keys' do
+ describe 'GET /user/:id/keys' do
before { admin }
context 'when unauthenticated' do
@@ -465,7 +479,7 @@ describe API::API, api: true do
end
end
- describe 'DELETE /user/:uid/keys/:id' do
+ describe 'DELETE /user/:id/keys/:key_id' do
before { admin }
context 'when unauthenticated' do
@@ -506,8 +520,9 @@ describe API::API, api: true do
it "does not create invalid email" do
post api("/users/#{user.id}/emails", admin), {}
+
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('400 (Bad request) "email" not given')
+ expect(json_response['error']).to eq('email is missing')
end
it "creates email" do
@@ -524,7 +539,7 @@ describe API::API, api: true do
end
end
- describe 'GET /user/:uid/emails' do
+ describe 'GET /user/:id/emails' do
before { admin }
context 'when unauthenticated' do
@@ -558,7 +573,7 @@ describe API::API, api: true do
end
end
- describe 'DELETE /user/:uid/emails/:id' do
+ describe 'DELETE /user/:id/emails/:email_id' do
before { admin }
context 'when unauthenticated' do
@@ -673,7 +688,7 @@ describe API::API, api: true do
end
end
- describe "GET /user/keys/:id" do
+ describe "GET /user/keys/:key_id" do
it "returns single key" do
user.keys << key
user.save
@@ -686,7 +701,7 @@ describe API::API, api: true do
get api("/user/keys/42", user)
expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Not found')
+ expect(json_response['message']).to eq('404 Key Not Found')
end
it "returns 404 error if admin accesses user's ssh key" do
@@ -695,7 +710,7 @@ describe API::API, api: true do
admin
get api("/user/keys/#{key.id}", admin)
expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Not found')
+ expect(json_response['message']).to eq('404 Key Not Found')
end
it "returns 404 for invalid ID" do
@@ -721,14 +736,16 @@ describe API::API, api: true do
it "does not create ssh key without key" do
post api("/user/keys", user), title: 'title'
+
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('400 (Bad request) "key" not given')
+ expect(json_response['error']).to eq('key is missing')
end
it 'does not create ssh key without title' do
post api('/user/keys', user), key: 'some key'
+
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('400 (Bad request) "title" not given')
+ expect(json_response['error']).to eq('title is missing')
end
it "does not create ssh key without title" do
@@ -737,7 +754,7 @@ describe API::API, api: true do
end
end
- describe "DELETE /user/keys/:id" do
+ describe "DELETE /user/keys/:key_id" do
it "deletes existed key" do
user.keys << key
user.save
@@ -747,9 +764,11 @@ describe API::API, api: true do
expect(response).to have_http_status(200)
end
- it "returns success if key ID not found" do
+ it "returns 404 if key ID not found" do
delete api("/user/keys/42", user)
- expect(response).to have_http_status(200)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Key Not Found')
end
it "returns 401 error if unauthorized" do
@@ -786,7 +805,7 @@ describe API::API, api: true do
end
end
- describe "GET /user/emails/:id" do
+ describe "GET /user/emails/:email_id" do
it "returns single email" do
user.emails << email
user.save
@@ -798,7 +817,7 @@ describe API::API, api: true do
it "returns 404 Not Found within invalid ID" do
get api("/user/emails/42", user)
expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Not found')
+ expect(json_response['message']).to eq('404 Email Not Found')
end
it "returns 404 error if admin accesses user's email" do
@@ -807,7 +826,7 @@ describe API::API, api: true do
admin
get api("/user/emails/#{email.id}", admin)
expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Not found')
+ expect(json_response['message']).to eq('404 Email Not Found')
end
it "returns 404 for invalid ID" do
@@ -833,12 +852,13 @@ describe API::API, api: true do
it "does not create email with invalid email" do
post api("/user/emails", user), {}
+
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('400 (Bad request) "email" not given')
+ expect(json_response['error']).to eq('email is missing')
end
end
- describe "DELETE /user/emails/:id" do
+ describe "DELETE /user/emails/:email_id" do
it "deletes existed email" do
user.emails << email
user.save
@@ -848,9 +868,11 @@ describe API::API, api: true do
expect(response).to have_http_status(200)
end
- it "returns success if email ID not found" do
+ it "returns 404 if email ID not found" do
delete api("/user/emails/42", user)
- expect(response).to have_http_status(200)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Email Not Found')
end
it "returns 401 error if unauthorized" do
@@ -860,10 +882,10 @@ describe API::API, api: true do
expect(response).to have_http_status(401)
end
- it "returns a 404 for invalid ID" do
- delete api("/users/emails/ASDF", admin)
+ it "returns 400 for invalid ID" do
+ delete api("/user/emails/ASDF", admin)
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(400)
end
end
diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb
index a611c5e3823..a09d8689ff2 100644
--- a/spec/requests/ci/api/builds_spec.rb
+++ b/spec/requests/ci/api/builds_spec.rb
@@ -17,6 +17,10 @@ describe Ci::API::API do
let!(:build) { create(:ci_build, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let(:user_agent) { 'gitlab-ci-multi-runner 1.5.2 (1-5-stable; go1.6.3; linux/amd64)' }
+ before do
+ stub_container_registry_config(enabled: false)
+ end
+
shared_examples 'no builds available' do
context 'when runner sends version in User-Agent' do
context 'for stable version' do
@@ -53,6 +57,41 @@ describe Ci::API::API do
it 'updates runner info' do
expect { register_builds }.to change { runner.reload.contacted_at }
end
+
+ context 'registry credentials' do
+ let(:registry_credentials) do
+ { 'type' => 'registry',
+ 'url' => 'registry.example.com:5005',
+ 'username' => 'gitlab-ci-token',
+ 'password' => build.token }
+ end
+
+ context 'when registry is enabled' do
+ before do
+ stub_container_registry_config(enabled: true, host_port: 'registry.example.com:5005')
+ end
+
+ it 'sends registry credentials key' do
+ register_builds info: { platform: :darwin }
+
+ expect(json_response).to have_key('credentials')
+ expect(json_response['credentials']).to include(registry_credentials)
+ end
+ end
+
+ context 'when registry is disabled' do
+ before do
+ stub_container_registry_config(enabled: false, host_port: 'registry.example.com:5005')
+ end
+
+ it 'does not send registry credentials' do
+ register_builds info: { platform: :darwin }
+
+ expect(json_response).to have_key('credentials')
+ expect(json_response['credentials']).not_to include(registry_credentials)
+ end
+ end
+ end
end
context 'when builds are finished' do
diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb
new file mode 100644
index 00000000000..705dbb7d1c0
--- /dev/null
+++ b/spec/requests/projects/cycle_analytics_events_spec.rb
@@ -0,0 +1,140 @@
+require 'spec_helper'
+
+describe 'cycle analytics events' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
+
+ describe 'GET /:namespace/:project/cycle_analytics/events/issues' do
+ before do
+ project.team << [user, :developer]
+
+ allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
+
+ 3.times { create_cycle }
+ deploy_master
+
+ login_as(user)
+ end
+
+ it 'lists the issue events' do
+ get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json)
+
+ expect(json_response['events']).not_to be_empty
+
+ first_issue_iid = Issue.order(created_at: :desc).pluck(:iid).first.to_s
+
+ expect(json_response['events'].first['iid']).to eq(first_issue_iid)
+ end
+
+ it 'lists the plan events' do
+ get namespace_project_cycle_analytics_plan_path(project.namespace, project, format: :json)
+
+ expect(json_response['events']).not_to be_empty
+
+ expect(json_response['events'].first['short_sha']).to eq(MergeRequest.last.commits.first.short_id)
+ end
+
+ it 'lists the code events' do
+ get namespace_project_cycle_analytics_code_path(project.namespace, project, format: :json)
+
+ expect(json_response['events']).not_to be_empty
+
+ first_mr_iid = MergeRequest.order(created_at: :desc).pluck(:iid).first.to_s
+
+ expect(json_response['events'].first['iid']).to eq(first_mr_iid)
+ end
+
+ it 'lists the test events' do
+ get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json)
+
+ expect(json_response['events']).not_to be_empty
+
+ expect(json_response['events'].first['date']).not_to be_empty
+ end
+
+ it 'lists the review events' do
+ get namespace_project_cycle_analytics_review_path(project.namespace, project, format: :json)
+
+ expect(json_response['events']).not_to be_empty
+
+ first_mr_iid = MergeRequest.order(created_at: :desc).pluck(:iid).first.to_s
+
+ expect(json_response['events'].first['iid']).to eq(first_mr_iid)
+ end
+
+ it 'lists the staging events' do
+ get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json)
+
+ expect(json_response['events']).not_to be_empty
+
+ expect(json_response['events'].first['date']).not_to be_empty
+ end
+
+ it 'lists the production events' do
+ get namespace_project_cycle_analytics_production_path(project.namespace, project, format: :json)
+
+ expect(json_response['events']).not_to be_empty
+
+ first_issue_iid = Issue.order(created_at: :desc).pluck(:iid).first.to_s
+
+ expect(json_response['events'].first['iid']).to eq(first_issue_iid)
+ end
+
+ context 'specific branch' do
+ it 'lists the test events' do
+ branch = MergeRequest.first.source_branch
+
+ get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json, branch: branch)
+
+ expect(json_response['events']).not_to be_empty
+
+ expect(json_response['events'].first['date']).not_to be_empty
+ end
+ end
+
+ context 'with private project and builds' do
+ before do
+ ProjectMember.first.update(access_level: Gitlab::Access::GUEST)
+ end
+
+ it 'does not list the test events' do
+ get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json)
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it 'does not list the staging events' do
+ get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json)
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it 'lists the issue events' do
+ get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json)
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
+
+ def json_response
+ JSON.parse(response.body)
+ end
+
+ def create_cycle
+ milestone = create(:milestone, project: project)
+ issue.update(milestone: milestone)
+ mr = create_merge_request_closing_issue(issue)
+
+ pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha)
+ pipeline.run
+
+ create(:ci_build, pipeline: pipeline, status: :success, author: user)
+ create(:ci_build, pipeline: pipeline, status: :success, author: user)
+
+ merge_merge_requests_closing_issue(issue)
+
+ ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.sha)
+ end
+end
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index 61dca5d5a62..f15c45cbaac 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -261,20 +261,28 @@ describe "Authentication", "routing" do
end
describe "Groups", "routing" do
+ let(:name) { 'complex.group-namegit' }
+
it "to #show" do
- expect(get("/groups/1")).to route_to('groups#show', id: '1')
+ expect(get("/groups/#{name}")).to route_to('groups#show', id: name)
end
it "also display group#show on the short path" do
allow(Group).to receive(:find_by).and_return(true)
- expect(get('/1')).to route_to('groups#show', id: '1')
+ expect(get("/#{name}")).to route_to('groups#show', id: name)
end
- it "also display group#show with dot in the path" do
- allow(Group).to receive(:find_by).and_return(true)
+ it "to #activity" do
+ expect(get("/groups/#{name}/activity")).to route_to('groups#activity', id: name)
+ end
+
+ it "to #issues" do
+ expect(get("/groups/#{name}/issues")).to route_to('groups#issues', id: name)
+ end
- expect(get('/group.with.dot')).to route_to('groups#show', id: 'group.with.dot')
+ it "to #members" do
+ expect(get("/groups/#{name}/group_members")).to route_to('groups/group_members#index', group_id: name)
end
end
diff --git a/spec/serializers/analytics_build_entity_spec.rb b/spec/serializers/analytics_build_entity_spec.rb
new file mode 100644
index 00000000000..9ac6f20fd3c
--- /dev/null
+++ b/spec/serializers/analytics_build_entity_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe AnalyticsBuildEntity do
+ let(:entity) do
+ described_class.new(build, request: double)
+ end
+
+ context 'build with an author' do
+ let(:user) { create(:user) }
+ let(:build) { create(:ci_build, author: user) }
+
+ subject { entity.as_json }
+
+ it 'contains the URL' do
+ expect(subject).to include(:url)
+ end
+
+ it 'contains the author' do
+ expect(subject).to include(:author)
+ end
+
+ it 'does not contain sensitive information' do
+ expect(subject).not_to include(/token/)
+ expect(subject).not_to include(/variables/)
+ end
+ end
+end
diff --git a/spec/serializers/analytics_build_serializer_spec.rb b/spec/serializers/analytics_build_serializer_spec.rb
new file mode 100644
index 00000000000..a0a9d9a5f12
--- /dev/null
+++ b/spec/serializers/analytics_build_serializer_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe AnalyticsBuildSerializer do
+ let(:serializer) do
+ described_class
+ .new.represent(resource)
+ end
+
+ let(:json) { serializer.as_json }
+ let(:resource) { create(:ci_build) }
+
+ context 'when there is a single object provided' do
+ it 'it generates payload for single object' do
+ expect(json).to be_an_instance_of Hash
+ end
+
+ it 'contains important elements of analyticsBuild' do
+ expect(json)
+ .to include(:name, :branch, :short_sha, :date, :total_time, :url, :author)
+ end
+ end
+end
diff --git a/spec/serializers/analytics_generic_entity_spec.rb b/spec/serializers/analytics_generic_entity_spec.rb
new file mode 100644
index 00000000000..68086216ba9
--- /dev/null
+++ b/spec/serializers/analytics_generic_entity_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe AnalyticsIssueEntity do
+ let(:user) { create(:user) }
+ let(:entity_hash) do
+ {
+ total_time: "172802.724419",
+ title: "Eos voluptatem inventore in sed.",
+ iid: "1",
+ id: "1",
+ created_at: "2016-11-12 15:04:02.948604",
+ author: user,
+ }
+ end
+
+ let(:project) { create(:empty_project) }
+ let(:request) { EntityRequest.new(project: project, entity: :merge_request) }
+
+ let(:entity) do
+ described_class.new(entity_hash, request: request, project: project)
+ end
+
+ context 'generic entity' do
+ subject { entity.as_json }
+
+ it 'contains the entity URL' do
+ expect(subject).to include(:url)
+ end
+
+ it 'contains the author' do
+ expect(subject).to include(:author)
+ end
+
+ it 'does not contain sensitive information' do
+ expect(subject).not_to include(/token/)
+ expect(subject).not_to include(/variables/)
+ end
+ end
+end
diff --git a/spec/serializers/analytics_issue_serializer_spec.rb b/spec/serializers/analytics_issue_serializer_spec.rb
new file mode 100644
index 00000000000..2842e1ba52f
--- /dev/null
+++ b/spec/serializers/analytics_issue_serializer_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe AnalyticsIssueSerializer do
+ let(:serializer) do
+ described_class
+ .new(project: project, entity: :merge_request)
+ .represent(resource)
+ end
+
+ let(:user) { create(:user) }
+ let(:json) { serializer.as_json }
+ let(:project) { create(:project) }
+ let(:resource) do
+ {
+ total_time: "172802.724419",
+ title: "Eos voluptatem inventore in sed.",
+ iid: "1",
+ id: "1",
+ created_at: "2016-11-12 15:04:02.948604",
+ author: user,
+ }
+ end
+
+ context 'when there is a single object provided' do
+ it 'it generates payload for single object' do
+ expect(json).to be_an_instance_of Hash
+ end
+
+ it 'contains important elements of the issue' do
+ expect(json).to include(:title, :iid, :created_at, :total_time, :url, :author)
+ end
+ end
+end
diff --git a/spec/serializers/analytics_merge_request_serializer_spec.rb b/spec/serializers/analytics_merge_request_serializer_spec.rb
new file mode 100644
index 00000000000..564207984df
--- /dev/null
+++ b/spec/serializers/analytics_merge_request_serializer_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+
+describe AnalyticsMergeRequestSerializer do
+ let(:serializer) do
+ described_class
+ .new(project: project, entity: :merge_request)
+ .represent(resource)
+ end
+
+ let(:user) { create(:user) }
+ let(:json) { serializer.as_json }
+ let(:project) { create(:project) }
+ let(:resource) do
+ {
+ total_time: "172802.724419",
+ title: "Eos voluptatem inventore in sed.",
+ iid: "1",
+ id: "1",
+ state: 'open',
+ created_at: "2016-11-12 15:04:02.948604",
+ author: user
+ }
+ end
+
+ context 'when there is a single object provided' do
+ it 'it generates payload for single object' do
+ expect(json).to be_an_instance_of Hash
+ end
+
+ it 'contains important elements of the merge request' do
+ expect(json).to include(:title, :iid, :created_at, :total_time, :url, :author, :state)
+ end
+ end
+end
diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb
index 2734f5bedca..6dcfaec259e 100644
--- a/spec/serializers/build_entity_spec.rb
+++ b/spec/serializers/build_entity_spec.rb
@@ -10,9 +10,9 @@ describe BuildEntity do
context 'when build is a regular job' do
let(:build) { create(:ci_build) }
- it 'contains url to build page and retry action' do
- expect(subject).to include(:build_url, :retry_url)
- expect(subject).not_to include(:play_url)
+ it 'contains paths to build page and retry action' do
+ expect(subject).to include(:build_path, :retry_path)
+ expect(subject).not_to include(:play_path)
end
it 'does not contain sensitive information' do
@@ -24,8 +24,8 @@ describe BuildEntity do
context 'when build is a manual action' do
let(:build) { create(:ci_build, :manual) }
- it 'contains url to play action' do
- expect(subject).to include(:play_url)
+ it 'contains path to play action' do
+ expect(subject).to include(:play_path)
end
end
end
diff --git a/spec/serializers/commit_entity_spec.rb b/spec/serializers/commit_entity_spec.rb
index 628e35c9a28..15f11ac3df9 100644
--- a/spec/serializers/commit_entity_spec.rb
+++ b/spec/serializers/commit_entity_spec.rb
@@ -31,7 +31,11 @@ describe CommitEntity do
end
end
- it 'contains commit URL' do
+ it 'contains path to commit' do
+ expect(subject).to include(:commit_path)
+ end
+
+ it 'contains URL to commit' do
expect(subject).to include(:commit_url)
end
diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb
index 51b6de91571..ea87771e2a2 100644
--- a/spec/serializers/deployment_entity_spec.rb
+++ b/spec/serializers/deployment_entity_spec.rb
@@ -15,6 +15,6 @@ describe DeploymentEntity do
it 'exposes nested information about branch' do
expect(subject[:ref][:name]).to eq 'master'
- expect(subject[:ref][:ref_url]).not_to be_empty
+ expect(subject[:ref][:ref_path]).not_to be_empty
end
end
diff --git a/spec/serializers/entity_date_helper_spec.rb b/spec/serializers/entity_date_helper_spec.rb
new file mode 100644
index 00000000000..b9cc2f64831
--- /dev/null
+++ b/spec/serializers/entity_date_helper_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+describe EntityDateHelper do
+ let(:date_helper_class) { Class.new { include EntityDateHelper }.new }
+
+ it 'converts 0 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(0)).to eq(seconds: 0)
+ end
+
+ it 'converts 40 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(40)).to eq(seconds: 40)
+ end
+
+ it 'converts 60 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(60)).to eq(mins: 1)
+ end
+
+ it 'converts 70 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(70)).to eq(mins: 1, seconds: 10)
+ end
+
+ it 'converts 3600 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(3600)).to eq(hours: 1)
+ end
+
+ it 'converts 3750 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(3750)).to eq(hours: 1, mins: 2, seconds: 30)
+ end
+
+ it 'converts 86400 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(86400)).to eq(days: 1)
+ end
+
+ it 'converts 86560 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(86560)).to eq(days: 1, mins: 2, seconds: 40)
+ end
+
+ it 'converts 86760 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(99760)).to eq(days: 1, hours: 3, mins: 42, seconds: 40)
+ end
+
+ it 'converts 986760 seconds' do
+ expect(date_helper_class.distance_of_time_as_hash(986760)).to eq(days: 11, hours: 10, mins: 6)
+ end
+end
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index 4ca8c299147..57728ce3181 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -13,6 +13,6 @@ describe EnvironmentEntity do
end
it 'exposes core elements of environment' do
- expect(subject).to include(:id, :name, :state, :environment_url)
+ expect(subject).to include(:id, :name, :state, :environment_path)
end
end
diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb
index 37bc086826c..8f95c9250b0 100644
--- a/spec/serializers/environment_serializer_spec.rb
+++ b/spec/serializers/environment_serializer_spec.rb
@@ -33,7 +33,7 @@ describe EnvironmentSerializer do
it 'contains important elements of environment' do
expect(json)
- .to include(:name, :external_url, :environment_url, :last_deployment)
+ .to include(:name, :external_url, :environment_path, :last_deployment)
end
it 'contains relevant information about last deployment' do
diff --git a/spec/services/chat_names/authorize_user_service_spec.rb b/spec/services/chat_names/authorize_user_service_spec.rb
new file mode 100644
index 00000000000..d50bfb0492c
--- /dev/null
+++ b/spec/services/chat_names/authorize_user_service_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe ChatNames::AuthorizeUserService, services: true do
+ describe '#execute' do
+ let(:service) { create(:service) }
+
+ subject { described_class.new(service, params).execute }
+
+ context 'when all parameters are valid' do
+ let(:params) { { team_id: 'T0001', team_domain: 'myteam', user_id: 'U0001', user_name: 'user' } }
+
+ it 'requests a new token' do
+ is_expected.to be_url
+ end
+ end
+
+ context 'when there are missing parameters' do
+ let(:params) { {} }
+
+ it 'does not request a new token' do
+ is_expected.to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb
new file mode 100644
index 00000000000..51441e8f3be
--- /dev/null
+++ b/spec/services/chat_names/find_user_service_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe ChatNames::FindUserService, services: true do
+ describe '#execute' do
+ let(:service) { create(:service) }
+
+ subject { described_class.new(service, params).execute }
+
+ context 'find user mapping' do
+ let(:user) { create(:user) }
+ let!(:chat_name) { create(:chat_name, user: user, service: service) }
+
+ context 'when existing user is requested' do
+ let(:params) { { team_id: chat_name.team_id, user_id: chat_name.chat_id } }
+
+ it 'returns the existing user' do
+ is_expected.to eq(user)
+ end
+
+ it 'updates when last time chat name was used' do
+ subject
+
+ expect(chat_name.reload.last_used_at).to be_like_time(Time.now)
+ end
+ end
+
+ context 'when different user is requested' do
+ let(:params) { { team_id: chat_name.team_id, user_id: 'non-existing-user' } }
+
+ it 'returns existing user' do
+ is_expected.to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/destroy_group_service_spec.rb b/spec/services/destroy_group_service_spec.rb
index da724643604..538e85cdc89 100644
--- a/spec/services/destroy_group_service_spec.rb
+++ b/spec/services/destroy_group_service_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe DestroyGroupService, services: true do
+ include DatabaseConnectionHelpers
+
let!(:user) { create(:user) }
let!(:group) { create(:group) }
let!(:project) { create(:project, namespace: group) }
@@ -50,6 +52,44 @@ describe DestroyGroupService, services: true do
describe 'asynchronous delete' do
it_behaves_like 'group destruction', true
+
+ context 'potential race conditions' do
+ context "when the `GroupDestroyWorker` task runs immediately" do
+ it "deletes the group" do
+ # Commit the contents of this spec's transaction so far
+ # so subsequent db connections can see it.
+ #
+ # DO NOT REMOVE THIS LINE, even if you see a WARNING with "No
+ # transaction is currently in progress". Without this, this
+ # spec will always be green, since the group created in setup
+ # cannot be seen by any other connections / threads in this spec.
+ Group.connection.commit_db_transaction
+
+ group_record = run_with_new_database_connection do |conn|
+ conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first
+ end
+
+ expect(group_record).not_to be_nil
+
+ # Execute the contents of `GroupDestroyWorker` in a separate thread, to
+ # simulate data manipulation by the Sidekiq worker (different database
+ # connection / transaction).
+ expect(GroupDestroyWorker).to receive(:perform_async).and_wrap_original do |m, group_id, user_id|
+ Thread.new { m[group_id, user_id] }.join(5)
+ end
+
+ # Kick off the initial group destroy in a new thread, so that
+ # it doesn't share this spec's database transaction.
+ Thread.new { DestroyGroupService.new(group, user).async_execute }.join(5)
+
+ group_record = run_with_new_database_connection do |conn|
+ conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first
+ end
+
+ expect(group_record).to be_nil
+ end
+ end
+ end
end
describe 'synchronous delete' do
diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb
index cea7e6429f9..9d7702f5c96 100644
--- a/spec/services/git_push_service_spec.rb
+++ b/spec/services/git_push_service_spec.rb
@@ -27,27 +27,14 @@ describe GitPushService, services: true do
it { is_expected.to be_truthy }
- it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache).
- with('master', newrev)
+ it 'calls the after_push_commit hook' do
+ expect(project.repository).to receive(:after_push_commit).with('master')
subject
end
- it 'flushes the visible content cache' do
- expect(project.repository).to receive(:expire_has_visible_content_cache)
-
- subject
- end
-
- it 'flushes the branches cache' do
- expect(project.repository).to receive(:expire_branches_cache)
-
- subject
- end
-
- it 'flushes the branch count cache' do
- expect(project.repository).to receive(:expire_branch_count_cache)
+ it 'calls the after_create_branch hook' do
+ expect(project.repository).to receive(:after_create_branch)
subject
end
@@ -56,21 +43,8 @@ describe GitPushService, services: true do
context 'existing branch' do
it { is_expected.to be_truthy }
- it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache).
- with('master', newrev)
-
- subject
- end
-
- it 'does not flush the branches cache' do
- expect(project.repository).not_to receive(:expire_branches_cache)
-
- subject
- end
-
- it 'does not flush the branch count cache' do
- expect(project.repository).not_to receive(:expire_branch_count_cache)
+ it 'calls the after_push_commit hook' do
+ expect(project.repository).to receive(:after_push_commit).with('master')
subject
end
@@ -81,27 +55,14 @@ describe GitPushService, services: true do
it { is_expected.to be_truthy }
- it 'flushes the visible content cache' do
- expect(project.repository).to receive(:expire_has_visible_content_cache)
-
- subject
- end
-
- it 'flushes the branches cache' do
- expect(project.repository).to receive(:expire_branches_cache)
-
- subject
- end
-
- it 'flushes the branch count cache' do
- expect(project.repository).to receive(:expire_branch_count_cache)
+ it 'calls the after_push_commit hook' do
+ expect(project.repository).to receive(:after_push_commit).with('master')
subject
end
- it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache).
- with('master', newrev)
+ it 'calls the after_remove_branch hook' do
+ expect(project.repository).to receive(:after_remove_branch)
subject
end
@@ -490,7 +451,17 @@ describe GitPushService, services: true do
context "closing an issue" do
let(:message) { "this is some work.\n\ncloses JIRA-1" }
- let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://localhost/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json }
+ let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://#{Gitlab.config.gitlab.host}/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json }
+
+ before do
+ open_issue = JIRA::Resource::Issue.new(jira_tracker.client, attrs: { "id" => "JIRA-1" })
+ closed_issue = open_issue.dup
+ allow(open_issue).to receive(:resolution).and_return(false)
+ allow(closed_issue).to receive(:resolution).and_return(true)
+ allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue)
+
+ allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-1")
+ end
context "using right markdown" do
it "initiates one api call to jira server to close the issue" do
@@ -588,6 +559,51 @@ describe GitPushService, services: true do
end
end
+ describe '#update_caches' do
+ let(:service) do
+ described_class.new(project,
+ user,
+ oldrev: sample_commit.parent_id,
+ newrev: sample_commit.id,
+ ref: 'refs/heads/master')
+ end
+
+ context 'on the default branch' do
+ before do
+ allow(service).to receive(:is_default_branch?).and_return(true)
+ end
+
+ it 'flushes the caches of any special files that have been changed' do
+ commit = double(:commit)
+ diff = double(:diff, new_path: 'README.md')
+
+ expect(commit).to receive(:raw_diffs).with(deltas_only: true).
+ and_return([diff])
+
+ service.push_commits = [commit]
+
+ expect(ProjectCacheWorker).to receive(:perform_async).
+ with(project.id, %i(readme))
+
+ service.update_caches
+ end
+ end
+
+ context 'on a non-default branch' do
+ before do
+ allow(service).to receive(:is_default_branch?).and_return(false)
+ end
+
+ it 'does not flush any conditional caches' do
+ expect(ProjectCacheWorker).to receive(:perform_async).
+ with(project.id, []).
+ and_call_original
+
+ service.update_caches
+ end
+ end
+ end
+
def execute_service(project, user, oldrev, newrev, ref)
service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref )
service.execute
diff --git a/spec/services/git_tag_push_service_spec.rb b/spec/services/git_tag_push_service_spec.rb
index 0879e3ab4c8..bd074b9bd71 100644
--- a/spec/services/git_tag_push_service_spec.rb
+++ b/spec/services/git_tag_push_service_spec.rb
@@ -18,7 +18,7 @@ describe GitTagPushService, services: true do
end
it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache)
+ expect(project.repository).to receive(:before_push_tag)
subject
end
@@ -28,12 +28,6 @@ describe GitTagPushService, services: true do
subject
end
-
- it 'flushes the tag count cache' do
- expect(project.repository).to receive(:expire_tag_count_cache)
-
- subject
- end
end
describe "Git Tag Push Data" do
diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb
index 6f7ce8ca992..5f3020b6525 100644
--- a/spec/services/issuable/bulk_update_service_spec.rb
+++ b/spec/services/issuable/bulk_update_service_spec.rb
@@ -260,14 +260,14 @@ describe Issuable::BulkUpdateService, services: true do
it 'subscribes the given user' do
bulk_update(issues, subscription_event: 'subscribe')
- expect(issues).to all(be_subscribed(user))
+ expect(issues).to all(be_subscribed(user, project))
end
end
describe 'unsubscribe from issues' do
let(:issues) do
create_list(:closed_issue, 2, project: project) do |issue|
- issue.subscriptions.create(user: user, subscribed: true)
+ issue.subscriptions.create(user: user, project: project, subscribed: true)
end
end
@@ -275,7 +275,7 @@ describe Issuable::BulkUpdateService, services: true do
bulk_update(issues, subscription_event: 'unsubscribe')
issues.each do |issue|
- expect(issue).not_to be_subscribed(user)
+ expect(issue).not_to be_subscribed(user, project)
end
end
end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 1638a46ed51..4777a90639e 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -215,7 +215,7 @@ describe Issues::UpdateService, services: true do
let!(:subscriber) do
create(:user).tap do |u|
- label.toggle_subscription(u)
+ label.toggle_subscription(u, project)
project.team << [u, :developer]
end
end
diff --git a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb
index dd656c3bbb7..a44312dd363 100644
--- a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb
+++ b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb
@@ -1,13 +1,22 @@
require 'spec_helper'
-# Write specs in this file.
describe MergeRequests::AddTodoWhenBuildFailsService do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { create(:project) }
let(:sha) { '1234567890abcdef1234567890abcdef12345678' }
- let(:pipeline) { create(:ci_pipeline_with_one_job, ref: merge_request.source_branch, project: project, sha: sha) }
- let(:service) { MergeRequests::AddTodoWhenBuildFailsService.new(project, user, commit_message: 'Awesome message') }
+ let(:ref) { merge_request.source_branch }
+
+ let(:pipeline) do
+ create(:ci_pipeline_with_one_job, ref: ref,
+ project: project,
+ sha: sha)
+ end
+
+ let(:service) do
+ described_class.new(project, user, commit_message: 'Awesome message')
+ end
+
let(:todo_service) { TodoService.new }
let(:merge_request) do
@@ -23,7 +32,9 @@ describe MergeRequests::AddTodoWhenBuildFailsService do
describe '#execute' do
context 'commit status with ref' do
- let(:commit_status) { create(:generic_commit_status, ref: merge_request.source_branch, pipeline: pipeline) }
+ let(:commit_status) do
+ create(:generic_commit_status, ref: ref, pipeline: pipeline)
+ end
it 'notifies the todo service' do
expect(todo_service).to receive(:merge_request_build_failed).with(merge_request)
@@ -32,7 +43,7 @@ describe MergeRequests::AddTodoWhenBuildFailsService do
end
context 'commit status with non-HEAD ref' do
- let(:commit_status) { create(:generic_commit_status, ref: merge_request.source_branch) }
+ let(:commit_status) { create(:generic_commit_status, ref: ref) }
it 'does not notify the todo service' do
expect(todo_service).not_to receive(:merge_request_build_failed)
@@ -48,6 +59,18 @@ describe MergeRequests::AddTodoWhenBuildFailsService do
service.execute(commit_status)
end
end
+
+ context 'when commit status is a build allowed to fail' do
+ let(:commit_status) do
+ create(:ci_build, :allowed_to_fail, ref: ref, pipeline: pipeline)
+ end
+
+ it 'does not create todo' do
+ expect(todo_service).not_to receive(:merge_request_build_failed)
+
+ service.execute(commit_status)
+ end
+ end
end
describe '#close' do
diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb
index 3a71776e81f..08829e4be70 100644
--- a/spec/services/merge_requests/get_urls_service_spec.rb
+++ b/spec/services/merge_requests/get_urls_service_spec.rb
@@ -4,8 +4,8 @@ describe MergeRequests::GetUrlsService do
let(:project) { create(:project, :public) }
let(:service) { MergeRequests::GetUrlsService.new(project) }
let(:source_branch) { "my_branch" }
- let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" }
- let(:show_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" }
+ let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" }
+ let(:show_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" }
let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" }
let(:deleted_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 #{Gitlab::Git::BLANK_SHA} refs/heads/#{source_branch}" }
let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" }
@@ -115,7 +115,7 @@ describe MergeRequests::GetUrlsService do
let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" }
let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/existing_branch" }
let(:changes) { "#{new_branch_changes}\n#{existing_branch_changes}" }
- let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" }
+ let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" }
it 'returns 2 urls for both creating new and showing merge request' do
result = service.execute(changes)
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index f93d7732a9a..7db32a33c93 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -59,25 +59,43 @@ describe MergeRequests::MergeService, services: true do
include JiraServiceHelper
let(:jira_tracker) { project.create_jira_service }
+ let(:jira_issue) { ExternalIssue.new('JIRA-123', project) }
+ let(:commit) { double('commit', safe_message: "Fixes #{jira_issue.to_reference}") }
before do
project.update_attributes!(has_external_issue_tracker: true)
jira_service_settings
+ stub_jira_urls(jira_issue.id)
+ allow(merge_request).to receive(:commits).and_return([commit])
end
it 'closes issues on JIRA issue tracker' do
jira_issue = ExternalIssue.new('JIRA-123', project)
+ stub_jira_urls(jira_issue)
commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}")
allow(merge_request).to receive(:commits).and_return([commit])
- expect_any_instance_of(JiraService).to receive(:close_issue).with(merge_request, jira_issue).once
+ expect_any_instance_of(JiraService).to receive(:close_issue).with(merge_request, an_instance_of(JIRA::Resource::Issue)).once
service.execute(merge_request)
end
+ context "when jira_issue_transition_id is not present" do
+ before { allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(nil) }
+
+ it "does not close issue" do
+ allow(jira_tracker).to receive_messages(jira_issue_transition_id: nil)
+
+ expect_any_instance_of(JiraService).not_to receive(:transition_issue)
+
+ service.execute(merge_request)
+ end
+ end
+
context "wrong issue markdown" do
it 'does not close issues on JIRA issue tracker' do
- jira_issue = ExternalIssue.new('#123', project)
+ jira_issue = ExternalIssue.new('#JIRA-123', project)
+ stub_jira_urls(jira_issue)
commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}")
allow(merge_request).to receive(:commits).and_return([commit])
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 0220f7e1db2..e515bc9f89c 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -227,16 +227,6 @@ describe MergeRequests::RefreshService, services: true do
end
end
- context 'when the source branch is deleted' do
- it 'does not create a MergeRequestDiff record' do
- refresh_service = service.new(@project, @user)
-
- expect do
- refresh_service.execute(@oldrev, Gitlab::Git::BLANK_SHA, 'refs/heads/master')
- end.not_to change { MergeRequestDiff.count }
- end
- end
-
def reload_mrs
@merge_request.reload
@fork_merge_request.reload
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index 2433a7dad06..cb5d7cdb467 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -199,7 +199,7 @@ describe MergeRequests::UpdateService, services: true do
context 'when the issue is relabeled' do
let!(:non_subscriber) { create(:user) }
- let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } }
+ let!(:subscriber) { create(:user) { |u| label.toggle_subscription(u, project) } }
before do
project.team << [non_subscriber, :developer]
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 8ce35354c22..8726c9eaa55 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -342,7 +342,9 @@ describe NotificationService, services: true do
end
describe 'Issues' do
- let(:project) { create(:empty_project, :public) }
+ let(:group) { create(:group) }
+ let(:project) { create(:empty_project, :public, namespace: group) }
+ let(:another_project) { create(:empty_project, :public, namespace: group) }
let(:issue) { create :issue, project: project, assignee: create(:user), description: 'cc @participant' }
before do
@@ -377,13 +379,24 @@ describe NotificationService, services: true do
end
it "emails subscribers of the issue's labels" do
- subscriber = create(:user)
- label = create(:label, issues: [issue])
+ user_1 = create(:user)
+ user_2 = create(:user)
+ user_3 = create(:user)
+ user_4 = create(:user)
+ label = create(:label, project: project, issues: [issue])
+ group_label = create(:group_label, group: group, issues: [issue])
issue.reload
- label.toggle_subscription(subscriber)
+ label.toggle_subscription(user_1, project)
+ group_label.toggle_subscription(user_2, project)
+ group_label.toggle_subscription(user_3, another_project)
+ group_label.toggle_subscription(user_4)
+
notification.new_issue(issue, @u_disabled)
- should_email(subscriber)
+ should_email(user_1)
+ should_email(user_2)
+ should_not_email(user_3)
+ should_email(user_4)
end
context 'confidential issues' do
@@ -399,14 +412,14 @@ describe NotificationService, services: true do
project.team << [member, :developer]
project.team << [guest, :guest]
- label = create(:label, issues: [confidential_issue])
+ label = create(:label, project: project, issues: [confidential_issue])
confidential_issue.reload
- label.toggle_subscription(non_member)
- label.toggle_subscription(author)
- label.toggle_subscription(assignee)
- label.toggle_subscription(member)
- label.toggle_subscription(guest)
- label.toggle_subscription(admin)
+ label.toggle_subscription(non_member, project)
+ label.toggle_subscription(author, project)
+ label.toggle_subscription(assignee, project)
+ label.toggle_subscription(member, project)
+ label.toggle_subscription(guest, project)
+ label.toggle_subscription(admin, project)
reset_delivered_emails!
@@ -554,20 +567,30 @@ describe NotificationService, services: true do
end
describe '#relabeled_issue' do
- let(:label) { create(:label, issues: [issue]) }
- let(:label2) { create(:label) }
- let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } }
- let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } }
+ let(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1', issues: [issue]) }
+ let(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') }
+ let(:label_1) { create(:label, project: project, title: 'Label 1', issues: [issue]) }
+ let(:label_2) { create(:label, project: project, title: 'Label 2') }
+ let!(:subscriber_to_group_label_1) { create(:user) { |u| group_label_1.toggle_subscription(u, project) } }
+ let!(:subscriber_1_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u, project) } }
+ let!(:subscriber_2_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u) } }
+ let!(:subscriber_to_group_label_2_on_another_project) { create(:user) { |u| group_label_2.toggle_subscription(u, another_project) } }
+ let!(:subscriber_to_label_1) { create(:user) { |u| label_1.toggle_subscription(u, project) } }
+ let!(:subscriber_to_label_2) { create(:user) { |u| label_2.toggle_subscription(u, project) } }
it "emails subscribers of the issue's added labels only" do
- notification.relabeled_issue(issue, [label2], @u_disabled)
-
- should_not_email(subscriber_to_label)
- should_email(subscriber_to_label2)
+ notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled)
+
+ should_not_email(subscriber_to_label_1)
+ should_not_email(subscriber_to_group_label_1)
+ should_not_email(subscriber_to_group_label_2_on_another_project)
+ should_email(subscriber_1_to_group_label_2)
+ should_email(subscriber_2_to_group_label_2)
+ should_email(subscriber_to_label_2)
end
it "doesn't send email to anyone but subscribers of the given labels" do
- notification.relabeled_issue(issue, [label2], @u_disabled)
+ notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled)
should_not_email(issue.assignee)
should_not_email(issue.author)
@@ -578,8 +601,12 @@ describe NotificationService, services: true do
should_not_email(@watcher_and_subscriber)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
- should_not_email(subscriber_to_label)
- should_email(subscriber_to_label2)
+ should_not_email(subscriber_to_label_1)
+ should_not_email(subscriber_to_group_label_1)
+ should_not_email(subscriber_to_group_label_2_on_another_project)
+ should_email(subscriber_1_to_group_label_2)
+ should_email(subscriber_2_to_group_label_2)
+ should_email(subscriber_to_label_2)
end
context 'confidential issues' do
@@ -590,19 +617,19 @@ describe NotificationService, services: true do
let(:guest) { create(:user) }
let(:admin) { create(:admin) }
let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) }
- let!(:label_1) { create(:label, issues: [confidential_issue]) }
- let!(:label_2) { create(:label) }
+ let!(:label_1) { create(:label, project: project, issues: [confidential_issue]) }
+ let!(:label_2) { create(:label, project: project) }
it "emails subscribers of the issue's labels that can read the issue" do
project.team << [member, :developer]
project.team << [guest, :guest]
- label_2.toggle_subscription(non_member)
- label_2.toggle_subscription(author)
- label_2.toggle_subscription(assignee)
- label_2.toggle_subscription(member)
- label_2.toggle_subscription(guest)
- label_2.toggle_subscription(admin)
+ label_2.toggle_subscription(non_member, project)
+ label_2.toggle_subscription(author, project)
+ label_2.toggle_subscription(assignee, project)
+ label_2.toggle_subscription(member, project)
+ label_2.toggle_subscription(guest, project)
+ label_2.toggle_subscription(admin, project)
reset_delivered_emails!
@@ -725,7 +752,9 @@ describe NotificationService, services: true do
end
describe 'Merge Requests' do
- let(:project) { create(:project, :public) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:another_project) { create(:empty_project, :public, namespace: group) }
let(:merge_request) { create :merge_request, source_project: project, assignee: create(:user), description: 'cc @participant' }
before do
@@ -758,12 +787,23 @@ describe NotificationService, services: true do
end
it "emails subscribers of the merge request's labels" do
- subscriber = create(:user)
- label = create(:label, merge_requests: [merge_request])
- label.toggle_subscription(subscriber)
+ user_1 = create(:user)
+ user_2 = create(:user)
+ user_3 = create(:user)
+ user_4 = create(:user)
+ label = create(:label, project: project, merge_requests: [merge_request])
+ group_label = create(:group_label, group: group, merge_requests: [merge_request])
+ label.toggle_subscription(user_1, project)
+ group_label.toggle_subscription(user_2, project)
+ group_label.toggle_subscription(user_3, another_project)
+ group_label.toggle_subscription(user_4)
+
notification.new_merge_request(merge_request, @u_disabled)
- should_email(subscriber)
+ should_email(user_1)
+ should_email(user_2)
+ should_not_email(user_3)
+ should_email(user_4)
end
context 'participating' do
@@ -857,20 +897,30 @@ describe NotificationService, services: true do
end
describe '#relabel_merge_request' do
- let(:label) { create(:label, merge_requests: [merge_request]) }
- let(:label2) { create(:label) }
- let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } }
- let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } }
+ let(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1', merge_requests: [merge_request]) }
+ let(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') }
+ let(:label_1) { create(:label, project: project, title: 'Label 1', merge_requests: [merge_request]) }
+ let(:label_2) { create(:label, project: project, title: 'Label 2') }
+ let!(:subscriber_to_group_label_1) { create(:user) { |u| group_label_1.toggle_subscription(u, project) } }
+ let!(:subscriber_1_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u, project) } }
+ let!(:subscriber_2_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u) } }
+ let!(:subscriber_to_group_label_2_on_another_project) { create(:user) { |u| group_label_2.toggle_subscription(u, another_project) } }
+ let!(:subscriber_to_label_1) { create(:user) { |u| label_1.toggle_subscription(u, project) } }
+ let!(:subscriber_to_label_2) { create(:user) { |u| label_2.toggle_subscription(u, project) } }
it "emails subscribers of the merge request's added labels only" do
- notification.relabeled_merge_request(merge_request, [label2], @u_disabled)
-
- should_not_email(subscriber_to_label)
- should_email(subscriber_to_label2)
+ notification.relabeled_merge_request(merge_request, [group_label_2, label_2], @u_disabled)
+
+ should_not_email(subscriber_to_label_1)
+ should_not_email(subscriber_to_group_label_1)
+ should_not_email(subscriber_to_group_label_2_on_another_project)
+ should_email(subscriber_1_to_group_label_2)
+ should_email(subscriber_2_to_group_label_2)
+ should_email(subscriber_to_label_2)
end
it "doesn't send email to anyone but subscribers of the given labels" do
- notification.relabeled_merge_request(merge_request, [label2], @u_disabled)
+ notification.relabeled_merge_request(merge_request, [group_label_2, label_2], @u_disabled)
should_not_email(merge_request.assignee)
should_not_email(merge_request.author)
@@ -881,8 +931,12 @@ describe NotificationService, services: true do
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(@u_lazy_participant)
- should_not_email(subscriber_to_label)
- should_email(subscriber_to_label2)
+ should_not_email(subscriber_to_label_1)
+ should_not_email(subscriber_to_group_label_1)
+ should_not_email(subscriber_to_group_label_2_on_another_project)
+ should_email(subscriber_1_to_group_label_2)
+ should_email(subscriber_2_to_group_label_2)
+ should_email(subscriber_to_label_2)
end
end
@@ -1290,10 +1344,10 @@ describe NotificationService, services: true do
project.team << [@unsubscriber, :master]
project.team << [@watcher_and_subscriber, :master]
- issuable.subscriptions.create(user: @subscriber, subscribed: true)
- issuable.subscriptions.create(user: @subscribed_participant, subscribed: true)
- issuable.subscriptions.create(user: @unsubscriber, subscribed: false)
+ issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true)
+ issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true)
+ issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false)
# Make the watcher a subscriber to detect dupes
- issuable.subscriptions.create(user: @watcher_and_subscriber, subscribed: true)
+ issuable.subscriptions.create(user: @watcher_and_subscriber, project: project, subscribed: true)
end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index 2cf9883113c..fbd22560d6e 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -34,6 +34,8 @@ describe Projects::CreateService, services: true do
@group = create :group
@group.add_owner(@user)
+ @user.refresh_authorized_projects # Ensure cache is warm
+
@opts.merge!(namespace_id: @group.id)
@project = create_project(@user, @opts)
end
@@ -41,6 +43,7 @@ describe Projects::CreateService, services: true do
it { expect(@project).to be_valid }
it { expect(@project.owner).to eq(@group) }
it { expect(@project.namespace).to eq(@group) }
+ it { expect(@user.authorized_projects).to include(@project) }
end
context 'error handling' do
diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb
index b57e338b782..becf627a4f5 100644
--- a/spec/services/slash_commands/interpret_service_spec.rb
+++ b/spec/services/slash_commands/interpret_service_spec.rb
@@ -169,7 +169,7 @@ describe SlashCommands::InterpretService, services: true do
shared_examples 'unsubscribe command' do
it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do
- issuable.subscribe(developer)
+ issuable.subscribe(developer, project)
_, updates = service.execute(content, issuable)
expect(updates).to eq(subscription_event: 'unsubscribe')
@@ -321,7 +321,7 @@ describe SlashCommands::InterpretService, services: true do
it_behaves_like 'multiple label with same argument' do
let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) }
let(:issuable) { issue }
- end
+ end
it_behaves_like 'unlabel command' do
let(:content) { %(/unlabel ~"#{inprogress.title}") }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 5bb107fdd85..2a5709c6322 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe SystemNoteService, services: true do
+ include Gitlab::Routing.url_helpers
+
let(:project) { create(:project) }
let(:author) { create(:user) }
let(:noteable) { create(:issue, project: project) }
@@ -534,32 +536,117 @@ describe SystemNoteService, services: true do
let(:project) { create(:jira_project) }
let(:author) { create(:user) }
let(:issue) { create(:issue, project: project) }
- let(:mergereq) { create(:merge_request, :simple, target_project: project, source_project: project) }
+ let(:merge_request) { create(:merge_request, :simple, target_project: project, source_project: project) }
let(:jira_issue) { ExternalIssue.new("JIRA-1", project)}
let(:jira_tracker) { project.jira_service }
let(:commit) { project.commit }
let(:comment_url) { jira_api_comment_url(jira_issue.id) }
let(:success_message) { "JiraService SUCCESS: Successfully posted to http://jira.example.net." }
- before { stub_jira_urls(jira_issue.id) }
+ before do
+ stub_jira_urls(jira_issue.id)
+ jira_service_settings
+ end
+
+ noteable_types = ["merge_requests", "commit"]
+
+ noteable_types.each do |type|
+ context "when noteable is a #{type}" do
+ it "blocks cross reference when #{type.underscore}_events is false" do
+ jira_tracker.update("#{type}_events" => false)
+
+ noteable = type == "commit" ? commit : merge_request
+ result = described_class.cross_reference(jira_issue, noteable, author)
+
+ expect(result).to eq("Events for #{noteable.class.to_s.underscore.humanize.pluralize.downcase} are disabled.")
+ end
- context 'in JIRA issue tracker' do
- before { jira_service_settings }
+ it "blocks cross reference when #{type.underscore}_events is true" do
+ jira_tracker.update("#{type}_events" => true)
- describe "new reference" do
- subject { described_class.cross_reference(jira_issue, commit, author) }
+ noteable = type == "commit" ? commit : merge_request
+ result = described_class.cross_reference(jira_issue, noteable, author)
- it { is_expected.to eq(success_message) }
+ expect(result).to eq(success_message)
+ end
end
end
- context 'issue from an issue' do
- context 'in JIRA issue tracker' do
- before { jira_service_settings }
+ describe "new reference" do
+ context 'for commits' do
+ it "creates comment" do
+ result = described_class.cross_reference(jira_issue, commit, author)
+
+ expect(result).to eq(success_message)
+ end
+
+ it "creates remote link" do
+ described_class.cross_reference(jira_issue, commit, author)
+
+ expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with(
+ body: hash_including(
+ GlobalID: "GitLab",
+ object: {
+ url: namespace_project_commit_url(project.namespace, project, commit),
+ title: "GitLab: Mentioned on commit - #{commit.title}",
+ icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ status: { resolved: false }
+ }
+ )
+ ).once
+ end
+ end
+
+ context 'for issues' do
+ let(:issue) { create(:issue, project: project) }
- subject { described_class.cross_reference(jira_issue, issue, author) }
+ it "creates comment" do
+ result = described_class.cross_reference(jira_issue, issue, author)
- it { is_expected.to eq(success_message) }
+ expect(result).to eq(success_message)
+ end
+
+ it "creates remote link" do
+ described_class.cross_reference(jira_issue, issue, author)
+
+ expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with(
+ body: hash_including(
+ GlobalID: "GitLab",
+ object: {
+ url: namespace_project_issue_url(project.namespace, project, issue),
+ title: "GitLab: Mentioned on issue - #{issue.title}",
+ icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ status: { resolved: false }
+ }
+ )
+ ).once
+ end
+ end
+
+ context 'for snippets' do
+ let(:snippet) { create(:snippet, project: project) }
+
+ it "creates comment" do
+ result = described_class.cross_reference(jira_issue, snippet, author)
+
+ expect(result).to eq(success_message)
+ end
+
+ it "creates remote link" do
+ described_class.cross_reference(jira_issue, snippet, author)
+
+ expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with(
+ body: hash_including(
+ GlobalID: "GitLab",
+ object: {
+ url: namespace_project_snippet_url(project.namespace, project, snippet),
+ title: "GitLab: Mentioned on snippet - #{snippet.title}",
+ icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ status: { resolved: false }
+ }
+ )
+ ).once
+ end
end
end
@@ -569,9 +656,18 @@ describe SystemNoteService, services: true do
allow_any_instance_of(JIRA::Resource::Issue).to receive(:comments).and_return([OpenStruct.new(body: message)])
end
- subject { described_class.cross_reference(jira_issue, commit, author) }
+ it "does not return success message" do
+ result = described_class.cross_reference(jira_issue, commit, author)
- it { is_expected.not_to eq(success_message) }
+ expect(result).not_to eq(success_message)
+ end
+
+ it 'does not try to create comment and remote link' do
+ subject
+
+ expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue))
+ expect(WebMock).not_to have_requested(:post, jira_api_remote_link_url(jira_issue))
+ end
end
end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 73cf4c9a24c..bead1a006d1 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -26,10 +26,11 @@ RSpec.configure do |config|
config.verbose_retry = true
config.display_try_failure_messages = true
- config.include Devise::Test::ControllerHelpers, type: :controller
+ config.include Devise::Test::ControllerHelpers, type: :controller
+ config.include Devise::Test::ControllerHelpers, type: :view
config.include Warden::Test::Helpers, type: :request
- config.include LoginHelpers, type: :feature
- config.include SearchHelpers, type: :feature
+ config.include LoginHelpers, type: :feature
+ config.include SearchHelpers, type: :feature
config.include StubConfiguration
config.include EmailHelpers
config.include TestEnv
diff --git a/spec/support/database_connection_helpers.rb b/spec/support/database_connection_helpers.rb
new file mode 100644
index 00000000000..763329499f0
--- /dev/null
+++ b/spec/support/database_connection_helpers.rb
@@ -0,0 +1,9 @@
+module DatabaseConnectionHelpers
+ def run_with_new_database_connection
+ pool = ActiveRecord::Base.connection_pool
+ conn = pool.checkout
+ yield conn
+ ensure
+ pool.checkin(conn)
+ end
+end
diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb
index ac38e31b77e..247f0954221 100644
--- a/spec/support/db_cleaner.rb
+++ b/spec/support/db_cleaner.rb
@@ -11,6 +11,10 @@ RSpec.configure do |config|
DatabaseCleaner.strategy = :truncation
end
+ config.before(:each, truncate: true) do
+ DatabaseCleaner.strategy = :truncation
+ end
+
config.before(:each) do
DatabaseCleaner.start
end
diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb
index 5e3b8f2b23e..194620d0a68 100644
--- a/spec/support/features/issuable_slash_commands_shared_examples.rb
+++ b/spec/support/features/issuable_slash_commands_shared_examples.rb
@@ -230,31 +230,31 @@ shared_examples 'issuable record that supports slash commands in its description
context "with a note subscribing to the #{issuable_type}" do
it "creates a new todo for the #{issuable_type}" do
- expect(issuable.subscribed?(master)).to be_falsy
+ expect(issuable.subscribed?(master, project)).to be_falsy
write_note("/subscribe")
expect(page).not_to have_content '/subscribe'
expect(page).to have_content 'Your commands have been executed!'
- expect(issuable.subscribed?(master)).to be_truthy
+ expect(issuable.subscribed?(master, project)).to be_truthy
end
end
context "with a note unsubscribing to the #{issuable_type} as done" do
before do
- issuable.subscribe(master)
+ issuable.subscribe(master, project)
end
it "creates a new todo for the #{issuable_type}" do
- expect(issuable.subscribed?(master)).to be_truthy
+ expect(issuable.subscribed?(master, project)).to be_truthy
write_note("/unsubscribe")
expect(page).not_to have_content '/unsubscribe'
expect(page).to have_content 'Your commands have been executed!'
- expect(issuable.subscribed?(master)).to be_falsy
+ expect(issuable.subscribed?(master, project)).to be_falsy
end
end
end
diff --git a/spec/support/jira_service_helper.rb b/spec/support/jira_service_helper.rb
index 96e0dad6b55..929fc0c5182 100644
--- a/spec/support/jira_service_helper.rb
+++ b/spec/support/jira_service_helper.rb
@@ -6,7 +6,8 @@ module JiraServiceHelper
properties = {
title: "JIRA tracker",
url: JIRA_URL,
- project_key: "JIRA"
+ project_key: "JIRA",
+ jira_issue_transition_id: '1'
}
jira_tracker.update_attributes(properties: properties, active: true)
@@ -57,6 +58,10 @@ module JiraServiceHelper
JIRA_API + "/issue/#{issue_id}/comment"
end
+ def jira_api_remote_link_url(issue_id)
+ JIRA_API + "/issue/#{issue_id}/remotelink"
+ end
+
def jira_api_transition_url(issue_id)
JIRA_API + "/issue/#{issue_id}/transitions"
end
@@ -75,6 +80,7 @@ module JiraServiceHelper
WebMock.stub_request(:get, jira_issue_url(issue_id))
WebMock.stub_request(:get, jira_api_test_url)
WebMock.stub_request(:post, jira_api_comment_url(issue_id))
+ WebMock.stub_request(:post, jira_api_remote_link_url(issue_id))
WebMock.stub_request(:post, jira_api_transition_url(issue_id))
end
end
diff --git a/spec/support/matchers/be_url.rb b/spec/support/matchers/be_url.rb
new file mode 100644
index 00000000000..f8096af1b22
--- /dev/null
+++ b/spec/support/matchers/be_url.rb
@@ -0,0 +1,5 @@
+RSpec::Matchers.define :be_url do |_|
+ match do |actual|
+ URI.parse(actual) rescue false
+ end
+end
diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb
index da43622d3f9..e0c77201116 100644
--- a/spec/views/projects/builds/show.html.haml_spec.rb
+++ b/spec/views/projects/builds/show.html.haml_spec.rb
@@ -1,14 +1,12 @@
require 'spec_helper'
-describe 'projects/builds/show' do
- include Devise::Test::ControllerHelpers
-
+describe 'projects/builds/show', :view do
let(:project) { create(:project) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+
let(:pipeline) do
- create(:ci_pipeline, project: project,
- sha: project.commit.id)
+ create(:ci_pipeline, project: project, sha: project.commit.id)
end
- let(:build) { create(:ci_build, pipeline: pipeline) }
before do
assign(:build, build)
@@ -17,6 +15,129 @@ describe 'projects/builds/show' do
allow(view).to receive(:can?).and_return(true)
end
+ describe 'environment info in build view' do
+ context 'build with latest deployment' do
+ let(:build) do
+ create(:ci_build, :success, environment: 'staging')
+ end
+
+ before do
+ create(:environment, name: 'staging')
+ create(:deployment, deployable: build)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This build is the most recent deployment'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+
+ context 'build with outdated deployment' do
+ let(:build) do
+ create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
+ end
+
+ let(:second_build) do
+ create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
+ end
+
+ let(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ let!(:first_deployment) do
+ create(:deployment, environment: environment, deployable: build)
+ end
+
+ let!(:second_deployment) do
+ create(:deployment, environment: environment, deployable: second_build)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This build is an out-of-date deployment ' \
+ "to staging.\nView the most recent deployment ##{second_deployment.iid}."
+ render
+
+ expect(rendered).to have_css('.environment-information', text: expected_text)
+ end
+ end
+
+ context 'build failed to deploy' do
+ let(:build) do
+ create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
+ end
+
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'The deployment of this build to staging did not succeed.'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+
+ context 'build will deploy' do
+ let(:build) do
+ create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
+ end
+
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This build is creating a deployment to staging'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+
+ context 'build that failed to deploy and environment has not been created' do
+ let(:build) do
+ create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
+ end
+
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'The deployment of this build to staging did not succeed'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+
+ context 'build that will deploy and environment has not been created' do
+ let(:build) do
+ create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
+ end
+
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This build is creating a deployment to staging'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+ end
+
context 'when build is running' do
before do
build.run!
diff --git a/spec/workers/authorized_projects_worker_spec.rb b/spec/workers/authorized_projects_worker_spec.rb
new file mode 100644
index 00000000000..18a1aab766c
--- /dev/null
+++ b/spec/workers/authorized_projects_worker_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe AuthorizedProjectsWorker do
+ describe '#perform' do
+ it "refreshes user's authorized projects" do
+ user = create(:user)
+
+ expect(User).to receive(:find_by).with(id: user.id).and_return(user)
+ expect(user).to receive(:refresh_authorized_projects)
+
+ described_class.new.perform(user.id)
+ end
+
+ context "when user is not found" do
+ it "does nothing" do
+ expect_any_instance_of(User).not_to receive(:refresh_authorized_projects)
+
+ described_class.new.perform(999_999)
+ end
+ end
+ end
+end
diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb
index 2c9e7c2cd02..2d47d93acec 100644
--- a/spec/workers/pipeline_metrics_worker_spec.rb
+++ b/spec/workers/pipeline_metrics_worker_spec.rb
@@ -15,32 +15,36 @@ describe PipelineMetricsWorker do
end
describe '#perform' do
- subject { described_class.new.perform(pipeline.id) }
+ before do
+ described_class.new.perform(pipeline.id)
+ end
context 'when pipeline is running' do
let(:status) { 'running' }
it 'records the build start time' do
- subject
-
expect(merge_request.reload.metrics.latest_build_started_at).to be_like_time(pipeline.started_at)
end
it 'clears the build end time' do
- subject
-
expect(merge_request.reload.metrics.latest_build_finished_at).to be_nil
end
+
+ it 'records the pipeline' do
+ expect(merge_request.reload.metrics.pipeline).to eq(pipeline)
+ end
end
context 'when pipeline succeeded' do
let(:status) { 'success' }
it 'records the build end time' do
- subject
-
expect(merge_request.reload.metrics.latest_build_finished_at).to be_like_time(pipeline.finished_at)
end
+
+ it 'records the pipeline' do
+ expect(merge_request.reload.metrics.pipeline).to eq(pipeline)
+ end
end
end
end
diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb
index bfa8c0ff2c6..855c28b584e 100644
--- a/spec/workers/project_cache_worker_spec.rb
+++ b/spec/workers/project_cache_worker_spec.rb
@@ -2,62 +2,78 @@ require 'spec_helper'
describe ProjectCacheWorker do
let(:project) { create(:project) }
+ let(:worker) { described_class.new }
- subject { described_class.new }
-
- describe '.perform_async' do
- it 'schedules the job when no lease exists' do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:exists?).
- and_return(false)
+ describe '#perform' do
+ before do
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).
+ and_return(true)
+ end
- expect_any_instance_of(described_class).to receive(:perform)
+ context 'with a non-existing project' do
+ it 'does nothing' do
+ expect(worker).not_to receive(:update_repository_size)
- described_class.perform_async(project.id)
+ worker.perform(-1)
+ end
end
- it 'does not schedule the job when a lease exists' do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:exists?).
- and_return(true)
+ context 'with an existing project without a repository' do
+ it 'does nothing' do
+ allow_any_instance_of(Repository).to receive(:exists?).and_return(false)
- expect_any_instance_of(described_class).not_to receive(:perform)
+ expect(worker).not_to receive(:update_repository_size)
- described_class.perform_async(project.id)
+ worker.perform(project.id)
+ end
end
- end
- describe '#perform' do
- context 'when an exclusive lease can be obtained' do
- before do
- allow(subject).to receive(:try_obtain_lease_for).with(project.id).
- and_return(true)
- end
+ context 'with an existing project' do
+ it 'updates the repository size' do
+ expect(worker).to receive(:update_repository_size).and_call_original
- it 'updates project cache data' do
- expect_any_instance_of(Repository).to receive(:size)
- expect_any_instance_of(Repository).to receive(:commit_count)
+ worker.perform(project.id)
+ end
- expect_any_instance_of(Project).to receive(:update_repository_size)
- expect_any_instance_of(Project).to receive(:update_commit_count)
+ it 'updates the commit count' do
+ expect_any_instance_of(Project).to receive(:update_commit_count).
+ and_call_original
- subject.perform(project.id)
+ worker.perform(project.id)
end
- it 'handles missing repository data' do
- expect_any_instance_of(Repository).to receive(:exists?).and_return(false)
- expect_any_instance_of(Repository).not_to receive(:size)
+ it 'refreshes the method caches' do
+ expect_any_instance_of(Repository).to receive(:refresh_method_caches).
+ with(%i(readme)).
+ and_call_original
- subject.perform(project.id)
+ worker.perform(project.id, %i(readme))
end
end
+ end
- context 'when an exclusive lease can not be obtained' do
- it 'does nothing' do
- allow(subject).to receive(:try_obtain_lease_for).with(project.id).
+ describe '#update_repository_size' do
+ context 'when a lease could not be obtained' do
+ it 'does not update the repository size' do
+ allow(worker).to receive(:try_obtain_lease_for).
+ with(project.id, :update_repository_size).
and_return(false)
- expect(subject).not_to receive(:update_caches)
+ expect(project).not_to receive(:update_repository_size)
+
+ worker.update_repository_size(project)
+ end
+ end
+
+ context 'when a lease could be obtained' do
+ it 'updates the repository size' do
+ allow(worker).to receive(:try_obtain_lease_for).
+ with(project.id, :update_repository_size).
+ and_return(true)
+
+ expect(project).to receive(:update_repository_size).and_call_original
- subject.perform(project.id)
+ worker.update_repository_size(project)
end
end
end