Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLin Jen-Shin <godfat@godfat.org>2017-04-10 13:38:26 +0300
committerLin Jen-Shin <godfat@godfat.org>2017-04-10 13:38:26 +0300
commit75ded1c73e5c1499947a3c951d9af54abfd2fd5c (patch)
treed5ce58f9ed21489745f6e28d76809699b337f775
parent3aed06cfa387406c2e0257a8783bc05b53d4385a (diff)
parentc98add157732004d9a2eaa39770edf84eaca6896 (diff)
Merge remote-tracking branch 'upstream/master' into test-pg-mysql
* upstream/master: (238 commits) Periodically clean up temporary upload files to recover storage space Add a name field to the group edit form Remove the User#is_admin? method Fix specs Enable RSpec/DescribeSymbol; update .rubocop_todo.yml Update rubocop-rspec 1.12.0 -> 1.15.0 Rename displaying_blame to blame Actually include WaitForAjax! Don't show Copy contents button on Blame page alfredo review changes Give explicit height to SVG icons for Safari Update MR title change icon Put back usernames in activity and profile feed Wait for AJAX requests to complete so they don't blow up if they are only handled after DatabaseCleaner has already run Revert yarn.lock changes add CHANGELOG.md entry for !10522 upgrade webpack-dev-server to fix issues with SockJS causing odd reload behavior in firefox upgrade webpack to v2.3.3 to resolve sourcemap issues Rever yarn.lock changes Revert yarn.lock file changes ...
-rw-r--r--.babelrc1
-rw-r--r--.gitlab-ci.yml2
-rw-r--r--.rubocop.yml4
-rw-r--r--.rubocop_todo.yml126
-rw-r--r--Gemfile4
-rw-r--r--Gemfile.lock8
-rw-r--r--PROCESS.md19
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/behaviors/autosize.js45
-rw-r--r--app/assets/javascripts/behaviors/details_behavior.js45
-rw-r--r--app/assets/javascripts/behaviors/index.js9
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js109
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js90
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js83
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js4
-rw-r--r--app/assets/javascripts/build.js268
-rw-r--r--app/assets/javascripts/comment_type_toggle.js60
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js12
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js12
-rw-r--r--app/assets/javascripts/dispatcher.js11
-rw-r--r--app/assets/javascripts/droplab/constants.js11
-rw-r--r--app/assets/javascripts/droplab/drop_down.js139
-rw-r--r--app/assets/javascripts/droplab/drop_lab.js152
-rw-r--r--app/assets/javascripts/droplab/droplab.js741
-rw-r--r--app/assets/javascripts/droplab/droplab_ajax.js103
-rw-r--r--app/assets/javascripts/droplab/droplab_ajax_filter.js164
-rw-r--r--app/assets/javascripts/droplab/droplab_filter.js76
-rw-r--r--app/assets/javascripts/droplab/hook.js22
-rw-r--r--app/assets/javascripts/droplab/hook_button.js65
-rw-r--r--app/assets/javascripts/droplab/hook_input.js119
-rw-r--r--app/assets/javascripts/droplab/keyboard.js113
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax.js65
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax_filter.js133
-rw-r--r--app/assets/javascripts/droplab/plugins/filter.js95
-rw-r--r--app/assets/javascripts/droplab/plugins/input_setter.js50
-rw-r--r--app/assets/javascripts/droplab/utils.js38
-rw-r--r--app/assets/javascripts/files_comment_button.js26
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js10
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js22
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js17
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js2
-rw-r--r--app/assets/javascripts/gl_form.js3
-rw-r--r--app/assets/javascripts/group.js21
-rw-r--r--app/assets/javascripts/main.js17
-rw-r--r--app/assets/javascripts/merge_request_tabs.js11
-rw-r--r--app/assets/javascripts/milestone_select.js6
-rw-r--r--app/assets/javascripts/notes.js196
-rw-r--r--app/assets/javascripts/protected_tags/index.js2
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js26
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js41
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js86
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js52
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit_list.js18
-rw-r--r--app/assets/javascripts/render_gfm.js1
-rw-r--r--app/assets/javascripts/subscription.js5
-rw-r--r--app/assets/javascripts/users_select.js8
-rw-r--r--app/assets/stylesheets/framework/filters.scss6
-rw-r--r--app/assets/stylesheets/pages/builds.scss32
-rw-r--r--app/assets/stylesheets/pages/events.scss2
-rw-r--r--app/assets/stylesheets/pages/issuable.scss1
-rw-r--r--app/assets/stylesheets/pages/note_form.scss91
-rw-r--r--app/assets/stylesheets/pages/notes.scss14
-rw-r--r--app/assets/stylesheets/pages/projects.scss14
-rw-r--r--app/controllers/admin/application_controller.rb2
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/concerns/renders_notes.rb20
-rw-r--r--app/controllers/concerns/requires_health_token.rb25
-rw-r--r--app/controllers/health_check_controller.rb21
-rw-r--r--app/controllers/health_controller.rb60
-rw-r--r--app/controllers/projects/builds_controller.rb5
-rw-r--r--app/controllers/projects/commit_controller.rb20
-rw-r--r--app/controllers/projects/discussions_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb12
-rwxr-xr-xapp/controllers/projects/merge_requests_controller.rb32
-rw-r--r--app/controllers/projects/notes_controller.rb94
-rw-r--r--app/controllers/projects/pipelines_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb2
-rw-r--r--app/controllers/projects/protected_branches_controller.rb57
-rw-r--r--app/controllers/projects/protected_refs_controller.rb47
-rw-r--r--app/controllers/projects/protected_tags_controller.rb23
-rw-r--r--app/controllers/projects/settings/repository_controller.rb48
-rw-r--r--app/controllers/projects/snippets_controller.rb5
-rw-r--r--app/controllers/projects/triggers_controller.rb15
-rw-r--r--app/finders/notes_finder.rb65
-rw-r--r--app/helpers/branches_helper.rb6
-rw-r--r--app/helpers/diff_helper.rb8
-rw-r--r--app/helpers/notes_helper.rb56
-rw-r--r--app/helpers/system_note_helper.rb2
-rw-r--r--app/helpers/tags_helper.rb4
-rw-r--r--app/helpers/visibility_level_helper.rb2
-rw-r--r--app/mailers/emails/notes.rb17
-rw-r--r--app/mailers/notify.rb4
-rw-r--r--app/models/ci/pipeline.rb32
-rw-r--r--app/models/ci/pipeline_status.rb86
-rw-r--r--app/models/ci/trigger.rb6
-rw-r--r--app/models/ci/trigger_schedule.rb21
-rw-r--r--app/models/commit.rb5
-rw-r--r--app/models/commit_status.rb5
-rw-r--r--app/models/concerns/discussion_on_diff.rb57
-rw-r--r--app/models/concerns/has_status.rb3
-rw-r--r--app/models/concerns/ignorable_column.rb28
-rw-r--r--app/models/concerns/issuable.rb11
-rw-r--r--app/models/concerns/note_on_diff.rb9
-rw-r--r--app/models/concerns/noteable.rb68
-rw-r--r--app/models/concerns/protected_branch_access.rb16
-rw-r--r--app/models/concerns/protected_ref.rb42
-rw-r--r--app/models/concerns/protected_ref_access.rb18
-rw-r--r--app/models/concerns/protected_tag_access.rb11
-rw-r--r--app/models/concerns/resolvable_discussion.rb103
-rw-r--r--app/models/concerns/resolvable_note.rb72
-rw-r--r--app/models/diff_discussion.rb26
-rw-r--r--app/models/diff_note.rb120
-rw-r--r--app/models/discussion.rb200
-rw-r--r--app/models/discussion_note.rb13
-rw-r--r--app/models/individual_note_discussion.rb13
-rw-r--r--app/models/issue.rb1
-rw-r--r--app/models/legacy_diff_discussion.rb25
-rw-r--r--app/models/legacy_diff_note.rb24
-rw-r--r--app/models/merge_request.rb45
-rw-r--r--app/models/milestone.rb4
-rw-r--r--app/models/note.rb133
-rw-r--r--app/models/out_of_context_discussion.rb22
-rw-r--r--app/models/project.rb28
-rw-r--r--app/models/project_services/jira_service.rb2
-rw-r--r--app/models/protectable_dropdown.rb33
-rw-r--r--app/models/protected_branch.rb58
-rw-r--r--app/models/protected_ref_matcher.rb54
-rw-r--r--app/models/protected_tag.rb14
-rw-r--r--app/models/protected_tag/create_access_level.rb21
-rw-r--r--app/models/repository.rb2
-rw-r--r--app/models/sent_notification.rb84
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/user.rb4
-rw-r--r--app/policies/ci/runner_policy.rb2
-rw-r--r--app/policies/global_policy.rb1
-rw-r--r--app/presenters/ci/build_presenter.rb6
-rw-r--r--app/presenters/ci/pipeline_presenter.rb11
-rw-r--r--app/services/ci/create_pipeline_service.rb18
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb2
-rw-r--r--app/services/concerns/issues/resolve_discussions.rb4
-rw-r--r--app/services/delete_branch_service.rb2
-rw-r--r--app/services/git_push_service.rb2
-rw-r--r--app/services/issues/build_service.rb13
-rw-r--r--app/services/notes/build_service.rb25
-rw-r--r--app/services/notes/create_service.rb8
-rw-r--r--app/services/protected_branches/update_service.rb7
-rw-r--r--app/services/protected_tags/create_service.rb11
-rw-r--r--app/services/protected_tags/update_service.rb10
-rw-r--r--app/services/slash_commands/interpret_service.rb2
-rw-r--r--app/services/system_note_service.rb8
-rw-r--r--app/services/users/create_service.rb6
-rw-r--r--app/uploaders/file_uploader.rb4
-rw-r--r--app/views/ci/status/_badge.html.haml9
-rw-r--r--app/views/discussions/_diff_discussion.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml16
-rw-r--r--app/views/discussions/_notes.html.haml28
-rw-r--r--app/views/discussions/_parallel_diff_discussion.html.haml14
-rw-r--r--app/views/discussions/_resolve_all.html.haml17
-rw-r--r--app/views/events/event/_common.html.haml1
-rw-r--r--app/views/events/event/_created_project.html.haml1
-rw-r--r--app/views/events/event/_note.html.haml1
-rw-r--r--app/views/events/event/_push.html.haml1
-rw-r--r--app/views/layouts/header/_default.html.haml14
-rw-r--r--app/views/layouts/mailer.text.erb4
-rw-r--r--app/views/layouts/mailer.text.haml5
-rw-r--r--app/views/layouts/notify.html.haml4
-rw-r--r--app/views/layouts/notify.text.erb12
-rw-r--r--app/views/notify/_note_email.html.haml37
-rw-r--r--app/views/notify/_note_email.text.erb26
-rw-r--r--app/views/notify/_note_message.html.haml5
-rw-r--r--app/views/notify/_note_message.text.erb5
-rw-r--r--app/views/notify/_note_mr_or_commit_email.html.haml18
-rw-r--r--app/views/notify/_note_mr_or_commit_email.text.erb8
-rw-r--r--app/views/notify/_simple_diff.text.erb3
-rw-r--r--app/views/notify/new_issue_email.html.haml10
-rw-r--r--app/views/notify/new_mention_in_issue_email.html.haml10
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.html.haml13
-rw-r--r--app/views/notify/new_merge_request_email.html.haml10
-rw-r--r--app/views/notify/note_commit_email.html.haml3
-rw-r--r--app/views/notify/note_commit_email.text.erb3
-rw-r--r--app/views/notify/note_issue_email.html.haml2
-rw-r--r--app/views/notify/note_issue_email.text.erb10
-rw-r--r--app/views/notify/note_merge_request_email.html.haml3
-rw-r--r--app/views/notify/note_merge_request_email.text.erb3
-rw-r--r--app/views/notify/note_personal_snippet_email.html.haml2
-rw-r--r--app/views/notify/note_personal_snippet_email.text.erb9
-rw-r--r--app/views/notify/note_snippet_email.html.haml2
-rw-r--r--app/views/notify/note_snippet_email.text.erb9
-rw-r--r--app/views/projects/blame/show.html.haml2
-rw-r--r--app/views/projects/blob/_header.html.haml5
-rw-r--r--app/views/projects/branches/_branch.html.haml2
-rw-r--r--app/views/projects/builds/_header.html.haml12
-rw-r--r--app/views/projects/builds/show.html.haml5
-rw-r--r--app/views/projects/ci/builds/_build.html.haml82
-rw-r--r--app/views/projects/commit/show.html.haml1
-rw-r--r--app/views/projects/diffs/_line.html.haml9
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml8
-rw-r--r--app/views/projects/environments/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml4
-rw-r--r--app/views/projects/notes/_comment_button.html.haml30
-rw-r--r--app/views/projects/notes/_form.html.haml16
-rw-r--r--app/views/projects/notes/_note.html.haml2
-rw-r--r--app/views/projects/notes/_notes.html.haml6
-rw-r--r--app/views/projects/pipelines/_info.html.haml2
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml17
-rw-r--r--app/views/projects/protected_branches/show.html.haml8
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml32
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_tags/_index.html.haml18
-rw-r--r--app/views/projects/protected_tags/_matching_tag.html.haml9
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml21
-rw-r--r--app/views/projects/protected_tags/_tags_list.html.haml28
-rw-r--r--app/views/projects/protected_tags/_update_protected_tag.haml5
-rw-r--r--app/views/projects/protected_tags/show.html.haml25
-rw-r--r--app/views/projects/settings/repository/show.html.haml1
-rw-r--r--app/views/projects/tags/_tag.html.haml7
-rw-r--r--app/views/projects/tags/show.html.haml5
-rw-r--r--app/views/projects/triggers/_form.html.haml22
-rw-r--r--app/views/projects/triggers/_index.html.haml2
-rw-r--r--app/views/projects/triggers/_trigger.html.haml6
-rw-r--r--app/views/shared/_group_form.html.haml18
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--app/workers/trigger_schedule_worker.rb2
-rw-r--r--changelogs/unreleased/18471-restrict-tag-pushes-protected-tags.yml4
-rw-r--r--changelogs/unreleased/24240-add-monitoring-endpoints.yml4
-rw-r--r--changelogs/unreleased/27580-fix-show-go-back.yml4
-rw-r--r--changelogs/unreleased/28574-jira-trigers.yml4
-rw-r--r--changelogs/unreleased/30056-rename-milestones-empty.yml4
-rw-r--r--changelogs/unreleased/30587-pipeline-icon-z.yml4
-rw-r--r--changelogs/unreleased/30588-fix-javascript-sourcemaps-w-chrome-breakpoints.yml4
-rw-r--r--changelogs/unreleased/8998_skip_pending_commits_if_not_head.yml4
-rw-r--r--changelogs/unreleased/adam-finish-5993-closed-issuable.yml4
-rw-r--r--changelogs/unreleased/add-field-for-group-name.yml4
-rw-r--r--changelogs/unreleased/add-ui-for-trigger-schedule.yml4
-rw-r--r--changelogs/unreleased/clean_carrierwave_tempfiles.yml4
-rw-r--r--changelogs/unreleased/dz-hide-zero-counter.yml4
-rw-r--r--changelogs/unreleased/metrics-button-misplaced.yml4
-rw-r--r--changelogs/unreleased/new-resolvable-discussion.yml4
-rw-r--r--changelogs/unreleased/optimise-builds-view.yml4
-rw-r--r--changelogs/unreleased/remove_is_admin.yml4
-rw-r--r--config/routes.rb6
-rw-r--r--config/routes/project.rb2
-rw-r--r--config/webpack.config.js2
-rw-r--r--db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb29
-rw-r--r--db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb15
-rw-r--r--db/migrate/20170309173138_create_protected_tags.rb27
-rw-r--r--db/migrate/20170312114329_add_auto_canceled_by_id_to_pipeline.rb9
-rw-r--r--db/migrate/20170312114529_add_auto_canceled_by_id_foreign_key_to_pipeline.rb22
-rw-r--r--db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb2
-rw-r--r--db/migrate/20170406114958_add_auto_canceled_by_id_to_ci_builds.rb9
-rw-r--r--db/migrate/20170406115029_add_auto_canceled_by_id_foreign_key_to_ci_builds.rb22
-rw-r--r--db/migrate/20170407114956_add_ref_to_ci_trigger_schedule.rb9
-rw-r--r--db/migrate/20170407122426_add_active_to_ci_trigger_schedule.rb9
-rw-r--r--db/migrate/20170407140450_add_index_to_next_run_at_and_active.rb18
-rw-r--r--db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb23
-rw-r--r--db/post_migrate/20170408033905_remove_old_cache_directories.rb23
-rw-r--r--db/schema.rb37
-rw-r--r--doc/README.md46
-rw-r--r--doc/ci/variables/README.md2
-rw-r--r--doc/gitlab-basics/create-group.md2
-rw-r--r--doc/gitlab-basics/img/create_new_group_info.pngbin20321 -> 105173 bytes
-rw-r--r--doc/university/glossary/README.md4
-rw-r--r--doc/user/permissions.md1
-rw-r--r--doc/user/project/img/project_repository_settings.pngbin0 -> 35236 bytes
-rw-r--r--doc/user/project/img/protected_tag_matches.pngbin0 -> 85305 bytes
-rw-r--r--doc/user/project/img/protected_tags_list.pngbin0 -> 24490 bytes
-rw-r--r--doc/user/project/img/protected_tags_page.pngbin0 -> 56112 bytes
-rw-r--r--doc/user/project/img/protected_tags_permissions_dropdown.pngbin0 -> 26514 bytes
-rw-r--r--doc/user/project/integrations/jira.md2
-rw-r--r--doc/user/project/pipelines/settings.md9
-rw-r--r--doc/user/project/protected_tags.md60
-rw-r--r--doc/user/search/img/filter_issues_project.gifbin0 -> 1430218 bytes
-rwxr-xr-xdoc/user/search/img/issues_any_assignee.pngbin0 -> 90455 bytes
-rwxr-xr-xdoc/user/search/img/issues_assigned_to_you.pngbin0 -> 49079 bytes
-rwxr-xr-xdoc/user/search/img/issues_author.pngbin0 -> 55217 bytes
-rwxr-xr-xdoc/user/search/img/issues_mrs_shortcut.pngbin0 -> 34115 bytes
-rwxr-xr-xdoc/user/search/img/left_menu_bar.pngbin0 -> 37433 bytes
-rwxr-xr-xdoc/user/search/img/project_search.pngbin0 -> 41900 bytes
-rwxr-xr-xdoc/user/search/img/search_issues_board.pngbin0 -> 82113 bytes
-rwxr-xr-xdoc/user/search/img/sort_projects.pngbin0 -> 59495 bytes
-rw-r--r--doc/user/search/index.md94
-rw-r--r--doc/workflow/README.md1
-rw-r--r--doc/workflow/groups.md4
-rw-r--r--doc/workflow/groups/new_group_form.pngbin27263 -> 114515 bytes
-rw-r--r--features/steps/project/merge_requests.rb3
-rw-r--r--features/steps/project/merge_requests/acceptance.rb9
-rw-r--r--features/steps/shared/project.rb5
-rw-r--r--lib/api/entities.rb16
-rw-r--r--lib/api/groups.rb2
-rw-r--r--lib/api/helpers.rb4
-rw-r--r--lib/api/internal.rb2
-rw-r--r--lib/api/notes.rb2
-rw-r--r--lib/api/runners.rb8
-rw-r--r--lib/api/services.rb4
-rw-r--r--lib/api/users.rb6
-rw-r--r--lib/api/v3/groups.rb2
-rw-r--r--lib/api/v3/notes.rb2
-rw-r--r--lib/api/v3/runners.rb2
-rw-r--r--lib/api/v3/services.rb2
-rw-r--r--lib/banzai/filter/issuable_state_filter.rb35
-rw-r--r--lib/banzai/filter/redactor_filter.rb2
-rw-r--r--lib/banzai/issuable_extractor.rb36
-rw-r--r--lib/banzai/object_renderer.rb46
-rw-r--r--lib/banzai/pipeline/post_process_pipeline.rb1
-rw-r--r--lib/banzai/reference_parser/base_parser.rb20
-rw-r--r--lib/banzai/reference_parser/issue_parser.rb10
-rw-r--r--lib/banzai/reference_parser/merge_request_parser.rb8
-rw-r--r--lib/banzai/reference_parser/user_parser.rb6
-rw-r--r--lib/gitlab/cache/ci/project_pipeline_status.rb103
-rw-r--r--lib/gitlab/checks/change_access.rb39
-rw-r--r--lib/gitlab/diff/line.rb4
-rw-r--r--lib/gitlab/email/handler.rb6
-rw-r--r--lib/gitlab/email/handler/create_note_handler.rb13
-rw-r--r--lib/gitlab/git/repository.rb10
-rw-r--r--lib/gitlab/gitaly_client/commit.rb21
-rw-r--r--lib/gitlab/gitaly_client/notifications.rb9
-rw-r--r--lib/gitlab/gitaly_client/ref.rb13
-rw-r--r--lib/gitlab/gitaly_client/util.rb14
-rw-r--r--lib/gitlab/health_checks/base_abstract_check.rb45
-rw-r--r--lib/gitlab/health_checks/db_check.rb29
-rw-r--r--lib/gitlab/health_checks/fs_shards_check.rb117
-rw-r--r--lib/gitlab/health_checks/metric.rb3
-rw-r--r--lib/gitlab/health_checks/redis_check.rb25
-rw-r--r--lib/gitlab/health_checks/result.rb3
-rw-r--r--lib/gitlab/health_checks/simple_abstract_check.rb43
-rw-r--r--lib/gitlab/import_export/import_export.yml2
-rw-r--r--lib/gitlab/import_export/project_tree_restorer.rb6
-rw-r--r--lib/gitlab/import_export/relation_factory.rb3
-rw-r--r--lib/gitlab/user_access.rb20
-rw-r--r--lib/gitlab/visibility_level.rb2
-rw-r--r--lib/gitlab/workhorse.rb10
-rw-r--r--lib/tasks/gitlab/gitaly.rake14
-rw-r--r--lib/tasks/import.rake11
-rw-r--r--package.json4
-rw-r--r--public/404.html16
-rw-r--r--public/422.html17
-rw-r--r--public/500.html16
-rw-r--r--public/502.html16
-rw-r--r--public/503.html16
-rw-r--r--qa/qa/page/main/groups.rb2
-rw-r--r--spec/controllers/health_controller_spec.rb96
-rw-r--r--spec/controllers/projects/builds_controller_spec.rb33
-rw-r--r--spec/controllers/projects/commit_controller_spec.rb4
-rw-r--r--spec/controllers/projects/discussions_controller_spec.rb2
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb2
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb4
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb133
-rw-r--r--spec/controllers/projects/protected_branches_controller_spec.rb1
-rw-r--r--spec/controllers/projects/protected_tags_controller_spec.rb11
-rw-r--r--spec/factories/ci/trigger_schedules.rb4
-rw-r--r--spec/factories/merge_requests.rb4
-rw-r--r--spec/factories/notes.rb28
-rw-r--r--spec/factories/protected_tags.rb22
-rw-r--r--spec/factories/sent_notifications.rb4
-rw-r--r--spec/features/admin/admin_groups_spec.rb39
-rw-r--r--spec/features/admin/admin_users_spec.rb2
-rw-r--r--spec/features/dashboard/group_spec.rb14
-rw-r--r--spec/features/dashboard/issuables_counter_spec.rb35
-rw-r--r--spec/features/dashboard/project_member_activity_index_spec.rb6
-rw-r--r--spec/features/dashboard/projects_spec.rb11
-rw-r--r--spec/features/discussion_comments_spec.rb295
-rw-r--r--spec/features/groups_spec.rb2
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_hint_spec.rb14
-rw-r--r--spec/features/issues/filtered_search/dropdown_label_spec.rb31
-rw-r--r--spec/features/issues/filtered_search/dropdown_milestone_spec.rb14
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb18
-rw-r--r--spec/features/merge_requests/diff_notes_avatars_spec.rb4
-rw-r--r--spec/features/merge_requests/diff_notes_resolve_spec.rb2
-rw-r--r--spec/features/notes_on_merge_requests_spec.rb2
-rw-r--r--spec/features/projects/settings/pipelines_settings_spec.rb11
-rw-r--r--spec/features/protected_branches/access_control_ce_spec.rb12
-rw-r--r--spec/features/protected_tags/access_control_ce_spec.rb46
-rw-r--r--spec/features/protected_tags_spec.rb94
-rw-r--r--spec/features/triggers_spec.rb53
-rw-r--r--spec/finders/notes_finder_spec.rb41
-rw-r--r--spec/helpers/ci_status_helper_spec.rb6
-rw-r--r--spec/helpers/issues_helper_spec.rb2
-rw-r--r--spec/helpers/notes_helper_spec.rb17
-rw-r--r--spec/helpers/projects_helper_spec.rb2
-rw-r--r--spec/javascripts/build_spec.js106
-rw-r--r--spec/javascripts/comment_type_toggle_spec.js157
-rw-r--r--spec/javascripts/droplab/constants_spec.js29
-rw-r--r--spec/javascripts/droplab/drop_down_spec.js593
-rw-r--r--spec/javascripts/droplab/hook_spec.js82
-rw-r--r--spec/javascripts/droplab/plugins/input_setter_spec.js212
-rw-r--r--spec/javascripts/filtered_search/dropdown_user_spec.js8
-rw-r--r--spec/javascripts/merge_request_tabs_spec.js8
-rw-r--r--spec/lib/banzai/filter/issuable_state_filter_spec.rb95
-rw-r--r--spec/lib/banzai/filter/redactor_filter_spec.rb10
-rw-r--r--spec/lib/banzai/issuable_extractor_spec.rb52
-rw-r--r--spec/lib/banzai/object_renderer_spec.rb139
-rw-r--r--spec/lib/banzai/reference_parser/base_parser_spec.rb18
-rw-r--r--spec/lib/banzai/reference_parser/issue_parser_spec.rb12
-rw-r--r--spec/lib/banzai/reference_parser/user_parser_spec.rb9
-rw-r--r--spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb (renamed from spec/models/ci/pipeline_status_spec.rb)33
-rw-r--r--spec/lib/gitlab/checks/change_access_spec.rb79
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb1
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_spec.rb2
-rw-r--r--spec/lib/gitlab/gitaly_client/notifications_spec.rb2
-rw-r--r--spec/lib/gitlab/gitaly_client/ref_spec.rb2
-rw-r--r--spec/lib/gitlab/healthchecks/db_check_spec.rb6
-rw-r--r--spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb127
-rw-r--r--spec/lib/gitlab/healthchecks/redis_check_spec.rb6
-rw-r--r--spec/lib/gitlab/healthchecks/simple_check_shared.rb66
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml10
-rw-r--r--spec/lib/gitlab/import_export/project.json18
-rw-r--r--spec/lib/gitlab/import_export/project_tree_restorer_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml18
-rw-r--r--spec/lib/gitlab/user_access_spec.rb69
-rw-r--r--spec/mailers/notify_spec.rb142
-rw-r--r--spec/mailers/previews/notify_preview.rb96
-rw-r--r--spec/models/ci/pipeline_spec.rb55
-rw-r--r--spec/models/commit_status_spec.rb27
-rw-r--r--spec/models/concerns/discussion_on_diff_spec.rb24
-rw-r--r--spec/models/concerns/has_status_spec.rb12
-rw-r--r--spec/models/concerns/noteable_spec.rb261
-rw-r--r--spec/models/concerns/resolvable_discussion_spec.rb548
-rw-r--r--spec/models/concerns/resolvable_note_spec.rb329
-rw-r--r--spec/models/diff_discussion_spec.rb19
-rw-r--r--spec/models/diff_note_spec.rb334
-rw-r--r--spec/models/discussion_spec.rb623
-rw-r--r--spec/models/legacy_diff_discussion_spec.rb11
-rw-r--r--spec/models/legacy_diff_note_spec.rb101
-rw-r--r--spec/models/merge_request_spec.rb178
-rw-r--r--spec/models/milestone_spec.rb12
-rw-r--r--spec/models/note_spec.rb293
-rw-r--r--spec/models/project_spec.rb77
-rw-r--r--spec/models/protectable_dropdown_spec.rb25
-rw-r--r--spec/models/protected_branch_spec.rb64
-rw-r--r--spec/models/protected_tag_spec.rb12
-rw-r--r--spec/models/sent_notification_spec.rb166
-rw-r--r--spec/models/user_spec.rb4
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb26
-rw-r--r--spec/presenters/ci/pipeline_presenter_spec.rb54
-rw-r--r--spec/requests/api/issues_spec.rb2
-rw-r--r--spec/requests/api/session_spec.rb6
-rw-r--r--spec/requests/api/v3/issues_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb221
-rw-r--r--spec/services/ci/expire_pipeline_cache_service_spec.rb7
-rw-r--r--spec/services/ci/retry_build_service_spec.rb7
-rw-r--r--spec/services/discussions/resolve_service_spec.rb4
-rw-r--r--spec/services/issues/build_service_spec.rb16
-rw-r--r--spec/services/issues/create_service_spec.rb2
-rw-r--r--spec/services/issues/resolve_discussions_spec.rb2
-rw-r--r--spec/services/notes/build_service_spec.rb40
-rw-r--r--spec/services/notification_service_spec.rb2
-rw-r--r--spec/services/protected_branches/update_service_spec.rb26
-rw-r--r--spec/services/protected_tags/create_service_spec.rb21
-rw-r--r--spec/services/protected_tags/update_service_spec.rb26
-rw-r--r--spec/services/system_note_service_spec.rb2
-rw-r--r--spec/support/slash_commands_helpers.rb2
-rw-r--r--spec/support/time_tracking_shared_examples.rb2
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb32
-rw-r--r--spec/views/projects/builds/show.html.haml_spec.rb2
-rw-r--r--spec/views/projects/notes/_form.html.haml_spec.rb4
-rw-r--r--spec/views/projects/pipelines/show.html.haml_spec.rb10
-rw-r--r--spec/workers/trigger_schedule_worker_spec.rb33
-rw-r--r--yarn.lock95
462 files changed, 10582 insertions, 4822 deletions
diff --git a/.babelrc b/.babelrc
index ee4c391da30..2bae7ca9fbf 100644
--- a/.babelrc
+++ b/.babelrc
@@ -8,7 +8,6 @@
"plugins": [
["istanbul", {
"exclude": [
- "app/assets/javascripts/droplab/**/*",
"spec/javascripts/**/*"
]
}],
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 3ceb0b4ba79..a900825b3de 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -434,7 +434,7 @@ bundler:audit:
- master@gitlab/gitlabhq
- master@gitlab/gitlab-ee
script:
- - git fetch origin v8.5.9
+ - git fetch origin v8.14.10
- git checkout -f FETCH_HEAD
- cp config/resque.yml.example config/resque.yml
- sed -i 's/localhost/redis/g' config/resque.yml
diff --git a/.rubocop.yml b/.rubocop.yml
index ac6b141cea3..e5549b64503 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -954,6 +954,10 @@ RSpec/DescribeClass:
RSpec/DescribeMethod:
Enabled: false
+# Avoid describing symbols.
+RSpec/DescribeSymbol:
+ Enabled: true
+
# Checks that the second argument to top level describe is the tested method
# name.
RSpec/DescribedClass:
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 8588988dc87..38b22afdf82 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,12 +1,12 @@
# This configuration was generated by
# `rubocop --auto-gen-config --exclude-limit 0`
-# on 2017-02-22 13:02:35 -0600 using RuboCop version 0.47.1.
+# on 2017-04-07 20:17:35 -0400 using RuboCop version 0.47.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
-# Offense count: 51
+# Offense count: 54
RSpec/BeforeAfterAll:
Enabled: false
@@ -15,11 +15,19 @@ RSpec/BeforeAfterAll:
RSpec/EmptyExampleGroup:
Enabled: false
-# Offense count: 1
+# Offense count: 233
+RSpec/EmptyLineAfterFinalLet:
+ Enabled: false
+
+# Offense count: 167
+RSpec/EmptyLineAfterSubject:
+ Enabled: false
+
+# Offense count: 3
RSpec/ExpectOutput:
Enabled: false
-# Offense count: 63
+# Offense count: 72
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: implicit, each, example
RSpec/HookArgument:
@@ -31,19 +39,37 @@ RSpec/HookArgument:
RSpec/ImplicitExpect:
Enabled: false
-# Offense count: 36
-RSpec/RepeatedExample:
+# Offense count: 11
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+# SupportedStyles: it_behaves_like, it_should_behave_like
+RSpec/ItBehavesLike:
+ Enabled: false
+
+# Offense count: 4
+RSpec/IteratedExpectation:
+ Enabled: false
+
+# Offense count: 3
+RSpec/OverwritingSetup:
Enabled: false
# Offense count: 34
+RSpec/RepeatedExample:
+ Enabled: false
+
+# Offense count: 43
+RSpec/ScatteredLet:
+ Enabled: false
+
+# Offense count: 32
RSpec/ScatteredSetup:
Enabled: false
# Offense count: 1
-RSpec/SingleArgumentMessageChain:
+RSpec/SharedContext:
Enabled: false
-# Offense count: 163
+# Offense count: 150
Rails/FilePath:
Enabled: false
@@ -53,7 +79,7 @@ Rails/FilePath:
Rails/ReversibleMigration:
Enabled: false
-# Offense count: 278
+# Offense count: 302
# Configuration parameters: Blacklist.
# Blacklist: decrement!, decrement_counter, increment!, increment_counter, toggle!, touch, update_all, update_attribute, update_column, update_columns, update_counters
Rails/SkipsModelValidations:
@@ -64,26 +90,26 @@ Rails/SkipsModelValidations:
Security/YAMLLoad:
Enabled: false
-# Offense count: 55
+# Offense count: 59
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: percent_q, bare_percent
Style/BarePercentLiterals:
Enabled: false
-# Offense count: 1304
+# Offense count: 1403
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: leading, trailing
Style/DotPosition:
Enabled: false
-# Offense count: 6
+# Offense count: 5
# Cop supports --auto-correct.
Style/EachWithObject:
Enabled: false
-# Offense count: 25
+# Offense count: 28
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: empty, nil, both
@@ -95,72 +121,72 @@ Style/EmptyElse:
Style/EmptyLiteral:
Enabled: false
-# Offense count: 56
+# Offense count: 59
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: compact, expanded
Style/EmptyMethod:
Enabled: false
-# Offense count: 184
+# Offense count: 214
# Cop supports --auto-correct.
# Configuration parameters: AllowForAlignment, ForceEqualSignAlignment.
Style/ExtraSpacing:
Enabled: false
-# Offense count: 8
+# Offense count: 9
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: format, sprintf, percent
Style/FormatString:
Enabled: false
-# Offense count: 268
+# Offense count: 285
# Configuration parameters: MinBodyLength.
Style/GuardClause:
Enabled: false
-# Offense count: 14
+# Offense count: 16
Style/IfInsideElse:
Enabled: false
-# Offense count: 179
+# Offense count: 186
# Cop supports --auto-correct.
# Configuration parameters: MaxLineLength.
Style/IfUnlessModifier:
Enabled: false
-# Offense count: 57
+# Offense count: 99
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth.
# SupportedStyles: special_inside_parentheses, consistent, align_brackets
Style/IndentArray:
Enabled: false
-# Offense count: 120
+# Offense count: 160
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth.
# SupportedStyles: special_inside_parentheses, consistent, align_braces
Style/IndentHash:
Enabled: false
-# Offense count: 45
+# Offense count: 50
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: line_count_dependent, lambda, literal
Style/Lambda:
Enabled: false
-# Offense count: 7
+# Offense count: 6
# Cop supports --auto-correct.
Style/LineEndConcatenation:
Enabled: false
-# Offense count: 22
+# Offense count: 34
# Cop supports --auto-correct.
Style/MethodCallWithoutArgsParentheses:
Enabled: false
-# Offense count: 9
+# Offense count: 10
Style/MethodMissing:
Enabled: false
@@ -169,26 +195,26 @@ Style/MethodMissing:
Style/MultilineIfModifier:
Enabled: false
-# Offense count: 22
+# Offense count: 24
# Cop supports --auto-correct.
Style/NestedParenthesizedCalls:
Enabled: false
-# Offense count: 17
+# Offense count: 18
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, MinBodyLength, SupportedStyles.
# SupportedStyles: skip_modifier_ifs, always
Style/Next:
Enabled: false
-# Offense count: 31
+# Offense count: 37
# Cop supports --auto-correct.
# Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles.
# SupportedOctalStyles: zero_with_o, zero_only
Style/NumericLiteralPrefix:
Enabled: false
-# Offense count: 77
+# Offense count: 88
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles.
# SupportedStyles: predicate, comparison
@@ -200,7 +226,7 @@ Style/NumericPredicate:
Style/ParallelAssignment:
Enabled: false
-# Offense count: 477
+# Offense count: 570
# Cop supports --auto-correct.
# Configuration parameters: PreferredDelimiters.
Style/PercentLiteralDelimiters:
@@ -211,7 +237,7 @@ Style/PercentLiteralDelimiters:
Style/PerlBackrefs:
Enabled: false
-# Offense count: 72
+# Offense count: 83
# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist.
# NamePrefix: is_, has_, have_
# NamePrefixBlacklist: is_, has_, have_
@@ -219,21 +245,21 @@ Style/PerlBackrefs:
Style/PredicateName:
Enabled: false
-# Offense count: 39
+# Offense count: 45
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: short, verbose
Style/PreferredHashMethods:
Enabled: false
-# Offense count: 62
+# Offense count: 65
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: compact, exploded
Style/RaiseArgs:
Enabled: false
-# Offense count: 4
+# Offense count: 5
# Cop supports --auto-correct.
Style/RedundantBegin:
Enabled: false
@@ -249,19 +275,19 @@ Style/RedundantFreeze:
Style/RedundantReturn:
Enabled: false
-# Offense count: 365
+# Offense count: 382
# Cop supports --auto-correct.
Style/RedundantSelf:
Enabled: false
-# Offense count: 108
+# Offense count: 111
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes.
# SupportedStyles: slashes, percent_r, mixed
Style/RegexpLiteral:
Enabled: false
-# Offense count: 22
+# Offense count: 24
# Cop supports --auto-correct.
Style/RescueModifier:
Enabled: false
@@ -277,7 +303,7 @@ Style/SelfAssignment:
Style/SingleLineMethods:
Enabled: false
-# Offense count: 155
+# Offense count: 168
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: space, no_space
@@ -290,14 +316,14 @@ Style/SpaceBeforeBlockBraces:
Style/SpaceBeforeFirstArg:
Enabled: false
-# Offense count: 38
+# Offense count: 46
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: require_no_space, require_space
Style/SpaceInLambdaLiteral:
Enabled: false
-# Offense count: 203
+# Offense count: 229
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces, SpaceBeforeBlockParameters.
# SupportedStyles: space, no_space
@@ -305,58 +331,58 @@ Style/SpaceInLambdaLiteral:
Style/SpaceInsideBlockBraces:
Enabled: false
-# Offense count: 91
+# Offense count: 116
# Cop supports --auto-correct.
Style/SpaceInsideParens:
Enabled: false
-# Offense count: 4
+# Offense count: 12
# Cop supports --auto-correct.
Style/SpaceInsidePercentLiteralDelimiters:
Enabled: false
-# Offense count: 55
+# Offense count: 57
# Cop supports --auto-correct.
# Configuration parameters: SupportedStyles.
# SupportedStyles: use_perl_names, use_english_names
Style/SpecialGlobalVars:
EnforcedStyle: use_perl_names
-# Offense count: 40
+# Offense count: 42
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiteralsInInterpolation:
Enabled: false
-# Offense count: 57
+# Offense count: 64
# Cop supports --auto-correct.
# Configuration parameters: IgnoredMethods.
# IgnoredMethods: respond_to, define_method
Style/SymbolProc:
Enabled: false
-# Offense count: 5
+# Offense count: 6
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles, AllowSafeAssignment.
# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex
Style/TernaryParentheses:
Enabled: false
-# Offense count: 43
+# Offense count: 53
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyleForMultiline, SupportedStylesForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma
Style/TrailingCommaInArguments:
Enabled: false
-# Offense count: 13
+# Offense count: 18
# Cop supports --auto-correct.
# Configuration parameters: AllowNamedUnderscoreVariables.
Style/TrailingUnderscoreVariable:
Enabled: false
-# Offense count: 70
+# Offense count: 78
# Cop supports --auto-correct.
Style/TrailingWhitespace:
Enabled: false
@@ -373,7 +399,7 @@ Style/TrivialAccessors:
Style/UnlessElse:
Enabled: false
-# Offense count: 22
+# Offense count: 24
# Cop supports --auto-correct.
Style/UnneededInterpolation:
Enabled: false
diff --git a/Gemfile b/Gemfile
index b16505b3aa2..d4b2ade4243 100644
--- a/Gemfile
+++ b/Gemfile
@@ -304,7 +304,7 @@ group :development, :test do
gem 'spring-commands-spinach', '~> 1.1.0'
gem 'rubocop', '~> 0.47.1', require: false
- gem 'rubocop-rspec', '~> 1.12.0', require: false
+ gem 'rubocop-rspec', '~> 1.15.0', require: false
gem 'scss_lint', '~> 0.47.0', require: false
gem 'haml_lint', '~> 0.21.0', require: false
gem 'simplecov', '~> 0.14.0', require: false
@@ -356,3 +356,5 @@ gem 'sys-filesystem', '~> 1.1.6'
# Gitaly GRPC client
gem 'gitaly', '~> 0.5.0'
+
+gem 'toml-rb', '~> 0.3.15', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index e4603df5f7f..d7e3f7343d0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -117,6 +117,7 @@ GEM
chronic_duration (0.10.6)
numerizer (~> 0.1.1)
chunky_png (1.3.5)
+ citrus (3.0.2)
cliver (0.3.2)
coderay (1.1.1)
coercible (1.0.0)
@@ -668,7 +669,7 @@ GEM
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
- rubocop-rspec (1.12.0)
+ rubocop-rspec (1.15.0)
rubocop (>= 0.42.0)
ruby-fogbugz (0.2.1)
crack (~> 0.4)
@@ -784,6 +785,8 @@ GEM
tilt (2.0.6)
timecop (0.8.1)
timfel-krb5-auth (0.8.3)
+ toml-rb (0.3.15)
+ citrus (~> 3.0, > 3.0)
tool (0.2.3)
truncato (0.7.8)
htmlentities (~> 4.3.1)
@@ -984,7 +987,7 @@ DEPENDENCIES
rspec-retry (~> 0.4.5)
rspec_profiling (~> 0.0.5)
rubocop (~> 0.47.1)
- rubocop-rspec (~> 1.12.0)
+ rubocop-rspec (~> 1.15.0)
ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.16.2)
rufus-scheduler (~> 3.1.10)
@@ -1015,6 +1018,7 @@ DEPENDENCIES
test_after_commit (~> 1.1)
thin (~> 1.7.0)
timecop (~> 0.8.0)
+ toml-rb (~> 0.3.15)
truncato (~> 0.7.8)
u2f (~> 0.2.1)
uglifier (~> 2.7.2)
diff --git a/PROCESS.md b/PROCESS.md
index 2f331ee9169..cfa841dc13d 100644
--- a/PROCESS.md
+++ b/PROCESS.md
@@ -73,11 +73,20 @@ These types of merge requests need special consideration:
and a dedicated team with front-end, back-end, and UX.
* **Small features**: any other feature request.
-**Large features** must be with a maintainer **by the 1st**. It's OK if they
-aren't completely done, but this allows the maintainer enough time to make the
-decision about whether this can make it in before the freeze. If the maintainer
-doesn't think it will make it, they should inform the developers working on it
-and the Product Manager responsible for the feature.
+**Large features** must be with a maintainer **by the 1st**. This means that:
+
+* There is a merge request (even if it's WIP).
+* The person (or people, if it needs a frontend and backend maintainer) who will
+ ultimately be responsible for merging this have been pinged on the MR.
+
+It's OK if merge request isn't completely done, but this allows the maintainer
+enough time to make the decision about whether this can make it in before the
+freeze. If the maintainer doesn't think it will make it, they should inform the
+developers working on it and the Product Manager responsible for the feature.
+
+The maintainer can also choose to assign a reviewer to perform an initial
+review, but this way the maintainer is unlikely to be surprised by receiving an
+MR later in the cycle.
**Small features** must be with a reviewer (not necessarily maintainer) **by the
3rd**.
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 67106e85a37..ce426741637 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -51,7 +51,7 @@ function renderCategory(name, emojiList, opts = {}) {
<h5 class="emoji-menu-title">
${name}
</h5>
- <ul class="clearfix emoji-menu-list ${opts.menuListClass}">
+ <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
${emojiList.map(emojiName => `
<li class="emoji-menu-list-item">
<button class="emoji-menu-btn text-center js-emoji-btn" type="button">
diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js
index f7f41d55b52..3bea460dcc6 100644
--- a/app/assets/javascripts/behaviors/autosize.js
+++ b/app/assets/javascripts/behaviors/autosize.js
@@ -1,28 +1,23 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, consistent-return, max-len */
-/* global autosize */
+import autosize from 'vendor/autosize';
-var autosize = require('vendor/autosize');
+$(() => {
+ const $fields = $('.js-autosize');
-(function() {
- $(function() {
- var $fields;
- $fields = $('.js-autosize');
- $fields.on('autosize:resized', function() {
- var $field;
- $field = $(this);
- return $field.data('height', $field.outerHeight());
- });
- $fields.on('resize.autosize', function() {
- var $field;
- $field = $(this);
- if ($field.data('height') !== $field.outerHeight()) {
- $field.data('height', $field.outerHeight());
- autosize.destroy($field);
- return $field.css('max-height', window.outerHeight);
- }
- });
- autosize($fields);
- autosize.update($fields);
- return $fields.css('resize', 'vertical');
+ $fields.on('autosize:resized', function resized() {
+ const $field = $(this);
+ $field.data('height', $field.outerHeight());
});
-}).call(window);
+
+ $fields.on('resize.autosize', function resize() {
+ const $field = $(this);
+ if ($field.data('height') !== $field.outerHeight()) {
+ $field.data('height', $field.outerHeight());
+ autosize.destroy($field);
+ $field.css('max-height', window.outerHeight);
+ }
+ });
+
+ autosize($fields);
+ autosize.update($fields);
+ $fields.css('resize', 'vertical');
+});
diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js
index fd0840fa117..7c9dbcc8d6e 100644
--- a/app/assets/javascripts/behaviors/details_behavior.js
+++ b/app/assets/javascripts/behaviors/details_behavior.js
@@ -1,26 +1,23 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, max-len */
-(function() {
- $(function() {
- $("body").on("click", ".js-details-target", function() {
- var container;
- container = $(this).closest(".js-details-container");
- return container.toggleClass("open");
- });
- // Show details content. Hides link after click.
- //
- // %div
- // %a.js-details-expand
- // %div.js-details-content
- //
- return $("body").on("click", ".js-details-expand", function(e) {
- $(this).next('.js-details-content').removeClass("hide");
- $(this).hide();
- var truncatedItem = $(this).siblings('.js-details-short');
- if (truncatedItem.length) {
- truncatedItem.addClass("hide");
- }
- return e.preventDefault();
- });
+$(() => {
+ $('body').on('click', '.js-details-target', function target() {
+ $(this).closest('.js-details-container').toggleClass('open');
});
-}).call(window);
+
+ // Show details content. Hides link after click.
+ //
+ // %div
+ // %a.js-details-expand
+ // %div.js-details-content
+ //
+ $('body').on('click', '.js-details-expand', function expand(e) {
+ e.preventDefault();
+ $(this).next('.js-details-content').removeClass('hide');
+ $(this).hide();
+
+ const truncatedItem = $(this).siblings('.js-details-short');
+ if (truncatedItem.length) {
+ truncatedItem.addClass('hide');
+ }
+ });
+});
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
new file mode 100644
index 00000000000..5b931e6cfa6
--- /dev/null
+++ b/app/assets/javascripts/behaviors/index.js
@@ -0,0 +1,9 @@
+import './autosize';
+import './bind_in_out';
+import './details_behavior';
+import { installGlEmojiElement } from './gl_emoji';
+import './quick_submit';
+import './requires_input';
+import './toggler_behavior';
+
+installGlEmojiElement();
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 626f3503c91..3d162b24413 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-arrow-callback, camelcase, consistent-return, quotes, object-shorthand, comma-dangle, max-len */
+import '../commons/bootstrap';
// Quick Submit behavior
//
@@ -6,9 +6,6 @@
// "Meta+Enter" (Mac) or "Ctrl+Enter" (Linux/Windows) key combination, the form
// is submitted.
//
-import '../commons/bootstrap';
-
-//
// ### Example Markup
//
// <form action="/foo" class="js-quick-submit">
@@ -17,61 +14,59 @@ import '../commons/bootstrap';
// <input type="submit" value="Submit" />
// </form>
//
-(function() {
- var isMac, keyCodeIs;
- isMac = function() {
- return navigator.userAgent.match(/Macintosh/);
- };
+function isMac() {
+ return navigator.userAgent.match(/Macintosh/);
+}
- keyCodeIs = function(e, keyCode) {
- if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) {
- return false;
- }
- return e.keyCode === keyCode;
- };
+function keyCodeIs(e, keyCode) {
+ if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) {
+ return false;
+ }
+ return e.keyCode === keyCode;
+}
- $(document).on('keydown.quick_submit', '.js-quick-submit', function(e) {
- var $form, $submit_button;
- // Enter
- if (!keyCodeIs(e, 13)) {
- return;
- }
- if (!((e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey) || (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey))) {
- return;
- }
- e.preventDefault();
- $form = $(e.target).closest('form');
- $submit_button = $form.find('input[type=submit], button[type=submit]');
- if ($submit_button.attr('disabled')) {
- return;
- }
- $submit_button.disable();
- return $form.submit();
- });
+$(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
+ // Enter
+ if (!keyCodeIs(e, 13)) {
+ return;
+ }
+
+ const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey;
+ const onlyCtrl = e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey;
+ if (!onlyMeta && !onlyCtrl) {
+ return;
+ }
+
+ e.preventDefault();
+ const $form = $(e.target).closest('form');
+ const $submitButton = $form.find('input[type=submit], button[type=submit]');
+
+ if (!$submitButton.attr('disabled')) {
+ $submitButton.disable();
+ $form.submit();
+ }
+});
+
+// If the user tabs to a submit button on a `js-quick-submit` form, display a
+// tooltip to let them know they could've used the hotkey
+$(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function displayTooltip(e) {
+ // Tab
+ if (!keyCodeIs(e, 9)) {
+ return;
+ }
+
+ const $this = $(this);
+ const title = isMac() ?
+ 'You can also press &#8984;-Enter' :
+ 'You can also press Ctrl-Enter';
- // If the user tabs to a submit button on a `js-quick-submit` form, display a
- // tooltip to let them know they could've used the hotkey
- $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function(e) {
- var $this, title;
- // Tab
- if (!keyCodeIs(e, 9)) {
- return;
- }
- if (isMac()) {
- title = "You can also press &#8984;-Enter";
- } else {
- title = "You can also press Ctrl-Enter";
- }
- $this = $(this);
- return $this.tooltip({
- container: 'body',
- html: 'true',
- placement: 'auto top',
- title: title,
- trigger: 'manual'
- }).tooltip('show').one('blur', function() {
- return $this.tooltip('hide');
- });
+ $this.tooltip({
+ container: 'body',
+ html: 'true',
+ placement: 'auto top',
+ title,
+ trigger: 'manual',
});
-}).call(window);
+ $this.tooltip('show').one('blur', () => $this.tooltip('hide'));
+});
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index eb7143f5b1a..b20d108aa25 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -1,12 +1,10 @@
-/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, no-else-return, consistent-return, max-len */
+import '../commons/bootstrap';
+
// Requires Input behavior
//
// When called on a form with input fields with the `required` attribute, the
// form's submit button will be disabled until all required fields have values.
//
-import '../commons/bootstrap';
-
-//
// ### Example Markup
//
// <form class="js-requires-input">
@@ -14,49 +12,43 @@ import '../commons/bootstrap';
// <input type="submit" value="Submit">
// </form>
//
-(function() {
- $.fn.requiresInput = function() {
- var $button, $form, fieldSelector, requireInput, required;
- $form = $(this);
- $button = $('button[type=submit], input[type=submit]', $form);
- required = '[required=required]';
- fieldSelector = "input" + required + ", select" + required + ", textarea" + required;
- requireInput = function() {
- var values;
- values = _.map($(fieldSelector, $form), function(field) {
- // Collect the input values of *all* required fields
- return field.value;
- });
- // Disable the button if any required fields are empty
- if (values.length && _.any(values, _.isEmpty)) {
- return $button.disable();
- } else {
- return $button.enable();
- }
- };
- // Set initial button state
- requireInput();
- return $form.on('change input', fieldSelector, requireInput);
- };
- $(function() {
- var $form, hideOrShowHelpBlock;
- $form = $('form.js-requires-input');
- $form.requiresInput();
- // Hide or Show the help block when creating a new project
- // based on the option selected
- hideOrShowHelpBlock = function(form) {
- var selected;
- selected = $('.js-select-namespace option:selected');
- if (selected.length && selected.data('options-parent') === 'groups') {
- return form.find('.help-block').hide();
- } else if (selected.length) {
- return form.find('.help-block').show();
- }
- };
- hideOrShowHelpBlock($form);
- return $('.select2.js-select-namespace').change(function() {
- return hideOrShowHelpBlock($form);
- });
- });
-}).call(window);
+$.fn.requiresInput = function requiresInput() {
+ const $form = $(this);
+ const $button = $('button[type=submit], input[type=submit]', $form);
+ const fieldSelector = 'input[required=required], select[required=required], textarea[required=required]';
+
+ function requireInput() {
+ // Collect the input values of *all* required fields
+ const values = _.map($(fieldSelector, $form), field => field.value);
+
+ // Disable the button if any required fields are empty
+ if (values.length && _.any(values, _.isEmpty)) {
+ $button.disable();
+ } else {
+ $button.enable();
+ }
+ }
+
+ // Set initial button state
+ requireInput();
+ $form.on('change input', fieldSelector, requireInput);
+};
+
+// Hide or Show the help block when creating a new project
+// based on the option selected
+function hideOrShowHelpBlock(form) {
+ const selected = $('.js-select-namespace option:selected');
+ if (selected.length && selected.data('options-parent') === 'groups') {
+ form.find('.help-block').hide();
+ } else if (selected.length) {
+ form.find('.help-block').show();
+ }
+}
+
+$(() => {
+ const $form = $('form.js-requires-input');
+ $form.requiresInput();
+ hideOrShowHelpBlock($form);
+ $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
+});
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index 576b8a0425f..4c9ad128e6c 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -1,44 +1,43 @@
-/* eslint-disable wrap-iife, func-names, space-before-function-paren, prefer-arrow-callback, vars-on-top, no-var, max-len */
-(function(w) {
- $(function() {
- var toggleContainer = function(container, /* optional */toggleState) {
- var $container = $(container);
-
- $container
- .find('.js-toggle-button .fa')
- .toggleClass('fa-chevron-up', toggleState)
- .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined);
-
- $container
- .find('.js-toggle-content')
- .toggle(toggleState);
- };
-
- // Toggle button. Show/hide content inside parent container.
- // Button does not change visibility. If button has icon - it changes chevron style.
- //
- // %div.js-toggle-container
- // %button.js-toggle-button
- // %div.js-toggle-content
- //
- $('body').on('click', '.js-toggle-button', function(e) {
- toggleContainer($(this).closest('.js-toggle-container'));
-
- const targetTag = e.currentTarget.tagName.toLowerCase();
- if (targetTag === 'a' || targetTag === 'button') {
- e.preventDefault();
- }
- });
-
- // If we're accessing a permalink, ensure it is not inside a
- // closed js-toggle-container!
- var hash = w.gl.utils.getLocationHash();
- var anchor = hash && document.getElementById(hash);
- var container = anchor && $(anchor).closest('.js-toggle-container');
-
- if (container) {
- toggleContainer(container, true);
- anchor.scrollIntoView();
+
+// Toggle button. Show/hide content inside parent container.
+// Button does not change visibility. If button has icon - it changes chevron style.
+//
+// %div.js-toggle-container
+// %button.js-toggle-button
+// %div.js-toggle-content
+//
+
+$(() => {
+ function toggleContainer(container, toggleState) {
+ const $container = $(container);
+
+ $container
+ .find('.js-toggle-button .fa')
+ .toggleClass('fa-chevron-up', toggleState)
+ .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined);
+
+ $container
+ .find('.js-toggle-content')
+ .toggle(toggleState);
+ }
+
+ $('body').on('click', '.js-toggle-button', function toggleButton(e) {
+ toggleContainer($(this).closest('.js-toggle-container'));
+
+ const targetTag = e.currentTarget.tagName.toLowerCase();
+ if (targetTag === 'a' || targetTag === 'button') {
+ e.preventDefault();
}
});
-})(window);
+
+ // If we're accessing a permalink, ensure it is not inside a
+ // closed js-toggle-container!
+ const hash = window.gl.utils.getLocationHash();
+ const anchor = hash && document.getElementById(hash);
+ const container = anchor && $(anchor).closest('.js-toggle-container');
+
+ if (container) {
+ toggleContainer(container, true);
+ anchor.scrollIntoView();
+ }
+});
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index e057ac8df02..37094c5c9be 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -38,6 +38,10 @@ $(() => {
Store.create();
+ // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore
+ gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args);
+ gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args);
+
gl.IssueBoardsApp = new Vue({
el: $boardApp,
components: {
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index 7cb022c848a..9e7df9ad350 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -1,24 +1,28 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-param-reassign, quotes, yoda, no-else-return, consistent-return, comma-dangle, object-shorthand, prefer-template, one-var, one-var-declaration-per-line, no-unused-vars, max-len, vars-on-top */
+/* eslint-disable func-names, wrap-iife, no-use-before-define,
+consistent-return, prefer-rest-params */
/* global Breakpoints */
-var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-var AUTO_SCROLL_OFFSET = 75;
-var DOWN_BUILD_TRACE = '#down-build-trace';
+const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; };
+const AUTO_SCROLL_OFFSET = 75;
+const DOWN_BUILD_TRACE = '#down-build-trace';
-window.Build = (function() {
+window.Build = (function () {
Build.timeout = null;
Build.state = null;
function Build(options) {
- options = options || $('.js-build-options').data();
- this.pageUrl = options.pageUrl;
- this.buildUrl = options.buildUrl;
- this.buildStatus = options.buildStatus;
- this.state = options.logState;
- this.buildStage = options.buildStage;
- this.updateDropdown = bind(this.updateDropdown, this);
+ this.options = options || $('.js-build-options').data();
+
+ this.pageUrl = this.options.pageUrl;
+ this.buildUrl = this.options.buildUrl;
+ this.buildStatus = this.options.buildStatus;
+ this.state = this.options.logState;
+ this.buildStage = this.options.buildStage;
this.$document = $(document);
+
+ this.updateDropdown = bind(this.updateDropdown, this);
+
this.$body = $('body');
this.$buildTrace = $('#build-trace');
this.$autoScrollContainer = $('.autoscroll-container');
@@ -29,112 +33,110 @@ window.Build = (function() {
this.$scrollTopBtn = $('#scroll-top');
this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh');
+ this.$buildScroll = $('#js-build-scroll');
+ this.$truncatedInfo = $('.js-truncated-info');
clearTimeout(Build.timeout);
// Init breakpoint checker
this.bp = Breakpoints.get();
this.initSidebar();
- this.$buildScroll = $('#js-build-scroll');
-
this.populateJobs(this.buildStage);
this.updateStageDropdownText(this.buildStage);
this.sidebarOnResize();
- this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this));
- this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown);
+ this.$document
+ .off('click', '.js-sidebar-build-toggle')
+ .on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this));
+
+ this.$document
+ .off('click', '.stage-item')
+ .on('click', '.stage-item', this.updateDropdown);
+
this.$document.on('scroll', this.initScrollMonitor.bind(this));
- $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this));
- $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace);
+
+ $(window)
+ .off('resize.build')
+ .on('resize.build', this.sidebarOnResize.bind(this));
+
+ $('a', this.$buildScroll)
+ .off('click.stepTrace')
+ .on('click.stepTrace', this.stepTrace);
+
this.updateArtifactRemoveDate();
- if ($('#build-trace').length) {
- this.getInitialBuildTrace();
- this.initScrollButtonAffix();
- }
+ this.initScrollButtonAffix();
this.invokeBuildTrace();
}
- Build.prototype.initSidebar = function() {
+ Build.prototype.initSidebar = function () {
this.$sidebar = $('.js-build-sidebar');
this.$sidebar.niceScroll();
- this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
+ this.$document
+ .off('click', '.js-sidebar-build-toggle')
+ .on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
};
- Build.prototype.location = function() {
- return window.location.href.split("#")[0];
+ Build.prototype.invokeBuildTrace = function () {
+ return this.getBuildTrace();
};
- Build.prototype.invokeBuildTrace = function() {
- var continueRefreshStatuses = ['running', 'pending'];
- // Continue to update build trace when build is running or pending
- if (continueRefreshStatuses.indexOf(this.buildStatus) !== -1) {
- // Check for new build output if user still watching build page
- // Only valid for runnig build when output changes during time
- Build.timeout = setTimeout((function(_this) {
- return function() {
- if (_this.location() === _this.pageUrl) {
- return _this.getBuildTrace();
- }
- };
- })(this), 4000);
- }
- };
-
- Build.prototype.getInitialBuildTrace = function() {
- var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
-
+ Build.prototype.getBuildTrace = function () {
return $.ajax({
- url: this.pageUrl + "/trace.json",
+ url: `${this.pageUrl}/trace.json`,
dataType: 'json',
- success: function(buildData) {
- $('.js-build-output').html(buildData.html);
- gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
- if (window.location.hash === DOWN_BUILD_TRACE) {
- $("html,body").scrollTop(this.$buildTrace.height());
+ data: {
+ state: this.state,
+ },
+ success: ((log) => {
+ const $buildContainer = $('.js-build-output');
+
+ if (log.state) {
+ this.state = log.state;
}
- if (removeRefreshStatuses.indexOf(buildData.status) !== -1) {
+
+ if (log.append) {
+ $buildContainer.append(log.html);
+ } else {
+ $buildContainer.html(log.html);
+ if (log.truncated) {
+ $('.js-truncated-info-size').html(` ${log.size} `);
+ this.$truncatedInfo.removeClass('hidden');
+ this.initAffixTruncatedInfo();
+ } else {
+ this.$truncatedInfo.addClass('hidden');
+ }
+ }
+
+ this.checkAutoscroll();
+
+ if (!log.complete) {
+ Build.timeout = setTimeout(() => {
+ this.invokeBuildTrace();
+ }, 4000);
+ } else {
this.$buildRefreshAnimation.remove();
- return this.initScrollMonitor();
}
- }.bind(this)
- });
- };
- Build.prototype.getBuildTrace = function() {
- return $.ajax({
- url: this.pageUrl + "/trace.json?state=" + (encodeURIComponent(this.state)),
- dataType: "json",
- success: (function(_this) {
- return function(log) {
- var pageUrl;
-
- if (log.state) {
- _this.state = log.state;
- }
- _this.invokeBuildTrace();
- if (log.status === "running") {
- if (log.append) {
- $('.js-build-output').append(log.html);
- } else {
- $('.js-build-output').html(log.html);
- }
- return _this.checkAutoscroll();
- } else if (log.status !== _this.buildStatus) {
- pageUrl = _this.pageUrl;
- if (_this.$autoScrollStatus.data('state') === 'enabled') {
- pageUrl += DOWN_BUILD_TRACE;
- }
-
- return gl.utils.visitUrl(pageUrl);
+ if (log.status !== this.buildStatus) {
+ let pageUrl = this.pageUrl;
+
+ if (this.$autoScrollStatus.data('state') === 'enabled') {
+ pageUrl += DOWN_BUILD_TRACE;
}
- };
- })(this)
+
+ gl.utils.visitUrl(pageUrl);
+ }
+ }),
+ error: () => {
+ this.$buildRefreshAnimation.remove();
+ return this.initScrollMonitor();
+ },
});
};
- Build.prototype.checkAutoscroll = function() {
- if (this.$autoScrollStatus.data("state") === "enabled") {
- return $("html,body").scrollTop(this.$buildTrace.height());
+ Build.prototype.checkAutoscroll = function () {
+ if (this.$autoScrollStatus.data('state') === 'enabled') {
+ return $('html,body').scrollTop(this.$buildTrace.height());
}
// Handle a situation where user started new build
@@ -146,7 +148,7 @@ window.Build = (function() {
}
};
- Build.prototype.initScrollButtonAffix = function() {
+ Build.prototype.initScrollButtonAffix = function () {
// Hide everything initially
this.$scrollTopBtn.hide();
this.$scrollBottomBtn.hide();
@@ -167,15 +169,17 @@ window.Build = (function() {
// - Show Top Arrow button
// - Show Bottom Arrow button
// - Disable Autoscroll and hide indicator (when build is running)
- Build.prototype.initScrollMonitor = function() {
- if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
+ Build.prototype.initScrollMonitor = function () {
+ if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
// User is somewhere in middle of Build Log
this.$scrollTopBtn.show();
if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed
this.$scrollBottomBtn.show();
- } else if (this.$buildRefreshAnimation.is(':visible') && !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
+ } else if (this.$buildRefreshAnimation.is(':visible') &&
+ !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
this.$scrollBottomBtn.show();
} else {
this.$scrollBottomBtn.hide();
@@ -186,10 +190,13 @@ window.Build = (function() {
this.$autoScrollContainer.hide();
this.$autoScrollStatusText.removeClass('animate');
} else {
- this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show();
+ this.$autoScrollContainer.css({
+ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
+ }).show();
this.$autoScrollStatusText.addClass('animate');
}
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
+ } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
// User is at Top of Build Log
this.$scrollTopBtn.hide();
@@ -197,17 +204,22 @@ window.Build = (function() {
this.$autoScrollContainer.hide();
this.$autoScrollStatusText.removeClass('animate');
- } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
- (this.$buildRefreshAnimation.is(':visible') && gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
+ } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
+ (this.$buildRefreshAnimation.is(':visible') &&
+ gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
// User is at Bottom of Build Log
this.$scrollTopBtn.show();
this.$scrollBottomBtn.hide();
// Show and Reposition Autoscroll Status Indicator
- this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show();
+ this.$autoScrollContainer.css({
+ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
+ }).show();
this.$autoScrollStatusText.addClass('animate');
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
+ } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
// Build Log height is small
this.$scrollTopBtn.hide();
@@ -218,65 +230,81 @@ window.Build = (function() {
this.$autoScrollStatusText.removeClass('animate');
}
- if (this.buildStatus === "running" || this.buildStatus === "pending") {
+ if (this.buildStatus === 'running' || this.buildStatus === 'pending') {
// Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise.
- this.$autoScrollStatus.data("state", gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled');
+ this.$autoScrollStatus.data(
+ 'state',
+ gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled',
+ );
}
};
- Build.prototype.shouldHideSidebarForViewport = function() {
- var bootstrapBreakpoint;
- bootstrapBreakpoint = this.bp.getBreakpointSize();
+ Build.prototype.shouldHideSidebarForViewport = function () {
+ const bootstrapBreakpoint = this.bp.getBreakpointSize();
return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
};
- Build.prototype.toggleSidebar = function(shouldHide) {
- var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+ Build.prototype.toggleSidebar = function (shouldHide) {
+ const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+
this.$buildScroll.toggleClass('sidebar-expanded', shouldShow)
.toggleClass('sidebar-collapsed', shouldHide);
+ this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow)
+ .toggleClass('sidebar-collapsed', shouldHide);
this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow)
.toggleClass('right-sidebar-collapsed', shouldHide);
};
- Build.prototype.sidebarOnResize = function() {
+ Build.prototype.sidebarOnResize = function () {
this.toggleSidebar(this.shouldHideSidebarForViewport());
};
- Build.prototype.sidebarOnClick = function() {
+ Build.prototype.sidebarOnClick = function () {
if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
};
- Build.prototype.updateArtifactRemoveDate = function() {
- var $date, date;
- $date = $('.js-artifacts-remove');
+ Build.prototype.updateArtifactRemoveDate = function () {
+ const $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')), ' '));
+ const date = $date.text();
+ return $date.text(
+ gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '),
+ );
}
};
- Build.prototype.populateJobs = function(stage) {
+ Build.prototype.populateJobs = function (stage) {
$('.build-job').hide();
- $('.build-job[data-stage="' + stage + '"]').show();
+ $(`.build-job[data-stage="${stage}"]`).show();
};
- Build.prototype.updateStageDropdownText = function(stage) {
+ Build.prototype.updateStageDropdownText = function (stage) {
$('.stage-selection').text(stage);
};
- Build.prototype.updateDropdown = function(e) {
+ Build.prototype.updateDropdown = function (e) {
e.preventDefault();
- var stage = e.currentTarget.text;
+ const stage = e.currentTarget.text;
this.updateStageDropdownText(stage);
this.populateJobs(stage);
};
- Build.prototype.stepTrace = function(e) {
- var $currentTarget;
+ Build.prototype.stepTrace = function (e) {
e.preventDefault();
- $currentTarget = $(e.currentTarget);
+
+ const $currentTarget = $(e.currentTarget);
$.scrollTo($currentTarget.attr('href'), {
- offset: 0
+ offset: 0,
+ });
+ };
+
+ Build.prototype.initAffixTruncatedInfo = function () {
+ const offsetTop = this.$buildTrace.offset().top;
+
+ this.$truncatedInfo.affix({
+ offset: {
+ top: offsetTop,
+ },
});
};
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
new file mode 100644
index 00000000000..df0ba86198c
--- /dev/null
+++ b/app/assets/javascripts/comment_type_toggle.js
@@ -0,0 +1,60 @@
+import DropLab from './droplab/drop_lab';
+import InputSetter from './droplab/plugins/input_setter';
+
+class CommentTypeToggle {
+ constructor(opts = {}) {
+ this.dropdownTrigger = opts.dropdownTrigger;
+ this.dropdownList = opts.dropdownList;
+ this.noteTypeInput = opts.noteTypeInput;
+ this.submitButton = opts.submitButton;
+ this.closeButton = opts.closeButton;
+ this.reopenButton = opts.reopenButton;
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+
+ const config = this.setConfig();
+
+ this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
+ }
+
+ setConfig() {
+ const config = {
+ InputSetter: [{
+ input: this.noteTypeInput,
+ valueAttribute: 'data-value',
+ },
+ {
+ input: this.submitButton,
+ valueAttribute: 'data-submit-text',
+ }],
+ };
+
+ if (this.closeButton) {
+ config.InputSetter.push({
+ input: this.closeButton,
+ valueAttribute: 'data-close-text',
+ }, {
+ input: this.closeButton,
+ valueAttribute: 'data-close-text',
+ inputAttribute: 'data-alternative-text',
+ });
+ }
+
+ if (this.reopenButton) {
+ config.InputSetter.push({
+ input: this.reopenButton,
+ valueAttribute: 'data-reopen-text',
+ }, {
+ input: this.reopenButton,
+ valueAttribute: 'data-reopen-text',
+ inputAttribute: 'data-alternative-text',
+ });
+ }
+
+ return config;
+ }
+}
+
+export default CommentTypeToggle;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 5f3ed9374bf..86d99dd87da 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -12,20 +12,18 @@ Vue.use(VueResource);
* Renders Pipelines table in pipelines tab in the commits show view.
*/
+// export for use in merge_request_tabs.js (TODO: remove this hack)
+window.gl = window.gl || {};
+window.gl.CommitPipelinesTable = CommitPipelinesTable;
+
$(() => {
- window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
- if (gl.commits.PipelinesTableBundle) {
- document.querySelector('#commit-pipeline-table-view').removeChild(this.pipelinesTableBundle.$el);
- gl.commits.PipelinesTableBundle.$destroy(true);
- }
-
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) {
gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable().$mount();
- document.querySelector('#commit-pipeline-table-view').appendChild(gl.commits.pipelines.PipelinesTableBundle.$el);
+ pipelineTableViewEl.appendChild(gl.commits.pipelines.PipelinesTableBundle.$el);
}
});
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
index fc2f20e3bcb..eb76b7d15fd 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -42,10 +42,14 @@ import Vue from 'vue';
}
},
created() {
- this.discussion = CommentsStore.state[this.discussionId];
+ if (this.discussionId) {
+ this.discussion = CommentsStore.state[this.discussionId];
+ }
},
mounted: function () {
- const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`);
+ if (!this.discussionId) return;
+
+ const $textarea = $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`);
this.textareaIsEmpty = $textarea.val() === '';
$textarea.on('input.comment-and-resolve-btn', () => {
@@ -53,7 +57,9 @@ import Vue from 'vue';
});
},
destroyed: function () {
- $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn');
+ if (!this.discussionId) return;
+
+ $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off('input.comment-and-resolve-btn');
}
});
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 0a1a62fb012..f277e1dddc7 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -37,6 +37,7 @@
import Issue from './issue';
import BindInOut from './behaviors/bind_in_out';
+import Group from './group';
import GroupName from './group_name';
import GroupsList from './groups_list';
import ProjectsList from './projects_list';
@@ -44,6 +45,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout';
+import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
const ShortcutsBlob = require('./shortcuts_blob');
@@ -270,8 +272,9 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'groups:create':
case 'admin:groups:create':
BindInOut.initAll();
- case 'groups:new':
- case 'admin:groups:new':
+ new Group();
+ new GroupAvatar();
+ break;
case 'groups:edit':
case 'admin:groups:edit':
new GroupAvatar();
@@ -329,8 +332,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
new Search();
break;
case 'projects:repository:show':
+ // Initialize Protected Branch Settings
new gl.ProtectedBranchCreate();
new gl.ProtectedBranchEditList();
+ // Initialize Protected Tag Settings
+ new ProtectedTagCreate();
+ new ProtectedTagEditList();
break;
case 'projects:ci_cd:show':
new gl.ProjectVariables();
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js
new file mode 100644
index 00000000000..a23d914772a
--- /dev/null
+++ b/app/assets/javascripts/droplab/constants.js
@@ -0,0 +1,11 @@
+const DATA_TRIGGER = 'data-dropdown-trigger';
+const DATA_DROPDOWN = 'data-dropdown';
+const SELECTED_CLASS = 'droplab-item-selected';
+const ACTIVE_CLASS = 'droplab-item-active';
+
+export {
+ DATA_TRIGGER,
+ DATA_DROPDOWN,
+ SELECTED_CLASS,
+ ACTIVE_CLASS,
+};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
new file mode 100644
index 00000000000..9588921ebcd
--- /dev/null
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -0,0 +1,139 @@
+/* eslint-disable */
+
+import utils from './utils';
+import { SELECTED_CLASS } from './constants';
+
+var DropDown = function(list) {
+ this.currentIndex = 0;
+ this.hidden = true;
+ this.list = typeof list === 'string' ? document.querySelector(list) : list;
+ this.items = [];
+
+ this.eventWrapper = {};
+
+ this.getItems();
+ this.initTemplateString();
+ this.addEvents();
+
+ this.initialState = list.innerHTML;
+};
+
+Object.assign(DropDown.prototype, {
+ getItems: function() {
+ this.items = [].slice.call(this.list.querySelectorAll('li'));
+ return this.items;
+ },
+
+ initTemplateString: function() {
+ var items = this.items || this.getItems();
+
+ var templateString = '';
+ if (items.length > 0) templateString = items[items.length - 1].outerHTML;
+ this.templateString = templateString;
+
+ return this.templateString;
+ },
+
+ clickEvent: function(e) {
+ if (e.target.tagName === 'UL') return;
+
+ var selected = utils.closest(e.target, 'LI');
+ if (!selected) return;
+
+ this.addSelectedClass(selected);
+
+ e.preventDefault();
+ this.hide();
+
+ var listEvent = new CustomEvent('click.dl', {
+ detail: {
+ list: this,
+ selected: selected,
+ data: e.target.dataset,
+ },
+ });
+ this.list.dispatchEvent(listEvent);
+ },
+
+ addSelectedClass: function (selected) {
+ this.removeSelectedClasses();
+ selected.classList.add(SELECTED_CLASS);
+ },
+
+ removeSelectedClasses: function () {
+ const items = this.items || this.getItems();
+
+ items.forEach(item => item.classList.remove(SELECTED_CLASS));
+ },
+
+ addEvents: function() {
+ this.eventWrapper.clickEvent = this.clickEvent.bind(this)
+ this.list.addEventListener('click', this.eventWrapper.clickEvent);
+ },
+
+ toggle: function() {
+ this.hidden ? this.show() : this.hide();
+ },
+
+ setData: function(data) {
+ this.data = data;
+ this.render(data);
+ },
+
+ addData: function(data) {
+ this.data = (this.data || []).concat(data);
+ this.render(this.data);
+ },
+
+ render: function(data) {
+ const children = data ? data.map(this.renderChildren.bind(this)) : [];
+ const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
+
+ renderableList.innerHTML = children.join('');
+ },
+
+ renderChildren: function(data) {
+ var html = utils.t(this.templateString, data);
+ var template = document.createElement('div');
+
+ template.innerHTML = html;
+ this.setImagesSrc(template);
+ template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block';
+
+ return template.firstChild.outerHTML;
+ },
+
+ setImagesSrc: function(template) {
+ const images = [].slice.call(template.querySelectorAll('img[data-src]'));
+
+ images.forEach((image) => {
+ image.src = image.getAttribute('data-src');
+ image.removeAttribute('data-src');
+ });
+ },
+
+ show: function() {
+ if (!this.hidden) return;
+ this.list.style.display = 'block';
+ this.currentIndex = 0;
+ this.hidden = false;
+ },
+
+ hide: function() {
+ if (this.hidden) return;
+ this.list.style.display = 'none';
+ this.currentIndex = 0;
+ this.hidden = true;
+ },
+
+ toggle: function () {
+ this.hidden ? this.show() : this.hide();
+ },
+
+ destroy: function() {
+ this.hide();
+ this.list.removeEventListener('click', this.eventWrapper.clickEvent);
+ }
+});
+
+export default DropDown;
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js
new file mode 100644
index 00000000000..6eb9f314af7
--- /dev/null
+++ b/app/assets/javascripts/droplab/drop_lab.js
@@ -0,0 +1,152 @@
+/* eslint-disable */
+
+import HookButton from './hook_button';
+import HookInput from './hook_input';
+import utils from './utils';
+import Keyboard from './keyboard';
+import { DATA_TRIGGER } from './constants';
+
+var DropLab = function() {
+ this.ready = false;
+ this.hooks = [];
+ this.queuedData = [];
+ this.config = {};
+
+ this.eventWrapper = {};
+};
+
+Object.assign(DropLab.prototype, {
+ loadStatic: function(){
+ var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
+ this.addHooks(dropdownTriggers);
+ },
+
+ addData: function () {
+ var args = [].slice.apply(arguments);
+ this.applyArgs(args, '_addData');
+ },
+
+ setData: function() {
+ var args = [].slice.apply(arguments);
+ this.applyArgs(args, '_setData');
+ },
+
+ destroy: function() {
+ this.hooks.forEach(hook => hook.destroy());
+ this.hooks = [];
+ this.removeEvents();
+ },
+
+ applyArgs: function(args, methodName) {
+ if (this.ready) return this[methodName].apply(this, args);
+
+ this.queuedData = this.queuedData || [];
+ this.queuedData.push(args);
+ },
+
+ _addData: function(trigger, data) {
+ this._processData(trigger, data, 'addData');
+ },
+
+ _setData: function(trigger, data) {
+ this._processData(trigger, data, 'setData');
+ },
+
+ _processData: function(trigger, data, methodName) {
+ this.hooks.forEach((hook) => {
+ if (Array.isArray(trigger)) hook.list[methodName](trigger);
+
+ if (hook.trigger.id === trigger) hook.list[methodName](data);
+ });
+ },
+
+ addEvents: function() {
+ this.eventWrapper.documentClicked = this.documentClicked.bind(this)
+ document.addEventListener('click', this.eventWrapper.documentClicked);
+ },
+
+ documentClicked: function(e) {
+ let thisTag = e.target;
+
+ if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL');
+ if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return;
+
+ this.hooks.forEach(hook => hook.list.hide());
+ },
+
+ removeEvents: function(){
+ document.removeEventListener('click', this.eventWrapper.documentClicked);
+ },
+
+ changeHookList: function(trigger, list, plugins, config) {
+ const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
+
+
+ this.hooks.forEach((hook, i) => {
+ hook.list.list.dataset.dropdownActive = false;
+
+ if (hook.trigger !== availableTrigger) return;
+
+ hook.destroy();
+ this.hooks.splice(i, 1);
+ this.addHook(availableTrigger, list, plugins, config);
+ });
+ },
+
+ addHook: function(hook, list, plugins, config) {
+ const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook;
+ let availableList;
+
+ if (typeof list === 'string') {
+ availableList = document.querySelector(list);
+ } else if (list instanceof Element) {
+ availableList = list;
+ } else {
+ availableList = document.querySelector(hook.dataset[utils.toCamelCase(DATA_TRIGGER)]);
+ }
+
+ availableList.dataset.dropdownActive = true;
+
+ const HookObject = availableHook.tagName === 'INPUT' ? HookInput : HookButton;
+ this.hooks.push(new HookObject(availableHook, availableList, plugins, config));
+
+ return this;
+ },
+
+ addHooks: function(hooks, plugins, config) {
+ hooks.forEach(hook => this.addHook(hook, null, plugins, config));
+ return this;
+ },
+
+ setConfig: function(obj){
+ this.config = obj;
+ },
+
+ fireReady: function() {
+ const readyEvent = new CustomEvent('ready.dl', {
+ detail: {
+ dropdown: this,
+ },
+ });
+ document.dispatchEvent(readyEvent);
+
+ this.ready = true;
+ },
+
+ init: function (hook, list, plugins, config) {
+ hook ? this.addHook(hook, list, plugins, config) : this.loadStatic();
+
+ this.addEvents();
+
+ Keyboard();
+
+ this.fireReady();
+
+ this.queuedData.forEach(data => this.addData(data));
+ this.queuedData = [];
+
+ return this;
+ },
+});
+
+export default DropLab;
diff --git a/app/assets/javascripts/droplab/droplab.js b/app/assets/javascripts/droplab/droplab.js
deleted file mode 100644
index 8b14191395b..00000000000
--- a/app/assets/javascripts/droplab/droplab.js
+++ /dev/null
@@ -1,741 +0,0 @@
-/* eslint-disable */
-// Determine where to place this
-if (typeof Object.assign != 'function') {
- Object.assign = function (target, varArgs) { // .length of function is 2
- 'use strict';
- if (target == null) { // TypeError if undefined or null
- throw new TypeError('Cannot convert undefined or null to object');
- }
-
- var to = Object(target);
-
- for (var index = 1; index < arguments.length; index++) {
- var nextSource = arguments[index];
-
- if (nextSource != null) { // Skip over if undefined or null
- for (var nextKey in nextSource) {
- // Avoid bugs when hasOwnProperty is shadowed
- if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
- to[nextKey] = nextSource[nextKey];
- }
- }
- }
- }
- return to;
- };
-}
-
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.droplab = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-var DATA_TRIGGER = 'data-dropdown-trigger';
-var DATA_DROPDOWN = 'data-dropdown';
-
-module.exports = {
- DATA_TRIGGER: DATA_TRIGGER,
- DATA_DROPDOWN: DATA_DROPDOWN,
-}
-
-},{}],2:[function(require,module,exports){
-// Custom event support for IE
-if ( typeof CustomEvent === "function" ) {
- module.exports = CustomEvent;
-} else {
- require('./window')(function(w){
- var CustomEvent = function ( event, params ) {
- params = params || { bubbles: false, cancelable: false, detail: undefined };
- var evt = document.createEvent( 'CustomEvent' );
- evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail );
- return evt;
- }
- CustomEvent.prototype = w.Event.prototype;
-
- w.CustomEvent = CustomEvent;
- });
- module.exports = CustomEvent;
-}
-
-},{"./window":11}],3:[function(require,module,exports){
-var CustomEvent = require('./custom_event_polyfill');
-var utils = require('./utils');
-
-var DropDown = function(list) {
- this.currentIndex = 0;
- this.hidden = true;
- this.list = list;
- this.items = [];
- this.getItems();
- this.initTemplateString();
- this.addEvents();
- this.initialState = list.innerHTML;
-};
-
-Object.assign(DropDown.prototype, {
- getItems: function() {
- this.items = [].slice.call(this.list.querySelectorAll('li'));
- return this.items;
- },
-
- initTemplateString: function() {
- var items = this.items || this.getItems();
-
- var templateString = '';
- if(items.length > 0) {
- templateString = items[items.length - 1].outerHTML;
- }
- this.templateString = templateString;
- return this.templateString;
- },
-
- clickEvent: function(e) {
- // climb up the tree to find the LI
- var selected = utils.closest(e.target, 'LI');
-
- if(selected) {
- e.preventDefault();
- this.hide();
- var listEvent = new CustomEvent('click.dl', {
- detail: {
- list: this,
- selected: selected,
- data: e.target.dataset,
- },
- });
- this.list.dispatchEvent(listEvent);
- }
- },
-
- addEvents: function() {
- this.clickWrapper = this.clickEvent.bind(this);
- // event delegation.
- this.list.addEventListener('click', this.clickWrapper);
- },
-
- toggle: function() {
- if(this.hidden) {
- this.show();
- } else {
- this.hide();
- }
- },
-
- setData: function(data) {
- this.data = data;
- this.render(data);
- },
-
- addData: function(data) {
- this.data = (this.data || []).concat(data);
- this.render(this.data);
- },
-
- // call render manually on data;
- render: function(data){
- // debugger
- // empty the list first
- var templateString = this.templateString;
- var newChildren = [];
- var toAppend;
-
- newChildren = (data ||[]).map(function(dat){
- var html = utils.t(templateString, dat);
- var template = document.createElement('div');
- template.innerHTML = html;
-
- // Help set the image src template
- var imageTags = template.querySelectorAll('img[data-src]');
- // debugger
- for(var i = 0; i < imageTags.length; i++) {
- var imageTag = imageTags[i];
- imageTag.src = imageTag.getAttribute('data-src');
- imageTag.removeAttribute('data-src');
- }
-
- if(dat.hasOwnProperty('droplab_hidden') && dat.droplab_hidden){
- template.firstChild.style.display = 'none'
- }else{
- template.firstChild.style.display = 'block';
- }
- return template.firstChild.outerHTML;
- });
- toAppend = this.list.querySelector('ul[data-dynamic]');
- if(toAppend) {
- toAppend.innerHTML = newChildren.join('');
- } else {
- this.list.innerHTML = newChildren.join('');
- }
- },
-
- show: function() {
- if (this.hidden) {
- // debugger
- this.list.style.display = 'block';
- this.currentIndex = 0;
- this.hidden = false;
- }
- },
-
- hide: function() {
- if (!this.hidden) {
- // debugger
- this.list.style.display = 'none';
- this.currentIndex = 0;
- this.hidden = true;
- }
- },
-
- destroy: function() {
- this.hide();
- this.list.removeEventListener('click', this.clickWrapper);
- }
-});
-
-module.exports = DropDown;
-
-},{"./custom_event_polyfill":2,"./utils":10}],4:[function(require,module,exports){
-require('./window')(function(w){
- module.exports = function(deps) {
- deps = deps || {};
- var window = deps.window || w;
- var document = deps.document || window.document;
- var CustomEvent = deps.CustomEvent || require('./custom_event_polyfill');
- var HookButton = deps.HookButton || require('./hook_button');
- var HookInput = deps.HookInput || require('./hook_input');
- var utils = deps.utils || require('./utils');
- var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
-
- var DropLab = function(hook){
- if (!(this instanceof DropLab)) return new DropLab(hook);
- this.ready = false;
- this.hooks = [];
- this.queuedData = [];
- this.config = {};
- this.loadWrapper;
- if(typeof hook !== 'undefined'){
- this.addHook(hook);
- }
- };
-
-
- Object.assign(DropLab.prototype, {
- load: function() {
- this.loadWrapper();
- },
-
- loadWrapper: function(){
- var dropdownTriggers = [].slice.apply(document.querySelectorAll('['+DATA_TRIGGER+']'));
- this.addHooks(dropdownTriggers).init();
- },
-
- addData: function () {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_addData');
- },
-
- setData: function() {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_setData');
- },
-
- destroy: function() {
- for(var i = 0; i < this.hooks.length; i++) {
- this.hooks[i].destroy();
- }
- this.hooks = [];
- this.removeEvents();
- },
-
- applyArgs: function(args, methodName) {
- if(this.ready) {
- this[methodName].apply(this, args);
- } else {
- this.queuedData = this.queuedData || [];
- this.queuedData.push(args);
- }
- },
-
- _addData: function(trigger, data) {
- this._processData(trigger, data, 'addData');
- },
-
- _setData: function(trigger, data) {
- this._processData(trigger, data, 'setData');
- },
-
- _processData: function(trigger, data, methodName) {
- for(var i = 0; i < this.hooks.length; i++) {
- var hook = this.hooks[i];
- if(hook.trigger.dataset.hasOwnProperty('id')) {
- if(hook.trigger.dataset.id === trigger) {
- hook.list[methodName](data);
- }
- }
- }
- },
-
- addEvents: function() {
- var self = this;
- this.windowClickedWrapper = function(e){
- var thisTag = e.target;
- if(thisTag.tagName !== 'UL'){
- // climb up the tree to find the UL
- thisTag = utils.closest(thisTag, 'UL');
- }
- if(utils.isDropDownParts(thisTag)){ return }
- if(utils.isDropDownParts(e.target)){ return }
- for(var i = 0; i < self.hooks.length; i++) {
- self.hooks[i].list.hide();
- }
- }.bind(this);
- document.addEventListener('click', this.windowClickedWrapper);
- },
-
- removeEvents: function(){
- w.removeEventListener('click', this.windowClickedWrapper);
- w.removeEventListener('load', this.loadWrapper);
- },
-
- changeHookList: function(trigger, list, plugins, config) {
- trigger = document.querySelector('[data-id="'+trigger+'"]');
- // list = document.querySelector(list);
- this.hooks.every(function(hook, i) {
- if(hook.trigger === trigger) {
- hook.destroy();
- this.hooks.splice(i, 1);
- this.addHook(trigger, list, plugins, config);
- return false;
- }
- return true
- }.bind(this));
- },
-
- addHook: function(hook, list, plugins, config) {
- if(!(hook instanceof HTMLElement) && typeof hook === 'string'){
- hook = document.querySelector(hook);
- }
- if(!list){
- list = document.querySelector(hook.dataset[utils.toDataCamelCase(DATA_TRIGGER)]);
- }
-
- if(hook) {
- if(hook.tagName === 'A' || hook.tagName === 'BUTTON') {
- this.hooks.push(new HookButton(hook, list, plugins, config));
- } else if(hook.tagName === 'INPUT') {
- this.hooks.push(new HookInput(hook, list, plugins, config));
- }
- }
- return this;
- },
-
- addHooks: function(hooks, plugins, config) {
- for(var i = 0; i < hooks.length; i++) {
- var hook = hooks[i];
- this.addHook(hook, null, plugins, config);
- }
- return this;
- },
-
- setConfig: function(obj){
- this.config = obj;
- },
-
- init: function () {
- this.addEvents();
- var readyEvent = new CustomEvent('ready.dl', {
- detail: {
- dropdown: this,
- },
- });
- window.dispatchEvent(readyEvent);
- this.ready = true;
- for(var i = 0; i < this.queuedData.length; i++) {
- this.addData.apply(this, this.queuedData[i]);
- }
- this.queuedData = [];
- return this;
- },
- });
-
- return DropLab;
- };
-});
-
-},{"./constants":1,"./custom_event_polyfill":2,"./hook_button":6,"./hook_input":7,"./utils":10,"./window":11}],5:[function(require,module,exports){
-var DropDown = require('./dropdown');
-
-var Hook = function(trigger, list, plugins, config){
- this.trigger = trigger;
- this.list = new DropDown(list);
- this.type = 'Hook';
- this.event = 'click';
- this.plugins = plugins || [];
- this.config = config || {};
- this.id = trigger.dataset.id;
-};
-
-Object.assign(Hook.prototype, {
-
- addEvents: function(){},
-
- constructor: Hook,
-});
-
-module.exports = Hook;
-
-},{"./dropdown":3}],6:[function(require,module,exports){
-var CustomEvent = require('./custom_event_polyfill');
-var Hook = require('./hook');
-
-var HookButton = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
- this.type = 'button';
- this.event = 'click';
- this.addEvents();
- this.addPlugins();
-};
-
-HookButton.prototype = Object.create(Hook.prototype);
-
-Object.assign(HookButton.prototype, {
- addPlugins: function() {
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].init(this);
- }
- },
-
- clicked: function(e){
- var buttonEvent = new CustomEvent('click.dl', {
- detail: {
- hook: this,
- },
- bubbles: true,
- cancelable: true
- });
- this.list.show();
- e.target.dispatchEvent(buttonEvent);
- },
-
- addEvents: function(){
- this.clickedWrapper = this.clicked.bind(this);
- this.trigger.addEventListener('click', this.clickedWrapper);
- },
-
- removeEvents: function(){
- this.trigger.removeEventListener('click', this.clickedWrapper);
- },
-
- restoreInitialState: function() {
- this.list.list.innerHTML = this.list.initialState;
- },
-
- removePlugins: function() {
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].destroy();
- }
- },
-
- destroy: function() {
- this.restoreInitialState();
- this.removeEvents();
- this.removePlugins();
- },
-
-
- constructor: HookButton,
-});
-
-
-module.exports = HookButton;
-
-},{"./custom_event_polyfill":2,"./hook":5}],7:[function(require,module,exports){
-var CustomEvent = require('./custom_event_polyfill');
-var Hook = require('./hook');
-
-var HookInput = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
- this.type = 'input';
- this.event = 'input';
- this.addPlugins();
- this.addEvents();
-};
-
-Object.assign(HookInput.prototype, {
- addPlugins: function() {
- var self = this;
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].init(self);
- }
- },
-
- addEvents: function(){
- var self = this;
-
- this.mousedown = function mousedown(e) {
- if(self.hasRemovedEvents) return;
-
- var mouseEvent = new CustomEvent('mousedown.dl', {
- detail: {
- hook: self,
- text: e.target.value,
- },
- bubbles: true,
- cancelable: true
- });
- e.target.dispatchEvent(mouseEvent);
- }
-
- this.input = function input(e) {
- if(self.hasRemovedEvents) return;
-
- self.list.show();
-
- var inputEvent = new CustomEvent('input.dl', {
- detail: {
- hook: self,
- text: e.target.value,
- },
- bubbles: true,
- cancelable: true
- });
- e.target.dispatchEvent(inputEvent);
- }
-
- this.keyup = function keyup(e) {
- if(self.hasRemovedEvents) return;
-
- keyEvent(e, 'keyup.dl');
- }
-
- this.keydown = function keydown(e) {
- if(self.hasRemovedEvents) return;
-
- keyEvent(e, 'keydown.dl');
- }
-
- function keyEvent(e, keyEventName){
- self.list.show();
-
- var keyEvent = new CustomEvent(keyEventName, {
- detail: {
- hook: self,
- text: e.target.value,
- which: e.which,
- key: e.key,
- },
- bubbles: true,
- cancelable: true
- });
- e.target.dispatchEvent(keyEvent);
- }
-
- this.events = this.events || {};
- this.events.mousedown = this.mousedown;
- this.events.input = this.input;
- this.events.keyup = this.keyup;
- this.events.keydown = this.keydown;
- this.trigger.addEventListener('mousedown', this.mousedown);
- this.trigger.addEventListener('input', this.input);
- this.trigger.addEventListener('keyup', this.keyup);
- this.trigger.addEventListener('keydown', this.keydown);
- },
-
- removeEvents: function() {
- this.hasRemovedEvents = true;
- this.trigger.removeEventListener('mousedown', this.mousedown);
- this.trigger.removeEventListener('input', this.input);
- this.trigger.removeEventListener('keyup', this.keyup);
- this.trigger.removeEventListener('keydown', this.keydown);
- },
-
- restoreInitialState: function() {
- this.list.list.innerHTML = this.list.initialState;
- },
-
- removePlugins: function() {
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].destroy();
- }
- },
-
- destroy: function() {
- this.restoreInitialState();
- this.removeEvents();
- this.removePlugins();
- this.list.destroy();
- }
-});
-
-module.exports = HookInput;
-
-},{"./custom_event_polyfill":2,"./hook":5}],8:[function(require,module,exports){
-var DropLab = require('./droplab')();
-var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
-var keyboard = require('./keyboard')();
-var setup = function() {
- window.DropLab = DropLab;
-};
-
-
-module.exports = setup();
-
-},{"./constants":1,"./droplab":4,"./keyboard":9}],9:[function(require,module,exports){
-require('./window')(function(w){
- module.exports = function(){
- var currentKey;
- var currentFocus;
- var isUpArrow = false;
- var isDownArrow = false;
- var removeHighlight = function removeHighlight(list) {
- var listItems = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0);
- var listItemsTmp = [];
- for(var i = 0; i < listItems.length; i++) {
- var listItem = listItems[i];
- listItem.classList.remove('dropdown-active');
-
- if (listItem.style.display !== 'none') {
- listItemsTmp.push(listItem);
- }
- }
- return listItemsTmp;
- };
-
- var setMenuForArrows = function setMenuForArrows(list) {
- var listItems = removeHighlight(list);
- if(list.currentIndex>0){
- if(!listItems[list.currentIndex-1]){
- list.currentIndex = list.currentIndex-1;
- }
-
- if (listItems[list.currentIndex-1]) {
- var el = listItems[list.currentIndex-1];
- var filterDropdownEl = el.closest('.filter-dropdown');
- el.classList.add('dropdown-active');
-
- if (filterDropdownEl) {
- var filterDropdownBottom = filterDropdownEl.offsetHeight;
- var elOffsetTop = el.offsetTop - 30;
-
- if (elOffsetTop > filterDropdownBottom) {
- filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom;
- }
- }
- }
- }
- };
-
- var mousedown = function mousedown(e) {
- var list = e.detail.hook.list;
- removeHighlight(list);
- list.show();
- list.currentIndex = 0;
- isUpArrow = false;
- isDownArrow = false;
- };
- var selectItem = function selectItem(list) {
- var listItems = removeHighlight(list);
- var currentItem = listItems[list.currentIndex-1];
- var listEvent = new CustomEvent('click.dl', {
- detail: {
- list: list,
- selected: currentItem,
- data: currentItem.dataset,
- },
- });
- list.list.dispatchEvent(listEvent);
- list.hide();
- }
-
- var keydown = function keydown(e){
- var typedOn = e.target;
- var list = e.detail.hook.list;
- var currentIndex = list.currentIndex;
- isUpArrow = false;
- isDownArrow = false;
-
- if(e.detail.which){
- currentKey = e.detail.which;
- if(currentKey === 13){
- selectItem(e.detail.hook.list);
- return;
- }
- if(currentKey === 38) {
- isUpArrow = true;
- }
- if(currentKey === 40) {
- isDownArrow = true;
- }
- } else if(e.detail.key) {
- currentKey = e.detail.key;
- if(currentKey === 'Enter'){
- selectItem(e.detail.hook.list);
- return;
- }
- if(currentKey === 'ArrowUp') {
- isUpArrow = true;
- }
- if(currentKey === 'ArrowDown') {
- isDownArrow = true;
- }
- }
- if(isUpArrow){ currentIndex--; }
- if(isDownArrow){ currentIndex++; }
- if(currentIndex < 0){ currentIndex = 0; }
- list.currentIndex = currentIndex;
- setMenuForArrows(e.detail.hook.list);
- };
-
- w.addEventListener('mousedown.dl', mousedown);
- w.addEventListener('keydown.dl', keydown);
- };
-});
-},{"./window":11}],10:[function(require,module,exports){
-var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
-var DATA_DROPDOWN = require('./constants').DATA_DROPDOWN;
-
-var toDataCamelCase = function(attr){
- return this.camelize(attr.split('-').slice(1).join(' '));
-};
-
-// the tiniest damn templating I can do
-var t = function(s,d){
- for(var p in d)
- s=s.replace(new RegExp('{{'+p+'}}','g'), d[p]);
- return s;
-};
-
-var camelize = function(str) {
- return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter, index) {
- return index == 0 ? letter.toLowerCase() : letter.toUpperCase();
- }).replace(/\s+/g, '');
-};
-
-var closest = function(thisTag, stopTag) {
- while(thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML'){
- thisTag = thisTag.parentNode;
- }
- return thisTag;
-};
-
-var isDropDownParts = function(target) {
- if(!target || target.tagName === 'HTML') { return false; }
- return (
- target.hasAttribute(DATA_TRIGGER) ||
- target.hasAttribute(DATA_DROPDOWN)
- );
-};
-
-module.exports = {
- toDataCamelCase: toDataCamelCase,
- t: t,
- camelize: camelize,
- closest: closest,
- isDropDownParts: isDropDownParts,
-};
-
-},{"./constants":1}],11:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[8])(8)
-});
diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js
deleted file mode 100644
index 020f8b4ac65..00000000000
--- a/app/assets/javascripts/droplab/droplab_ajax.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/* eslint-disable */
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/* global droplab */
-
-require('../window')(function(w){
- function droplabAjaxException(message) {
- this.message = message;
- }
-
- w.droplabAjax = {
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
- }
- };
- xhr.send();
- });
- },
-
- _loadData: function _loadData(data, config, self) {
- if (config.loadingTemplate) {
- var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
-
- if (dataLoadingTemplate) {
- dataLoadingTemplate.outerHTML = self.listTemplate;
- }
- }
-
- if (!self.destroyed) {
- self.hook.list[config.method].call(self.hook.list, data);
- }
- },
-
- init: function init(hook) {
- var self = this;
- self.destroyed = false;
- self.cache = self.cache || {};
- var config = hook.config.droplabAjax;
- this.hook = hook;
-
- if (!config || !config.endpoint || !config.method) {
- return;
- }
-
- if (config.method !== 'setData' && config.method !== 'addData') {
- return;
- }
-
- if (config.loadingTemplate) {
- var dynamicList = hook.list.list.querySelector('[data-dynamic]');
-
- var loadingTemplate = document.createElement('div');
- loadingTemplate.innerHTML = config.loadingTemplate;
- loadingTemplate.setAttribute('data-loading-template', '');
-
- this.listTemplate = dynamicList.outerHTML;
- dynamicList.outerHTML = loadingTemplate.outerHTML;
- }
-
- if (self.cache[config.endpoint]) {
- self._loadData(self.cache[config.endpoint], config, self);
- } else {
- this._loadUrlData(config.endpoint)
- .then(function(d) {
- self._loadData(d, config, self);
- }, function(xhrError) {
- // TODO: properly handle errors due to XHR cancellation
- return;
- }).catch(function(e) {
- throw new droplabAjaxException(e.message || e);
- });
- }
- },
-
- destroy: function() {
- var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
- this.destroyed = true;
- if (this.listTemplate && dynamicList) {
- dynamicList.outerHTML = this.listTemplate;
- }
- }
- };
-});
-},{"../window":2}],2:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[1])(1)
-});
diff --git a/app/assets/javascripts/droplab/droplab_ajax_filter.js b/app/assets/javascripts/droplab/droplab_ajax_filter.js
deleted file mode 100644
index 05eba7aef56..00000000000
--- a/app/assets/javascripts/droplab/droplab_ajax_filter.js
+++ /dev/null
@@ -1,164 +0,0 @@
-/* eslint-disable */
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/* global droplab */
-
-require('../window')(function(w){
- w.droplabAjaxFilter = {
- init: function(hook) {
- this.destroyed = false;
- this.hook = hook;
- this.notLoading();
-
- this.debounceTriggerWrapper = this.debounceTrigger.bind(this);
- this.hook.trigger.addEventListener('keydown.dl', this.debounceTriggerWrapper);
- this.hook.trigger.addEventListener('focus', this.debounceTriggerWrapper);
- this.trigger(true);
- },
-
- notLoading: function notLoading() {
- this.loading = false;
- },
-
- debounceTrigger: function debounceTrigger(e) {
- var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93];
- var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1;
- var focusEvent = e.type === 'focus';
-
- if (invalidKeyPressed || this.loading) {
- return;
- }
-
- if (this.timeout) {
- clearTimeout(this.timeout);
- }
-
- this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200);
- },
-
- trigger: function trigger(getEntireList) {
- var config = this.hook.config.droplabAjaxFilter;
- var searchValue = this.trigger.value;
-
- if (!config || !config.endpoint || !config.searchKey) {
- return;
- }
-
- if (config.searchValueFunction) {
- searchValue = config.searchValueFunction();
- }
-
- if (config.loadingTemplate && this.hook.list.data === undefined ||
- this.hook.list.data.length === 0) {
- var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
-
- var loadingTemplate = document.createElement('div');
- loadingTemplate.innerHTML = config.loadingTemplate;
- loadingTemplate.setAttribute('data-loading-template', true);
-
- this.listTemplate = dynamicList.outerHTML;
- dynamicList.outerHTML = loadingTemplate.outerHTML;
- }
-
- if (getEntireList) {
- searchValue = '';
- }
-
- if (config.searchKey === searchValue) {
- return this.list.show();
- }
-
- this.loading = true;
-
- var params = config.params || {};
- params[config.searchKey] = searchValue;
- var self = this;
- self.cache = self.cache || {};
- var url = config.endpoint + this.buildParams(params);
- var urlCachedData = self.cache[url];
-
- if (urlCachedData) {
- self._loadData(urlCachedData, config, self);
- } else {
- this._loadUrlData(url)
- .then(function(data) {
- self._loadData(data, config, self);
- }, function(xhrError) {
- // TODO: properly handle errors due to XHR cancellation
- return;
- });
- }
- },
-
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
- }
- };
- xhr.send();
- });
- },
-
- _loadData: function _loadData(data, config, self) {
- if (config.loadingTemplate && self.hook.list.data === undefined ||
- self.hook.list.data.length === 0) {
- const dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
-
- if (dataLoadingTemplate) {
- dataLoadingTemplate.outerHTML = self.listTemplate;
- }
- }
-
- if (!self.destroyed) {
- var hookListChildren = self.hook.list.list.children;
- var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
-
- if (onlyDynamicList && data.length === 0) {
- self.hook.list.hide();
- }
-
- self.hook.list.setData.call(self.hook.list, data);
- }
- self.notLoading();
- self.hook.list.currentIndex = 0;
- },
-
- buildParams: function(params) {
- if (!params) return '';
- var paramsArray = Object.keys(params).map(function(param) {
- return param + '=' + (params[param] || '');
- });
- return '?' + paramsArray.join('&');
- },
-
- destroy: function destroy() {
- if (this.timeout) {
- clearTimeout(this.timeout);
- }
-
- this.destroyed = true;
-
- this.hook.trigger.removeEventListener('keydown.dl', this.debounceTriggerWrapper);
- this.hook.trigger.removeEventListener('focus', this.debounceTriggerWrapper);
- }
- };
-});
-},{"../window":2}],2:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[1])(1)
-});
diff --git a/app/assets/javascripts/droplab/droplab_filter.js b/app/assets/javascripts/droplab/droplab_filter.js
deleted file mode 100644
index 7f7d93f3e27..00000000000
--- a/app/assets/javascripts/droplab/droplab_filter.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/* eslint-disable */
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.filter||(g.filter = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/* global droplab */
-
-require('../window')(function(w){
- w.droplabFilter = {
-
- keydownWrapper: function(e){
- var hiddenCount = 0;
- var dataHiddenCount = 0;
- var list = e.detail.hook.list;
- var data = list.data;
- var value = e.detail.hook.trigger.value.toLowerCase();
- var config = e.detail.hook.config.droplabFilter;
- var matches = [];
- var filterFunction;
- // will only work on dynamically set data
- if(!data){
- return;
- }
-
- if (config && config.filterFunction && typeof config.filterFunction === 'function') {
- filterFunction = config.filterFunction;
- } else {
- filterFunction = function(o){
- // cheap string search
- o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1;
- return o;
- };
- }
-
- dataHiddenCount = data.filter(function(o) {
- return !o.droplab_hidden;
- }).length;
-
- matches = data.map(function(o) {
- return filterFunction(o, value);
- });
-
- hiddenCount = matches.filter(function(o) {
- return !o.droplab_hidden;
- }).length;
-
- if (dataHiddenCount !== hiddenCount) {
- list.render(matches);
- list.currentIndex = 0;
- }
- },
-
- init: function init(hookInput) {
- var config = hookInput.config.droplabFilter;
-
- if (!config || (!config.template && !config.filterFunction)) {
- return;
- }
-
- this.hookInput = hookInput;
- this.hookInput.trigger.addEventListener('keyup.dl', this.keydownWrapper);
- this.hookInput.trigger.addEventListener('mousedown.dl', this.keydownWrapper);
- },
-
- destroy: function destroy(){
- this.hookInput.trigger.removeEventListener('keyup.dl', this.keydownWrapper);
- this.hookInput.trigger.removeEventListener('mousedown.dl', this.keydownWrapper);
- }
- };
-});
-},{"../window":2}],2:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[1])(1)
-});
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js
new file mode 100644
index 00000000000..2f840083571
--- /dev/null
+++ b/app/assets/javascripts/droplab/hook.js
@@ -0,0 +1,22 @@
+/* eslint-disable */
+
+import DropDown from './drop_down';
+
+var Hook = function(trigger, list, plugins, config){
+ this.trigger = trigger;
+ this.list = new DropDown(list);
+ this.type = 'Hook';
+ this.event = 'click';
+ this.plugins = plugins || [];
+ this.config = config || {};
+ this.id = trigger.id;
+};
+
+Object.assign(Hook.prototype, {
+
+ addEvents: function(){},
+
+ constructor: Hook,
+});
+
+export default Hook;
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js
new file mode 100644
index 00000000000..be8aead1303
--- /dev/null
+++ b/app/assets/javascripts/droplab/hook_button.js
@@ -0,0 +1,65 @@
+/* eslint-disable */
+
+import Hook from './hook';
+
+var HookButton = function(trigger, list, plugins, config) {
+ Hook.call(this, trigger, list, plugins, config);
+
+ this.type = 'button';
+ this.event = 'click';
+
+ this.eventWrapper = {};
+
+ this.addEvents();
+ this.addPlugins();
+};
+
+HookButton.prototype = Object.create(Hook.prototype);
+
+Object.assign(HookButton.prototype, {
+ addPlugins: function() {
+ this.plugins.forEach(plugin => plugin.init(this));
+ },
+
+ clicked: function(e){
+ var buttonEvent = new CustomEvent('click.dl', {
+ detail: {
+ hook: this,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ e.target.dispatchEvent(buttonEvent);
+
+ this.list.toggle();
+ },
+
+ addEvents: function(){
+ this.eventWrapper.clicked = this.clicked.bind(this);
+ this.trigger.addEventListener('click', this.eventWrapper.clicked);
+ },
+
+ removeEvents: function(){
+ this.trigger.removeEventListener('click', this.eventWrapper.clicked);
+ },
+
+ restoreInitialState: function() {
+ this.list.list.innerHTML = this.list.initialState;
+ },
+
+ removePlugins: function() {
+ this.plugins.forEach(plugin => plugin.destroy());
+ },
+
+ destroy: function() {
+ this.restoreInitialState();
+
+ this.removeEvents();
+ this.removePlugins();
+ },
+
+ constructor: HookButton,
+});
+
+
+export default HookButton;
diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js
new file mode 100644
index 00000000000..05082334045
--- /dev/null
+++ b/app/assets/javascripts/droplab/hook_input.js
@@ -0,0 +1,119 @@
+/* eslint-disable */
+
+import Hook from './hook';
+
+var HookInput = function(trigger, list, plugins, config) {
+ Hook.call(this, trigger, list, plugins, config);
+
+ this.type = 'input';
+ this.event = 'input';
+
+ this.eventWrapper = {};
+
+ this.addEvents();
+ this.addPlugins();
+};
+
+Object.assign(HookInput.prototype, {
+ addPlugins: function() {
+ this.plugins.forEach(plugin => plugin.init(this));
+ },
+
+ addEvents: function(){
+ this.eventWrapper.mousedown = this.mousedown.bind(this);
+ this.eventWrapper.input = this.input.bind(this);
+ this.eventWrapper.keyup = this.keyup.bind(this);
+ this.eventWrapper.keydown = this.keydown.bind(this);
+
+ this.trigger.addEventListener('mousedown', this.eventWrapper.mousedown);
+ this.trigger.addEventListener('input', this.eventWrapper.input);
+ this.trigger.addEventListener('keyup', this.eventWrapper.keyup);
+ this.trigger.addEventListener('keydown', this.eventWrapper.keydown);
+ },
+
+ removeEvents: function() {
+ this.hasRemovedEvents = true;
+
+ this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown);
+ this.trigger.removeEventListener('input', this.eventWrapper.input);
+ this.trigger.removeEventListener('keyup', this.eventWrapper.keyup);
+ this.trigger.removeEventListener('keydown', this.eventWrapper.keydown);
+ },
+
+ input: function(e) {
+ if(this.hasRemovedEvents) return;
+
+ this.list.show();
+
+ const inputEvent = new CustomEvent('input.dl', {
+ detail: {
+ hook: this,
+ text: e.target.value,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ e.target.dispatchEvent(inputEvent);
+ },
+
+ mousedown: function(e) {
+ if (this.hasRemovedEvents) return;
+
+ const mouseEvent = new CustomEvent('mousedown.dl', {
+ detail: {
+ hook: this,
+ text: e.target.value,
+ },
+ bubbles: true,
+ cancelable: true,
+ });
+ e.target.dispatchEvent(mouseEvent);
+ },
+
+ keyup: function(e) {
+ if (this.hasRemovedEvents) return;
+
+ this.keyEvent(e, 'keyup.dl');
+ },
+
+ keydown: function(e) {
+ if (this.hasRemovedEvents) return;
+
+ this.keyEvent(e, 'keydown.dl');
+ },
+
+ keyEvent: function(e, eventName) {
+ this.list.show();
+
+ const keyEvent = new CustomEvent(eventName, {
+ detail: {
+ hook: this,
+ text: e.target.value,
+ which: e.which,
+ key: e.key,
+ },
+ bubbles: true,
+ cancelable: true,
+ });
+ e.target.dispatchEvent(keyEvent);
+ },
+
+ restoreInitialState: function() {
+ this.list.list.innerHTML = this.list.initialState;
+ },
+
+ removePlugins: function() {
+ this.plugins.forEach(plugin => plugin.destroy());
+ },
+
+ destroy: function() {
+ this.restoreInitialState();
+
+ this.removeEvents();
+ this.removePlugins();
+
+ this.list.destroy();
+ }
+});
+
+export default HookInput;
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js
new file mode 100644
index 00000000000..36740a430e1
--- /dev/null
+++ b/app/assets/javascripts/droplab/keyboard.js
@@ -0,0 +1,113 @@
+/* eslint-disable */
+
+import { ACTIVE_CLASS } from './constants';
+
+const Keyboard = function () {
+ var currentKey;
+ var currentFocus;
+ var isUpArrow = false;
+ var isDownArrow = false;
+ var removeHighlight = function removeHighlight(list) {
+ var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0);
+ var listItems = [];
+ for(var i = 0; i < itemElements.length; i++) {
+ var listItem = itemElements[i];
+ listItem.classList.remove(ACTIVE_CLASS);
+
+ if (listItem.style.display !== 'none') {
+ listItems.push(listItem);
+ }
+ }
+ return listItems;
+ };
+
+ var setMenuForArrows = function setMenuForArrows(list) {
+ var listItems = removeHighlight(list);
+ if(list.currentIndex>0){
+ if(!listItems[list.currentIndex-1]){
+ list.currentIndex = list.currentIndex-1;
+ }
+
+ if (listItems[list.currentIndex-1]) {
+ var el = listItems[list.currentIndex-1];
+ var filterDropdownEl = el.closest('.filter-dropdown');
+ el.classList.add(ACTIVE_CLASS);
+
+ if (filterDropdownEl) {
+ var filterDropdownBottom = filterDropdownEl.offsetHeight;
+ var elOffsetTop = el.offsetTop - 30;
+
+ if (elOffsetTop > filterDropdownBottom) {
+ filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom;
+ }
+ }
+ }
+ }
+ };
+
+ var mousedown = function mousedown(e) {
+ var list = e.detail.hook.list;
+ removeHighlight(list);
+ list.show();
+ list.currentIndex = 0;
+ isUpArrow = false;
+ isDownArrow = false;
+ };
+ var selectItem = function selectItem(list) {
+ var listItems = removeHighlight(list);
+ var currentItem = listItems[list.currentIndex-1];
+ var listEvent = new CustomEvent('click.dl', {
+ detail: {
+ list: list,
+ selected: currentItem,
+ data: currentItem.dataset,
+ },
+ });
+ list.list.dispatchEvent(listEvent);
+ list.hide();
+ }
+
+ var keydown = function keydown(e){
+ var typedOn = e.target;
+ var list = e.detail.hook.list;
+ var currentIndex = list.currentIndex;
+ isUpArrow = false;
+ isDownArrow = false;
+
+ if(e.detail.which){
+ currentKey = e.detail.which;
+ if(currentKey === 13){
+ selectItem(e.detail.hook.list);
+ return;
+ }
+ if(currentKey === 38) {
+ isUpArrow = true;
+ }
+ if(currentKey === 40) {
+ isDownArrow = true;
+ }
+ } else if(e.detail.key) {
+ currentKey = e.detail.key;
+ if(currentKey === 'Enter'){
+ selectItem(e.detail.hook.list);
+ return;
+ }
+ if(currentKey === 'ArrowUp') {
+ isUpArrow = true;
+ }
+ if(currentKey === 'ArrowDown') {
+ isDownArrow = true;
+ }
+ }
+ if(isUpArrow){ currentIndex--; }
+ if(isDownArrow){ currentIndex++; }
+ if(currentIndex < 0){ currentIndex = 0; }
+ list.currentIndex = currentIndex;
+ setMenuForArrows(e.detail.hook.list);
+ };
+
+ document.addEventListener('mousedown.dl', mousedown);
+ document.addEventListener('keydown.dl', keydown);
+};
+
+export default Keyboard;
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js
new file mode 100644
index 00000000000..12afe53ed76
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/ajax.js
@@ -0,0 +1,65 @@
+/* eslint-disable */
+
+const Ajax = {
+ _loadUrlData: function _loadUrlData(url) {
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ var xhr = new XMLHttpRequest;
+ xhr.open('GET', url, true);
+ xhr.onreadystatechange = function () {
+ if(xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ var data = JSON.parse(xhr.responseText);
+ self.cache[url] = data;
+ return resolve(data);
+ } else {
+ return reject([xhr.responseText, xhr.status]);
+ }
+ }
+ };
+ xhr.send();
+ });
+ },
+ _loadData: function _loadData(data, config, self) {
+ if (config.loadingTemplate) {
+ var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
+ if (dataLoadingTemplate) dataLoadingTemplate.outerHTML = self.listTemplate;
+ }
+
+ if (!self.destroyed) self.hook.list[config.method].call(self.hook.list, data);
+ },
+ init: function init(hook) {
+ var self = this;
+ self.destroyed = false;
+ self.cache = self.cache || {};
+ var config = hook.config.Ajax;
+ this.hook = hook;
+ if (!config || !config.endpoint || !config.method) {
+ return;
+ }
+ if (config.method !== 'setData' && config.method !== 'addData') {
+ return;
+ }
+ if (config.loadingTemplate) {
+ var dynamicList = hook.list.list.querySelector('[data-dynamic]');
+ var loadingTemplate = document.createElement('div');
+ loadingTemplate.innerHTML = config.loadingTemplate;
+ loadingTemplate.setAttribute('data-loading-template', '');
+ this.listTemplate = dynamicList.outerHTML;
+ dynamicList.outerHTML = loadingTemplate.outerHTML;
+ }
+ if (self.cache[config.endpoint]) {
+ self._loadData(self.cache[config.endpoint], config, self);
+ } else {
+ this._loadUrlData(config.endpoint)
+ .then(function(d) {
+ self._loadData(d, config, self);
+ }, config.onError).catch(config.onError);
+ }
+ },
+ destroy: function() {
+ this.destroyed = true;
+ }
+};
+
+export default Ajax;
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js
new file mode 100644
index 00000000000..cfd7e2ca189
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js
@@ -0,0 +1,133 @@
+/* eslint-disable */
+
+const AjaxFilter = {
+ init: function(hook) {
+ this.destroyed = false;
+ this.hook = hook;
+ this.notLoading();
+
+ this.eventWrapper = {};
+ this.eventWrapper.debounceTrigger = this.debounceTrigger.bind(this);
+ this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceTrigger);
+ this.hook.trigger.addEventListener('focus', this.eventWrapper.debounceTrigger);
+
+ this.trigger(true);
+ },
+
+ notLoading: function notLoading() {
+ this.loading = false;
+ },
+
+ debounceTrigger: function debounceTrigger(e) {
+ var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93];
+ var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1;
+ var focusEvent = e.type === 'focus';
+ if (invalidKeyPressed || this.loading) {
+ return;
+ }
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200);
+ },
+
+ trigger: function trigger(getEntireList) {
+ var config = this.hook.config.AjaxFilter;
+ var searchValue = this.trigger.value;
+ if (!config || !config.endpoint || !config.searchKey) {
+ return;
+ }
+ if (config.searchValueFunction) {
+ searchValue = config.searchValueFunction();
+ }
+ if (config.loadingTemplate && this.hook.list.data === undefined ||
+ this.hook.list.data.length === 0) {
+ var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
+ var loadingTemplate = document.createElement('div');
+ loadingTemplate.innerHTML = config.loadingTemplate;
+ loadingTemplate.setAttribute('data-loading-template', true);
+ this.listTemplate = dynamicList.outerHTML;
+ dynamicList.outerHTML = loadingTemplate.outerHTML;
+ }
+ if (getEntireList) {
+ searchValue = '';
+ }
+ if (config.searchKey === searchValue) {
+ return this.list.show();
+ }
+ this.loading = true;
+ var params = config.params || {};
+ params[config.searchKey] = searchValue;
+ var self = this;
+ self.cache = self.cache || {};
+ var url = config.endpoint + this.buildParams(params);
+ var urlCachedData = self.cache[url];
+ if (urlCachedData) {
+ self._loadData(urlCachedData, config, self);
+ } else {
+ this._loadUrlData(url)
+ .then(function(data) {
+ self._loadData(data, config, self);
+ }, config.onError).catch(config.onError);
+ }
+ },
+
+ _loadUrlData: function _loadUrlData(url) {
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ var xhr = new XMLHttpRequest;
+ xhr.open('GET', url, true);
+ xhr.onreadystatechange = function () {
+ if(xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ var data = JSON.parse(xhr.responseText);
+ self.cache[url] = data;
+ return resolve(data);
+ } else {
+ return reject([xhr.responseText, xhr.status]);
+ }
+ }
+ };
+ xhr.send();
+ });
+ },
+
+ _loadData: function _loadData(data, config, self) {
+ const list = self.hook.list;
+ if (config.loadingTemplate && list.data === undefined ||
+ list.data.length === 0) {
+ const dataLoadingTemplate = list.list.querySelector('[data-loading-template]');
+ if (dataLoadingTemplate) {
+ dataLoadingTemplate.outerHTML = self.listTemplate;
+ }
+ }
+ if (!self.destroyed) {
+ var hookListChildren = list.list.children;
+ var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
+ if (onlyDynamicList && data.length === 0) {
+ list.hide();
+ }
+ list.setData.call(list, data);
+ }
+ self.notLoading();
+ list.currentIndex = 0;
+ },
+
+ buildParams: function(params) {
+ if (!params) return '';
+ var paramsArray = Object.keys(params).map(function(param) {
+ return param + '=' + (params[param] || '');
+ });
+ return '?' + paramsArray.join('&');
+ },
+
+ destroy: function destroy() {
+ if (this.timeout)clearTimeout(this.timeout);
+ this.destroyed = true;
+
+ this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceTrigger);
+ this.hook.trigger.removeEventListener('focus', this.eventWrapper.debounceTrigger);
+ }
+};
+
+export default AjaxFilter;
diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/droplab/plugins/filter.js
new file mode 100644
index 00000000000..d6a1aadd49c
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/filter.js
@@ -0,0 +1,95 @@
+/* eslint-disable */
+
+const Filter = {
+ keydown: function(e){
+ if (this.destroyed) return;
+
+ var hiddenCount = 0;
+ var dataHiddenCount = 0;
+
+ var list = e.detail.hook.list;
+ var data = list.data;
+ var value = e.detail.hook.trigger.value.toLowerCase();
+ var config = e.detail.hook.config.Filter;
+ var matches = [];
+ var filterFunction;
+ // will only work on dynamically set data
+ if(!data){
+ return;
+ }
+
+ if (config && config.filterFunction && typeof config.filterFunction === 'function') {
+ filterFunction = config.filterFunction;
+ } else {
+ filterFunction = function(o){
+ // cheap string search
+ o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1;
+ return o;
+ };
+ }
+
+ dataHiddenCount = data.filter(function(o) {
+ return !o.droplab_hidden;
+ }).length;
+
+ matches = data.map(function(o) {
+ return filterFunction(o, value);
+ });
+
+ hiddenCount = matches.filter(function(o) {
+ return !o.droplab_hidden;
+ }).length;
+
+ if (dataHiddenCount !== hiddenCount) {
+ list.setData(matches);
+ list.currentIndex = 0;
+ }
+ },
+
+ debounceKeydown: function debounceKeydown(e) {
+ if ([
+ 13, // enter
+ 16, // shift
+ 17, // ctrl
+ 18, // alt
+ 20, // caps lock
+ 37, // left arrow
+ 38, // up arrow
+ 39, // right arrow
+ 40, // down arrow
+ 91, // left window
+ 92, // right window
+ 93, // select
+ ].indexOf(e.detail.which || e.detail.keyCode) > -1) return;
+
+ if (this.timeout) clearTimeout(this.timeout);
+ this.timeout = setTimeout(this.keydown.bind(this, e), 200);
+ },
+
+ init: function init(hook) {
+ var config = hook.config.Filter;
+
+ if (!config || !config.template) return;
+
+ this.hook = hook;
+ this.destroyed = false;
+
+ this.eventWrapper = {};
+ this.eventWrapper.debounceKeydown = this.debounceKeydown.bind(this);
+
+ this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceKeydown);
+ this.hook.trigger.addEventListener('mousedown.dl', this.eventWrapper.debounceKeydown);
+
+ this.debounceKeydown({ detail: { hook: this.hook } });
+ },
+
+ destroy: function destroy() {
+ if (this.timeout) clearTimeout(this.timeout);
+ this.destroyed = true;
+
+ this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceKeydown);
+ this.hook.trigger.removeEventListener('mousedown.dl', this.eventWrapper.debounceKeydown);
+ }
+};
+
+export default Filter;
diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/droplab/plugins/input_setter.js
new file mode 100644
index 00000000000..d01fbc5830d
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/input_setter.js
@@ -0,0 +1,50 @@
+/* eslint-disable */
+
+const InputSetter = {
+ init(hook) {
+ this.hook = hook;
+ this.destroyed = false;
+ this.config = hook.config.InputSetter || (this.hook.config.InputSetter = {});
+
+ this.eventWrapper = {};
+
+ this.addEvents();
+ },
+
+ addEvents() {
+ this.eventWrapper.setInputs = this.setInputs.bind(this);
+ this.hook.list.list.addEventListener('click.dl', this.eventWrapper.setInputs);
+ },
+
+ removeEvents() {
+ this.hook.list.list.removeEventListener('click.dl', this.eventWrapper.setInputs);
+ },
+
+ setInputs(e) {
+ if (this.destroyed) return;
+
+ const selectedItem = e.detail.selected;
+
+ if (!Array.isArray(this.config)) this.config = [this.config];
+
+ this.config.forEach(config => this.setInput(config, selectedItem));
+ },
+
+ setInput(config, selectedItem) {
+ const input = config.input || this.hook.trigger;
+ const newValue = selectedItem.getAttribute(config.valueAttribute);
+ const inputAttribute = config.inputAttribute;
+
+ if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue);
+ if (input.tagName === 'INPUT') return input.value = newValue;
+ return input.textContent = newValue;
+ },
+
+ destroy() {
+ this.destroyed = true;
+
+ this.removeEvents();
+ },
+};
+
+export default InputSetter;
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js
new file mode 100644
index 00000000000..c149a33a1e9
--- /dev/null
+++ b/app/assets/javascripts/droplab/utils.js
@@ -0,0 +1,38 @@
+/* eslint-disable */
+
+import { DATA_TRIGGER, DATA_DROPDOWN } from './constants';
+
+const utils = {
+ toCamelCase(attr) {
+ return this.camelize(attr.split('-').slice(1).join(' '));
+ },
+
+ t(s, d) {
+ for (const p in d) {
+ if (Object.prototype.hasOwnProperty.call(d, p)) {
+ s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]);
+ }
+ }
+ return s;
+ },
+
+ camelize(str) {
+ return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => {
+ return index === 0 ? letter.toLowerCase() : letter.toUpperCase();
+ }).replace(/\s+/g, '');
+ },
+
+ closest(thisTag, stopTag) {
+ while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') {
+ thisTag = thisTag.parentNode;
+ }
+ return thisTag;
+ },
+
+ isDropDownParts(target) {
+ if (!target || target.tagName === 'HTML') return false;
+ return target.hasAttribute(DATA_TRIGGER) || target.hasAttribute(DATA_DROPDOWN);
+ },
+};
+
+export default utils;
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 3f041172ff3..59d6508fc02 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -55,14 +55,19 @@ window.FilesCommentButton = (function() {
textFileElement = this.getTextFileElement($currentTarget);
buttonParentElement.append(this.buildButton({
+ discussionID: lineContentElement.attr('data-discussion-id'),
+ lineType: lineContentElement.attr('data-line-type'),
+
noteableType: textFileElement.attr('data-noteable-type'),
noteableID: textFileElement.attr('data-noteable-id'),
commitID: textFileElement.attr('data-commit-id'),
noteType: lineContentElement.attr('data-note-type'),
- position: lineContentElement.attr('data-position'),
- lineType: lineContentElement.attr('data-line-type'),
- discussionID: lineContentElement.attr('data-discussion-id'),
- lineCode: lineContentElement.attr('data-line-code')
+
+ // LegacyDiffNote
+ lineCode: lineContentElement.attr('data-line-code'),
+
+ // DiffNote
+ position: lineContentElement.attr('data-position')
}));
};
@@ -76,14 +81,19 @@ window.FilesCommentButton = (function() {
FilesCommentButton.prototype.buildButton = function(buttonAttributes) {
return $commentButtonTemplate.clone().attr({
+ 'data-discussion-id': buttonAttributes.discussionID,
+ 'data-line-type': buttonAttributes.lineType,
+
'data-noteable-type': buttonAttributes.noteableType,
'data-noteable-id': buttonAttributes.noteableID,
'data-commit-id': buttonAttributes.commitID,
'data-note-type': buttonAttributes.noteType,
+
+ // LegacyDiffNote
'data-line-code': buttonAttributes.lineCode,
- 'data-position': buttonAttributes.position,
- 'data-discussion-id': buttonAttributes.discussionID,
- 'data-line-type': buttonAttributes.lineType
+
+ // DiffNote
+ 'data-position': buttonAttributes.position
});
};
@@ -121,7 +131,7 @@ window.FilesCommentButton = (function() {
};
FilesCommentButton.prototype.validateLineContent = function(lineContentElement) {
- return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== '';
+ return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== '';
};
return FilesCommentButton;
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 64d7153e547..381c40c03d8 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,13 +1,13 @@
-require('./filtered_search_dropdown');
+import Filter from '~/droplab/plugins/filter';
-/* global droplabFilter */
+require('./filtered_search_dropdown');
(() => {
class DropdownHint extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
- droplabFilter: {
+ Filter: {
template: 'hint',
filterFunction: gl.DropdownUtils.filterHint.bind(null, input),
},
@@ -69,12 +69,12 @@ require('./filtered_search_dropdown');
}
});
- this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config);
+ this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
}
init() {
- this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init();
+ this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index b3dc3e502c5..6296965b911 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,7 +1,9 @@
-require('./filtered_search_dropdown');
+/* global Flash */
+
+import Ajax from '~/droplab/plugins/ajax';
+import Filter from '~/droplab/plugins/filter';
-/* global droplabAjax */
-/* global droplabFilter */
+require('./filtered_search_dropdown');
(() => {
class DropdownNonUser extends gl.FilteredSearchDropdown {
@@ -9,13 +11,19 @@ require('./filtered_search_dropdown');
super(droplab, dropdown, input, filter);
this.symbol = symbol;
this.config = {
- droplabAjax: {
+ Ajax: {
endpoint,
method: 'setData',
loadingTemplate: this.loadingTemplate,
+ onError() {
+ /* eslint-disable no-new */
+ new Flash('An error occured fetching the dropdown data.');
+ /* eslint-enable no-new */
+ },
},
- droplabFilter: {
+ Filter: {
filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
+ template: 'title',
},
};
}
@@ -29,13 +37,13 @@ require('./filtered_search_dropdown');
renderContent(forceShowList = false) {
this.droplab
- .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config);
+ .changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config);
super.renderContent(forceShowList);
}
init() {
this.droplab
- .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init();
+ .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 04e2afad02f..38b5d315bcf 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,13 +1,15 @@
-require('./filtered_search_dropdown');
+/* global Flash */
+
+import AjaxFilter from '~/droplab/plugins/ajax_filter';
-/* global droplabAjaxFilter */
+require('./filtered_search_dropdown');
(() => {
class DropdownUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
- droplabAjaxFilter: {
+ AjaxFilter: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
@@ -18,6 +20,11 @@ require('./filtered_search_dropdown');
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
+ onError() {
+ /* eslint-disable no-new */
+ new Flash('An error occured fetching the dropdown data.');
+ /* eslint-enable no-new */
+ },
},
};
}
@@ -28,7 +35,7 @@ require('./filtered_search_dropdown');
}
renderContent(forceShowList = false) {
- this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config);
+ this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
super.renderContent(forceShowList);
}
@@ -56,7 +63,7 @@ require('./filtered_search_dropdown');
}
init() {
- this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init();
+ this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
}
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index e7bf530d343..d58eeeebf81 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -4,7 +4,7 @@
class FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
this.droplab = droplab;
- this.hookId = input && input.getAttribute('data-id');
+ this.hookId = input && input.id;
this.input = input;
this.filter = filter;
this.dropdown = dropdown;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 5fbe0450bb8..ec481b9ef97 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,4 +1,4 @@
-/* global DropLab */
+import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
(() => {
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 2a8a6b81b3f..b93a8f1d322 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -154,7 +154,7 @@ import eventHub from './event_hub';
if (e.keyCode === 13) {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
const dropdownEl = dropdown.element;
- const activeElements = dropdownEl.querySelectorAll('.dropdown-active');
+ const activeElements = dropdownEl.querySelectorAll('.droplab-item-active');
e.preventDefault();
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index e7c98e16581..ff10f19a4fe 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -29,7 +29,8 @@ GLForm.prototype.setupForm = function() {
this.form.find('.div-dropzone').remove();
this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes
- gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button'));
+ gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
+
gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
new DropzoneInput(this.form);
autosize(this.textarea);
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
new file mode 100644
index 00000000000..7732edde1e7
--- /dev/null
+++ b/app/assets/javascripts/group.js
@@ -0,0 +1,21 @@
+export default class Group {
+ constructor() {
+ this.groupPath = $('#group_path');
+ this.groupName = $('#group_name');
+ this.updateHandler = this.update.bind(this);
+ this.resetHandler = this.reset.bind(this);
+ if (this.groupName.val() === '') {
+ this.groupPath.on('keyup', this.updateHandler);
+ this.groupName.on('keydown', this.resetHandler);
+ }
+ }
+
+ update() {
+ this.groupName.val(this.groupPath.val());
+ }
+
+ reset() {
+ this.groupPath.off('keyup', this.updateHandler);
+ this.groupName.off('keydown', this.resetHandler);
+ }
+}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 5b50bc62876..c50ec24c818 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -37,14 +37,7 @@ import './shortcuts_issuable';
import './shortcuts_network';
// behaviors
-import './behaviors/autosize';
-import './behaviors/details_behavior';
-import './behaviors/quick_submit';
-import './behaviors/requires_input';
-import './behaviors/toggler_behavior';
-import './behaviors/bind_in_out';
-import { installGlEmojiElement } from './behaviors/gl_emoji';
-installGlEmojiElement();
+import './behaviors/';
// blob
import './blob/create_branch_dropdown';
@@ -75,12 +68,6 @@ import './u2f/error';
import './u2f/register';
import './u2f/util';
-// droplab
-import './droplab/droplab';
-import './droplab/droplab_ajax';
-import './droplab/droplab_ajax_filter';
-import './droplab/droplab_filter';
-
// everything else
import './abuse_reports';
import './activities';
@@ -285,7 +272,7 @@ $(function () {
// Disable form buttons while a form is submitting
$body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) {
var buttons;
- buttons = $('[type="submit"]', this);
+ buttons = $('[type="submit"], .js-disable-on-submit', this);
switch (e.type) {
case 'ajax:beforeSend':
case 'submit':
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 8528e0800ae..f7f6a773036 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -3,9 +3,6 @@
/* global Flash */
import Cookies from 'js-cookie';
-
-import CommitPipelinesTable from './commit/pipelines/pipelines_table';
-
import './breakpoints';
import './flash';
@@ -102,10 +99,10 @@ import './flash';
destroyPipelinesView() {
if (this.commitPipelinesTable) {
- document.querySelector('#commit-pipeline-table-view')
- .removeChild(this.commitPipelinesTable.$el);
-
this.commitPipelinesTable.$destroy();
+ this.commitPipelinesTable = null;
+
+ document.querySelector('#commit-pipeline-table-view').innerHTML = '';
}
}
@@ -234,7 +231,7 @@ import './flash';
}
mountPipelinesView() {
- this.commitPipelinesTable = new CommitPipelinesTable().$mount();
+ this.commitPipelinesTable = new gl.CommitPipelinesTable().$mount();
// $mount(el) replaces the el with the new rendered component. We need it in order to mount
// it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
document.querySelector('#commit-pipeline-table-view')
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index ac4fad88fe5..773fe3233a7 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -2,8 +2,6 @@
/* global Issuable */
/* global ListMilestone */
-import Vue from 'vue';
-
(function() {
this.MilestoneSelect = (function() {
function MilestoneSelect(currentProject, els) {
@@ -151,12 +149,12 @@ import Vue from 'vue';
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if (selected.id !== -1) {
- Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'milestone', new ListMilestone({
+ gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
id: selected.id,
title: selected.name
}));
} else {
- Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'milestone');
+ gl.issueBoards.boardStoreIssueDelete('milestone');
}
$dropdown.trigger('loading.gl.dropdown');
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 1d563c63f39..15f7a813626 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -5,6 +5,7 @@
/* global mrRefreshWidgetUrl */
import Cookies from 'js-cookie';
+import CommentTypeToggle from './comment_type_toggle';
require('./autosave');
window.autosize = require('vendor/autosize');
@@ -110,7 +111,6 @@ require('./task_list');
$(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);
};
@@ -137,6 +137,26 @@ require('./task_list');
$(document).off("click", '.system-note-commit-list-toggler');
};
+ Notes.initCommentTypeToggle = function (form) {
+ const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle');
+ const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu');
+ const noteTypeInput = form.querySelector('#note_type');
+ const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button');
+ const closeButton = form.querySelector('.js-note-target-close');
+ const reopenButton = form.querySelector('.js-note-target-reopen');
+
+ const commentTypeToggle = new CommentTypeToggle({
+ dropdownTrigger,
+ dropdownList,
+ noteTypeInput,
+ submitButton,
+ closeButton,
+ reopenButton,
+ });
+
+ commentTypeToggle.initDroplab();
+ };
+
Notes.prototype.keydownNoteText = function(e) {
var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
if (gl.utils.isMetaKey(e)) {
@@ -192,7 +212,7 @@ require('./task_list');
};
Notes.prototype.refresh = function() {
- if (!document.hidden && document.URL.indexOf(this.noteable_url) === 0) {
+ if (!document.hidden) {
return this.getContent();
}
};
@@ -213,11 +233,7 @@ require('./task_list');
_this.last_fetched_at = data.last_fetched_at;
_this.setPollingInterval(data.notes.length);
return $.each(notes, function(i, note) {
- if (note.discussion_html != null) {
- return _this.renderDiscussionNote(note);
- } else {
- return _this.renderNote(note);
- }
+ _this.renderNote(note);
});
};
})(this)
@@ -276,8 +292,12 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
- Notes.prototype.renderNote = function(note) {
+ Notes.prototype.renderNote = function(note, $form) {
var $notesList;
+ if (note.discussion_html != null) {
+ return this.renderDiscussionNote(note, $form);
+ }
+
if (!note.valid) {
if (note.errors.commands_only) {
new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
@@ -317,61 +337,50 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
- Notes.prototype.renderDiscussionNote = function(note) {
- var discussionContainer, form, note_html, row, lineType, diffAvatarContainer;
+ Notes.prototype.renderDiscussionNote = function(note, $form) {
+ var discussionContainer, form, row, lineType, diffAvatarContainer;
if (!this.isNewNote(note)) {
return;
}
this.note_ids.push(note.id);
- form = $("#new-discussion-note-form-" + note.discussion_id);
- if ((note.original_discussion_id != null) && form.length === 0) {
- form = $("#new-discussion-note-form-" + note.original_discussion_id);
- }
+ form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.discussion_id + "']");
row = form.closest("tr");
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
- note_html = $(note.html);
- note_html.renderGFM();
// is this the first note of discussion?
discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
- if ((note.original_discussion_id != null) && discussionContainer.length === 0) {
- discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']");
+ if (!discussionContainer.length) {
+ discussionContainer = form.closest('.discussion').find('.notes');
}
if (discussionContainer.length === 0) {
- if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
- // insert the note and the reply button after the temp row
- row.after(note.diff_discussion_html);
+ if (note.diff_discussion_html) {
+ var $discussion = $(note.diff_discussion_html).renderGFM();
- // remove the note (will be added again below)
- row.next().find(".note").remove();
- } else {
- // Merge new discussion HTML in
- var $discussion = $(note.diff_discussion_html);
- var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]');
- var contentContainerClass = '.' + $notes.closest('.notes_content')
- .attr('class')
- .split(' ')
- .join('.');
-
- // remove the note (will be added again below)
- $notes.find('.note').remove();
-
- row.find(contentContainerClass + ' .content').append($notes.closest('.content').children());
+ if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
+ // insert the note and the reply button after the temp row
+ row.after($discussion);
+ } else {
+ // Merge new discussion HTML in
+ var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]');
+ var contentContainerClass = '.' + $notes.closest('.notes_content')
+ .attr('class')
+ .split(' ')
+ .join('.');
+
+ row.find(contentContainerClass + ' .content').append($notes.closest('.content').children());
+ }
}
- // Before that, the container didn't exist
- discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
- // Add note to 'Changes' page discussions
- discussionContainer.append(note_html);
+
// Init discussion on 'Discussion' page if it is merge request page
- if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) {
- $('ul.main-notes-list').append(note.discussion_html).renderGFM();
+ if ($('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) {
+ $('ul.main-notes-list').append($(note.discussion_html).renderGFM());
}
} else {
// append new note to all matching discussions
- discussionContainer.append(note_html);
+ discussionContainer.append($(note.html).renderGFM());
}
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_id) {
+ if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) {
gl.diffNotesCompileComponents();
this.renderDiscussionAvatar(diffAvatarContainer, note);
}
@@ -455,9 +464,14 @@ require('./task_list');
form.addClass("js-main-target-form");
form.find("#note_line_code").remove();
form.find("#note_position").remove();
- form.find("#note_type").remove();
+ form.find("#note_type").val('');
+ form.find("#in_reply_to_discussion_id").remove();
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
- return this.parentTimeline = form.parents('.timeline');
+ this.parentTimeline = form.parents('.timeline');
+
+ if (form.length) {
+ Notes.initCommentTypeToggle(form.get(0));
+ }
};
/*
@@ -470,10 +484,24 @@ require('./task_list');
*/
Notes.prototype.setupNoteForm = function(form) {
- var textarea;
+ var textarea, key;
new gl.GLForm(form);
textarea = form.find(".js-note-text");
- return new Autosave(textarea, ["Note", form.find("#note_noteable_type").val(), form.find("#note_noteable_id").val(), form.find("#note_commit_id").val(), form.find("#note_type").val(), form.find("#note_line_code").val(), form.find("#note_position").val()]);
+ key = [
+ "Note",
+ form.find("#note_noteable_type").val(),
+ form.find("#note_noteable_id").val(),
+ form.find("#note_commit_id").val(),
+ form.find("#note_type").val(),
+ form.find("#in_reply_to_discussion_id").val(),
+
+ // LegacyDiffNote
+ form.find("#note_line_code").val(),
+
+ // DiffNote
+ form.find("#note_position").val()
+ ];
+ return new Autosave(textarea, key);
};
/*
@@ -510,7 +538,7 @@ require('./task_list');
}
}
- this.renderDiscussionNote(note);
+ this.renderNote(note, $form);
// cleanup after successfully creating a diff/discussion note
this.removeDiscussionNoteForm($form);
};
@@ -656,7 +684,7 @@ require('./task_list');
return function(i, el) {
var note, notes;
note = $(el);
- notes = note.closest(".notes");
+ notes = note.closest(".discussion-notes");
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -673,14 +701,13 @@ require('./task_list');
// "Discussions" tab
notes.closest(".timeline-entry").remove();
- if (!_this.isParallelView() || notesTr.find('.note').length === 0) {
- // "Changes" tab / commit view
- notesTr.remove();
+ // The notes tr can contain multiple lists of notes, like on the parallel diff
+ if (notesTr.find('.discussion-notes').length > 1) {
+ notes.remove();
} else {
- notes.closest('.content').empty();
+ notesTr.remove();
}
}
- return note.remove();
};
})(this));
// Decrement the "Discussions" counter only once
@@ -711,7 +738,7 @@ require('./task_list');
Notes.prototype.replyToDiscussionNote = function(e) {
var form, replyLink;
- form = this.formClone.clone();
+ form = this.cleanForm(this.formClone.clone());
replyLink = $(e.target).closest(".js-discussion-reply-button");
// insert the form after the button
replyLink
@@ -727,29 +754,44 @@ require('./task_list');
Sets some hidden fields in the form.
- Note: dataHolder must have the "discussionId", "lineCode", "noteableType"
- and "noteableId" data attributes set.
+ Note: dataHolder must have the "discussionId" and "lineCode" data attributes set.
*/
Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) {
// setup note target
- form.attr('id', "new-discussion-note-form-" + (dataHolder.data("discussionId")));
+ var discussionID = dataHolder.data("discussionId");
+
+ if (discussionID) {
+ form.attr("data-discussion-id", discussionID);
+ form.find("#in_reply_to_discussion_id").val(discussionID);
+ }
+
form.attr("data-line-code", dataHolder.data("lineCode"));
- form.find("#note_type").val(dataHolder.data("noteType"));
form.find("#line_type").val(dataHolder.data("lineType"));
+
+ form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
+ form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
form.find("#note_commit_id").val(dataHolder.data("commitId"));
+ form.find("#note_type").val(dataHolder.data("noteType"));
+
+ // LegacyDiffNote
form.find("#note_line_code").val(dataHolder.data("lineCode"));
+
+ // DiffNote
form.find("#note_position").val(dataHolder.attr("data-position"));
- form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
- form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
+
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
form.find('.js-note-target-close').remove();
+ form.find('.js-note-new-discussion').remove();
this.setupNoteForm(form);
+ form
+ .removeClass('js-main-target-form')
+ .addClass("discussion-form js-discussion-note-form");
+
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
var $commentBtn = form.find('comment-and-resolve-btn');
- $commentBtn
- .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'");
+ $commentBtn.attr(':discussion-id', `'${discussionID}'`);
gl.diffNotesCompileComponents();
}
@@ -757,10 +799,7 @@ require('./task_list');
form.find(".js-note-text").focus();
form
.find('.js-comment-resolve-button')
- .attr('data-discussion-id', dataHolder.data('discussionId'));
- form
- .removeClass('js-main-target-form')
- .addClass("discussion-form js-discussion-note-form");
+ .attr('data-discussion-id', discussionID);
};
/*
@@ -823,7 +862,7 @@ require('./task_list');
}
if (addForm) {
- newForm = this.formClone.clone();
+ newForm = this.cleanForm(this.formClone.clone());
newForm.appendTo(notesContent);
// show the form
return this.setupDiscussionNoteForm($link, newForm);
@@ -900,9 +939,10 @@ require('./task_list');
reopenbtn = form.find('.js-note-target-reopen');
closebtn = form.find('.js-note-target-close');
discardbtn = form.find('.js-note-discard');
+
if (textarea.val().trim().length > 0) {
- reopentext = reopenbtn.data('alternative-text');
- closetext = closebtn.data('alternative-text');
+ reopentext = reopenbtn.attr('data-alternative-text');
+ closetext = closebtn.attr('data-alternative-text');
if (reopenbtn.text() !== reopentext) {
reopenbtn.text(reopentext);
}
@@ -1009,6 +1049,20 @@ require('./task_list');
});
};
+ Notes.prototype.cleanForm = function($form) {
+ // Remove JS classes that are not needed here
+ $form
+ .find('.js-comment-type-dropdown')
+ .removeClass('btn-group');
+
+ // Remove dropdown
+ $form
+ .find('.dropdown-menu')
+ .remove();
+
+ return $form;
+ };
+
return Notes;
})();
}).call(window);
diff --git a/app/assets/javascripts/protected_tags/index.js b/app/assets/javascripts/protected_tags/index.js
new file mode 100644
index 00000000000..61e7ba53862
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/index.js
@@ -0,0 +1,2 @@
+export { default as ProtectedTagCreate } from './protected_tag_create';
+export { default as ProtectedTagEditList } from './protected_tag_edit_list';
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
new file mode 100644
index 00000000000..fff83f3af3b
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -0,0 +1,26 @@
+export default class ProtectedTagAccessDropdown {
+ constructor(options) {
+ this.options = options;
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ const { onSelect } = this.options;
+ this.options.$dropdown.glDropdown({
+ data: this.options.data,
+ selectable: true,
+ inputId: this.options.$dropdown.data('input-id'),
+ fieldName: this.options.$dropdown.data('field-name'),
+ toggleLabel(item, $el) {
+ if ($el.is('.is-active')) {
+ return item.text;
+ }
+ return 'Select';
+ },
+ clicked(item, $el, e) {
+ e.preventDefault();
+ onSelect();
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
new file mode 100644
index 00000000000..91bd140bd12
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -0,0 +1,41 @@
+import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import ProtectedTagDropdown from './protected_tag_dropdown';
+
+export default class ProtectedTagCreate {
+ constructor() {
+ this.$form = $('.js-new-protected-tag');
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+ const $allowedToCreateDropdown = this.$form.find('.js-allowed-to-create');
+
+ // Cache callback
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ // Allowed to Create dropdown
+ this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ $dropdown: $allowedToCreateDropdown,
+ data: gon.create_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+
+ // Select default
+ $allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0);
+
+ // Protected tag dropdown
+ this.protectedTagDropdown = new ProtectedTagDropdown({
+ $dropdown: this.$form.find('.js-protected-tag-select'),
+ onSelect: this.onSelectCallback,
+ });
+ }
+
+ // This will run after clicked callback
+ onSelect() {
+ // Enable submit button
+ const $tagInput = this.$form.find('input[name="protected_tag[name]"]');
+ const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
+
+ this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length));
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
new file mode 100644
index 00000000000..5ff4e443262
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -0,0 +1,86 @@
+export default class ProtectedTagDropdown {
+ /**
+ * @param {Object} options containing
+ * `$dropdown` target element
+ * `onSelect` event callback
+ * $dropdown must be an element created using `dropdown_tag()` rails helper
+ */
+ constructor(options) {
+ this.onSelect = options.onSelect;
+ this.$dropdown = options.$dropdown;
+ this.$dropdownContainer = this.$dropdown.parent();
+ this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
+ this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag');
+
+ this.buildDropdown();
+ this.bindEvents();
+
+ // Hide footer
+ this.toggleFooter(true);
+ }
+
+ buildDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.getProtectedTags.bind(this),
+ filterable: true,
+ remote: false,
+ search: {
+ fields: ['title'],
+ },
+ selectable: true,
+ toggleLabel(selected) {
+ return (selected && 'id' in selected) ? selected.title : 'Protected Tag';
+ },
+ fieldName: 'protected_tag[name]',
+ text(protectedTag) {
+ return _.escape(protectedTag.title);
+ },
+ id(protectedTag) {
+ return _.escape(protectedTag.id);
+ },
+ onFilter: this.toggleCreateNewButton.bind(this),
+ clicked: (item, $el, e) => {
+ e.preventDefault();
+ this.onSelect();
+ },
+ });
+ }
+
+ bindEvents() {
+ this.$protectedTag.on('click', this.onClickCreateWildcard.bind(this));
+ }
+
+ onClickCreateWildcard(e) {
+ this.$dropdown.data('glDropdown').remote.execute();
+ this.$dropdown.data('glDropdown').selectRowAtIndex();
+ e.preventDefault();
+ }
+
+ getProtectedTags(term, callback) {
+ if (this.selectedTag) {
+ callback(gon.open_tags.concat(this.selectedTag));
+ } else {
+ callback(gon.open_tags);
+ }
+ }
+
+ toggleCreateNewButton(tagName) {
+ if (tagName) {
+ this.selectedTag = {
+ title: tagName,
+ id: tagName,
+ text: tagName,
+ };
+
+ this.$dropdownContainer
+ .find('.create-new-protected-tag code')
+ .text(tagName);
+ }
+
+ this.toggleFooter(!tagName);
+ }
+
+ toggleFooter(toggleState) {
+ this.$dropdownFooter.toggleClass('hidden', toggleState);
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
new file mode 100644
index 00000000000..09a387c0f9e
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -0,0 +1,52 @@
+/* eslint-disable no-new */
+/* global Flash */
+
+import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+
+export default class ProtectedTagEdit {
+ constructor(options) {
+ this.$wrap = options.$wrap;
+ this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create');
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+ // Allowed to create dropdown
+ this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ $dropdown: this.$allowedToCreateDropdownButton,
+ data: gon.create_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+ }
+
+ onSelect() {
+ const $allowedToCreateInput = this.$wrap.find(`input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`);
+
+ // Do not update if one dropdown has not selected any option
+ if (!$allowedToCreateInput.length) return;
+
+ this.$allowedToCreateDropdownButton.disable();
+
+ $.ajax({
+ type: 'POST',
+ url: this.$wrap.data('url'),
+ dataType: 'json',
+ data: {
+ _method: 'PATCH',
+ protected_tag: {
+ create_access_levels_attributes: [{
+ id: this.$allowedToCreateDropdownButton.data('access-level-id'),
+ access_level: $allowedToCreateInput.val(),
+ }],
+ },
+ },
+ error() {
+ new Flash('Failed to update tag!', null, $('.js-protected-tags-list'));
+ },
+ }).always(() => {
+ this.$allowedToCreateDropdownButton.enable();
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
new file mode 100644
index 00000000000..bd9fc872266
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
@@ -0,0 +1,18 @@
+/* eslint-disable no-new */
+
+import ProtectedTagEdit from './protected_tag_edit';
+
+export default class ProtectedTagEditList {
+ constructor() {
+ this.$wrap = $('.protected-tags-list');
+ this.initEditForm();
+ }
+
+ initEditForm() {
+ this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => {
+ new ProtectedTagEdit({
+ $wrap: $(el),
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js
index ea91aaa10a6..2c3a9cacd38 100644
--- a/app/assets/javascripts/render_gfm.js
+++ b/app/assets/javascripts/render_gfm.js
@@ -8,6 +8,7 @@
$.fn.renderGFM = function() {
this.find('.js-syntax-highlight').syntaxHighlight();
this.find('.js-render-math').renderMath();
+ return this;
};
$(document).on('ready load', function() {
diff --git a/app/assets/javascripts/subscription.js b/app/assets/javascripts/subscription.js
index 9c307915ec4..5f9a3e00c22 100644
--- a/app/assets/javascripts/subscription.js
+++ b/app/assets/javascripts/subscription.js
@@ -1,5 +1,3 @@
-import Vue from 'vue';
-
(() => {
class Subscription {
constructor(containerElm) {
@@ -29,8 +27,7 @@ import Vue from 'vue';
// hack to allow this to work with the issue boards Vue object
if (document.querySelector('html').classList.contains('issue-boards-page')) {
- Vue.set(
- gl.issueBoards.BoardsStore.detail.issue,
+ gl.issueBoards.boardStoreIssueSet(
'subscribed',
!gl.issueBoards.BoardsStore.detail.issue.subscribed,
);
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 48e20cf501f..3325a7d429c 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -2,8 +2,6 @@
/* global Issuable */
/* global ListUser */
-import Vue from 'vue';
-
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
slice = [].slice;
@@ -74,7 +72,7 @@ import Vue from 'vue';
e.preventDefault();
if ($dropdown.hasClass('js-issue-board-sidebar')) {
- Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({
+ gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
id: _this.currentUser.id,
username: _this.currentUser.username,
name: _this.currentUser.name,
@@ -225,14 +223,14 @@ import Vue from 'vue';
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if (user.id) {
- Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({
+ gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
id: user.id,
username: user.username,
name: user.name,
avatar_url: user.avatar_url
}));
} else {
- Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'assignee');
+ gl.issueBoards.boardStoreIssueDelete('assignee');
}
updateIssueBoardsIssue();
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 484df6214d3..12465d4a70b 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -446,10 +446,8 @@
}
}
-.filter-dropdown-item.dropdown-active {
- .btn {
- @extend %filter-dropdown-item-btn-hover;
- }
+.filter-dropdown-item.droplab-item-active .btn {
+ @extend %filter-dropdown-item-btn-hover;
}
.filter-dropdown-loading {
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 969fc75c6eb..03fddaeb163 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -57,6 +57,37 @@
margin-right: 5px;
}
}
+
+ .truncated-info {
+ text-align: center;
+ border-bottom: 1px solid;
+ background-color: $black-transparent;
+ height: 45px;
+
+ &.affix {
+ top: 0;
+ }
+
+ // with sidebar
+ &.affix.sidebar-expanded {
+ right: 312px;
+ left: 22px;
+ }
+
+ // without sidebar
+ &.affix.sidebar-collapsed {
+ right: 20px;
+ left: 20px;
+ }
+
+ &.affix-top {
+ position: absolute;
+ top: 0;
+ margin: 0 auto;
+ right: 5px;
+ left: 5px;
+ }
+ }
}
.scroll-controls {
@@ -186,6 +217,7 @@
white-space: pre;
overflow-x: auto;
font-size: 12px;
+ position: relative;
.fa-refresh {
font-size: 24px;
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index e7f9bbbc62f..79da490675a 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -31,7 +31,7 @@
svg {
width: 20px;
- height: auto;
+ height: 20px;
fill: $gl-text-color-secondary;
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index e84a05e3e9e..0bca3e93e4c 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -196,6 +196,7 @@
transition: width .3s;
background: $gray-light;
padding: 10px 20px;
+ z-index: 2;
&.right-sidebar-expanded {
width: $gutter_width;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 927bf9805ce..b637994adf8 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -310,3 +310,94 @@
margin-bottom: 10px;
}
}
+
+.comment-type-dropdown {
+ .comment-btn {
+ width: auto;
+ }
+
+ .dropdown-toggle {
+ float: right;
+
+ .toggle-icon {
+ color: $white-light;
+ padding-right: 2px;
+ margin-top: 2px;
+ pointer-events: none;
+ }
+ }
+
+ .dropdown-menu {
+ top: initial;
+ bottom: 40px;
+ width: 298px;
+ }
+
+ .description {
+ display: inline-block;
+ white-space: normal;
+ margin-left: 8px;
+ padding-right: 33px;
+ }
+
+ li {
+ padding-top: 6px;
+
+ & > a {
+ margin: 0;
+ padding: 0;
+ color: inherit;
+ border-radius: 0;
+ text-overflow: inherit;
+
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ color: inherit;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ background-color: $dropdown-hover-color;
+ color: $white-light;
+ }
+
+ &.droplab-item-selected i {
+ visibility: visible;
+ }
+
+ i {
+ visibility: hidden;
+ }
+ }
+
+ i {
+ display: inline-block;
+ vertical-align: top;
+ padding-top: 2px;
+ }
+
+ .divider {
+ margin: 0 8px;
+ padding: 0;
+ border-top: $gray-darkest;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: flex;
+ width: 100%;
+
+ .comment-btn {
+ flex-grow: 1;
+ flex-shrink: 0;
+ width: auto;
+ }
+
+ .dropdown-toggle {
+ flex-grow: 0;
+ flex-shrink: 1;
+ width: auto;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 12ca20a1420..94ea4c5c8c6 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -19,7 +19,7 @@ ul.notes {
svg {
width: 18px;
- height: auto;
+ height: 18px;
fill: $gray-darkest;
position: absolute;
left: 30px;
@@ -294,6 +294,18 @@ ul.notes {
border-width: 1px;
}
+ .discussion-notes {
+ &:not(:first-child) {
+ border-top: 1px solid $white-normal;
+ margin-top: 20px;
+ }
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $white-normal;
+ margin-bottom: 20px;
+ }
+ }
+
.notes {
background-color: $white-light;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 0fa1f68e034..717ebb44a23 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -744,7 +744,8 @@ pre.light-well {
text-align: left;
}
-.protected-branches-list {
+.protected-branches-list,
+.protected-tags-list {
margin-bottom: 30px;
a {
@@ -776,6 +777,17 @@ pre.light-well {
}
}
+.protected-tags-list {
+ .dropdown-menu-toggle {
+ width: 100%;
+ max-width: 300px;
+ }
+
+ .flash-container {
+ padding: 0;
+ }
+}
+
.custom-notifications-form {
.is-loading {
.custom-notification-event-loading {
diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb
index cf795d977ce..a4648b33cfa 100644
--- a/app/controllers/admin/application_controller.rb
+++ b/app/controllers/admin/application_controller.rb
@@ -6,6 +6,6 @@ class Admin::ApplicationController < ApplicationController
layout 'admin'
def authenticate_admin!
- render_404 unless current_user.is_admin?
+ render_404 unless current_user.admin?
end
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 9433da02f64..8e7adc06584 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -21,6 +21,6 @@ class Admin::ImpersonationsController < Admin::ApplicationController
end
def authenticate_impersonator!
- render_404 unless impersonator && impersonator.is_admin? && !impersonator.blocked?
+ render_404 unless impersonator && impersonator.admin? && !impersonator.blocked?
end
end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
new file mode 100644
index 00000000000..dd21066ac13
--- /dev/null
+++ b/app/controllers/concerns/renders_notes.rb
@@ -0,0 +1,20 @@
+module RendersNotes
+ def prepare_notes_for_rendering(notes)
+ preload_noteable_for_regular_notes(notes)
+ preload_max_access_for_authors(notes, @project)
+ Banzai::NoteRenderer.render(notes, @project, current_user)
+
+ notes
+ end
+
+ private
+
+ def preload_max_access_for_authors(notes, project)
+ user_ids = notes.map(&:author_id)
+ project.team.max_member_access_for_user_ids(user_ids)
+ end
+
+ def preload_noteable_for_regular_notes(notes)
+ ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable)
+ end
+end
diff --git a/app/controllers/concerns/requires_health_token.rb b/app/controllers/concerns/requires_health_token.rb
new file mode 100644
index 00000000000..34ab1a97649
--- /dev/null
+++ b/app/controllers/concerns/requires_health_token.rb
@@ -0,0 +1,25 @@
+module RequiresHealthToken
+ extend ActiveSupport::Concern
+ included do
+ before_action :validate_health_check_access!
+ end
+
+ private
+
+ def validate_health_check_access!
+ render_404 unless token_valid?
+ end
+
+ def token_valid?
+ token = params[:token].presence || request.headers['TOKEN']
+ token.present? &&
+ ActiveSupport::SecurityUtils.variable_size_secure_compare(
+ token,
+ current_application_settings.health_check_access_token
+ )
+ end
+
+ def render_404
+ render file: Rails.root.join('public', '404'), layout: false, status: '404'
+ end
+end
diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb
index 037da7d2bce..5d3109b7187 100644
--- a/app/controllers/health_check_controller.rb
+++ b/app/controllers/health_check_controller.rb
@@ -1,22 +1,3 @@
class HealthCheckController < HealthCheck::HealthCheckController
- before_action :validate_health_check_access!
-
- private
-
- def validate_health_check_access!
- render_404 unless token_valid?
- end
-
- def token_valid?
- token = params[:token].presence || request.headers['TOKEN']
- token.present? &&
- ActiveSupport::SecurityUtils.variable_size_secure_compare(
- token,
- current_application_settings.health_check_access_token
- )
- end
-
- def render_404
- render file: Rails.root.join('public', '404'), layout: false, status: '404'
- end
+ include RequiresHealthToken
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
new file mode 100644
index 00000000000..df0fc3132ed
--- /dev/null
+++ b/app/controllers/health_controller.rb
@@ -0,0 +1,60 @@
+class HealthController < ActionController::Base
+ protect_from_forgery with: :exception
+ include RequiresHealthToken
+
+ CHECKS = [
+ Gitlab::HealthChecks::DbCheck,
+ Gitlab::HealthChecks::RedisCheck,
+ Gitlab::HealthChecks::FsShardsCheck,
+ ].freeze
+
+ def readiness
+ results = CHECKS.map { |check| [check.name, check.readiness] }
+
+ render_check_results(results)
+ end
+
+ def liveness
+ results = CHECKS.map { |check| [check.name, check.liveness] }
+
+ render_check_results(results)
+ end
+
+ def metrics
+ results = CHECKS.flat_map(&:metrics)
+
+ response = results.map(&method(:metric_to_prom_line)).join("\n")
+
+ render text: response, content_type: 'text/plain; version=0.0.4'
+ end
+
+ private
+
+ def metric_to_prom_line(metric)
+ labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || ''
+ if labels.empty?
+ "#{metric.name} #{metric.value}"
+ else
+ "#{metric.name}{#{labels}} #{metric.value}"
+ end
+ end
+
+ def render_check_results(results)
+ flattened = results.flat_map do |name, result|
+ if result.is_a?(Gitlab::HealthChecks::Result)
+ [[name, result]]
+ else
+ result.map { |r| [name, r] }
+ end
+ end
+ success = flattened.all? { |name, r| r.success }
+
+ response = flattened.map do |name, r|
+ info = { status: r.success ? 'ok' : 'failed' }
+ info['message'] = r.message if r.message
+ info[:labels] = r.labels if r.labels
+ [name, info]
+ end
+ render json: response.to_h, status: success ? :ok : :service_unavailable
+ end
+end
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index add66ce9f84..04e8cdf6256 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -19,6 +19,11 @@ class Projects::BuildsController < Projects::ApplicationController
else
@builds
end
+ @builds = @builds.includes([
+ { pipeline: :project },
+ :project,
+ :tags
+ ])
@builds = @builds.page(params[:page]).per(30)
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index f453822bed6..d25bbddd1bb 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -2,6 +2,7 @@
#
# Not to be confused with CommitsController, plural.
class Projects::CommitController < Projects::ApplicationController
+ include RendersNotes
include CreatesCommit
include DiffForPath
include DiffHelper
@@ -113,22 +114,19 @@ class Projects::CommitController < Projects::ApplicationController
end
def define_note_vars
- @grouped_diff_discussions = commit.notes.grouped_diff_discussions
- @notes = commit.notes.non_diff_notes.fresh
-
- Banzai::NoteRenderer.render(
- @grouped_diff_discussions.values.flat_map(&:notes) + @notes,
- @project,
- current_user,
- )
-
+ @noteable = @commit
@note = @project.build_commit_note(commit)
- @noteable = @commit
- @comments_target = {
+ @new_diff_note_attrs = {
noteable_type: 'Commit',
commit_id: @commit.id
}
+
+ @grouped_diff_discussions = commit.grouped_diff_discussions
+ @discussions = commit.discussions
+
+ @notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes)
+ @notes = prepare_notes_for_rendering(@notes)
end
def assign_change_commit_vars
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 1349b015a63..f4a18a5e8f7 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -28,7 +28,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
end
def discussion
- @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404
+ @discussion ||= @merge_request.find_discussion(params[:id]) || render_404
end
def authorize_resolve_discussion!
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index a50e16fa4ff..cbf67137261 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -1,5 +1,5 @@
class Projects::IssuesController < Projects::ApplicationController
- include NotesHelper
+ include RendersNotes
include ToggleSubscriptionAction
include IssuableActions
include ToggleAwardEmoji
@@ -84,15 +84,11 @@ class Projects::IssuesController < Projects::ApplicationController
end
def show
- raw_notes = @issue.notes.inc_relations_for_view.fresh
-
- @notes = Banzai::NoteRenderer.
- render(raw_notes, @project, current_user, @path, @project_wiki, @ref)
-
- @note = @project.notes.new(noteable: @issue)
@noteable = @issue
+ @note = @project.notes.new(noteable: @issue)
- preload_max_access_for_authors(@notes, @project)
+ @discussions = @issue.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
respond_to do |format|
format.html
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index c107b3ffa88..5c1f7e69ee8 100755
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -3,7 +3,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
include DiffForPath
include DiffHelper
include IssuableActions
- include NotesHelper
+ include RendersNotes
include ToggleAwardEmoji
include IssuableCollections
@@ -574,20 +574,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@note = @project.notes.new(noteable: @merge_request)
@discussions = @merge_request.discussions
-
- preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
-
- # This is not executed lazily
- @notes = Banzai::NoteRenderer.render(
- @discussions.flat_map(&:notes),
- @project,
- current_user,
- @path,
- @project_wiki,
- @ref
- )
-
- preload_max_access_for_authors(@notes, @project)
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
end
def define_widget_vars
@@ -600,22 +587,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
def define_diff_comment_vars
- @comments_target = {
+ @new_diff_note_attrs = {
noteable_type: 'MergeRequest',
noteable_id: @merge_request.id
}
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
- @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions
-
- Banzai::NoteRenderer.render(
- @grouped_diff_discussions.values.flat_map(&:notes),
- @project,
- current_user,
- @path,
- @project_wiki,
- @ref
- )
+
+ @grouped_diff_discussions = @merge_request.grouped_diff_discussions
+ @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
end
def define_pipelines_vars
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index d00177e7612..405ea3c0a4f 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -1,4 +1,5 @@
class Projects::NotesController < Projects::ApplicationController
+ include RendersNotes
include ToggleAwardEmoji
# Authorize
@@ -6,13 +7,15 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_create_note!, only: [:create]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
- before_action :find_current_user_notes, only: [:index]
def index
current_fetched_at = Time.now.to_i
notes_json = { notes: [], last_fetched_at: current_fetched_at }
+ @notes = notes_finder.execute.inc_relations_for_view
+ @notes = prepare_notes_for_rendering(@notes)
+
@notes.each do |note|
next if note.cross_reference_not_visible_for?(current_user)
@@ -23,7 +26,10 @@ class Projects::NotesController < Projects::ApplicationController
end
def create
- create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha])
+ create_params = note_params.merge(
+ merge_request_diff_head_sha: params[:merge_request_diff_head_sha],
+ in_reply_to_discussion_id: params[:in_reply_to_discussion_id]
+ )
@note = Notes::CreateService.new(project, current_user, create_params).execute
if @note.is_a?(Note)
@@ -111,6 +117,17 @@ class Projects::NotesController < Projects::ApplicationController
)
end
+ def discussion_html(discussion)
+ return if discussion.individual_note?
+
+ render_to_string(
+ "discussions/_discussion",
+ layout: false,
+ formats: [:html],
+ locals: { discussion: discussion }
+ )
+ end
+
def diff_discussion_html(discussion)
return unless discussion.diff_discussion?
@@ -118,13 +135,13 @@ class Projects::NotesController < Projects::ApplicationController
template = "discussions/_parallel_diff_discussion"
locals =
if params[:line_type] == 'old'
- { discussion_left: discussion, discussion_right: nil }
+ { discussions_left: [discussion], discussions_right: nil }
else
- { discussion_left: nil, discussion_right: discussion }
+ { discussions_left: nil, discussions_right: [discussion] }
end
else
template = "discussions/_diff_discussion"
- locals = { discussion: discussion }
+ locals = { discussions: [discussion] }
end
render_to_string(
@@ -135,54 +152,28 @@ class Projects::NotesController < Projects::ApplicationController
)
end
- def discussion_html(discussion)
- return unless discussion.diff_discussion?
-
- render_to_string(
- "discussions/_discussion",
- layout: false,
- formats: [:html],
- locals: { discussion: discussion }
- )
- end
-
def note_json(note)
attrs = {
- id: note.id
+ commands_changes: note.commands_changes
}
if note.persisted?
- Banzai::NoteRenderer.render([note], @project, current_user)
-
attrs.merge!(
valid: true,
- discussion_id: note.discussion_id,
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
html: note_html(note),
note: note.note
)
- if note.diff_note?
- discussion = note.to_discussion
-
+ discussion = note.to_discussion(noteable)
+ unless discussion.individual_note?
attrs.merge!(
+ discussion_resolvable: discussion.resolvable?,
+
diff_discussion_html: diff_discussion_html(discussion),
discussion_html: discussion_html(discussion)
)
-
- # The discussion_id is used to add the comment to the correct discussion
- # element on the merge request page. Among other things, the discussion_id
- # contains the sha of head commit of the merge request.
- # When new commits are pushed into the merge request after the initial
- # load of the merge request page, the discussion elements will still have
- # the old discussion_ids, with the old head commit sha. The new comment,
- # however, will have the new discussion_id with the new commit sha.
- # To ensure that these new comments will still end up in the correct
- # discussion element, we also send the original discussion_id, with the
- # old commit sha, along, and fall back on this value when no discussion
- # element with the new discussion_id could be found.
- if note.new_diff_note? && note.position != note.original_position
- attrs[:original_discussion_id] = note.original_discussion_id
- end
end
else
attrs.merge!(
@@ -191,7 +182,6 @@ class Projects::NotesController < Projects::ApplicationController
)
end
- attrs[:commands_changes] = note.commands_changes
attrs
end
@@ -205,14 +195,30 @@ class Projects::NotesController < Projects::ApplicationController
def note_params
params.require(:note).permit(
- :note, :noteable, :noteable_id, :noteable_type, :project_id,
- :attachment, :line_code, :commit_id, :type, :position
+ :project_id,
+ :noteable_type,
+ :noteable_id,
+ :commit_id,
+ :noteable,
+ :type,
+
+ :note,
+ :attachment,
+
+ # LegacyDiffNote
+ :line_code,
+
+ # DiffNote
+ :position
)
end
- def find_current_user_notes
- @notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at))
- .execute.inc_author
+ def notes_finder
+ @notes_finder ||= NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at))
+ end
+
+ def noteable
+ @noteable ||= notes_finder.target
end
def last_fetched_at
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 5de8301f74c..1780cc0233c 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -116,7 +116,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def pipeline
- @pipeline ||= project.pipelines.find_by!(id: params[:id])
+ @pipeline ||= project.pipelines.find_by!(id: params[:id]).present(current_user: current_user)
end
def commit
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index c8c80551ac9..ff50602831c 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -23,7 +23,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
def update_params
params.require(:project).permit(
:runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex,
- :public_builds
+ :public_builds, :auto_cancel_pending_pipelines
)
end
end
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index a8cb07eb67a..ba24fa9acfe 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -1,58 +1,23 @@
-class Projects::ProtectedBranchesController < Projects::ApplicationController
- include RepositorySettingsRedirect
- # Authorize
- before_action :require_non_empty_project
- before_action :authorize_admin_project!
- before_action :load_protected_branch, only: [:show, :update, :destroy]
+class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
+ protected
- layout "project_settings"
-
- def index
- redirect_to_repository_settings(@project)
- end
-
- def create
- @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute
- unless @protected_branch.persisted?
- flash[:alert] = @protected_branches.errors.full_messages.join(', ').html_safe
- end
- redirect_to_repository_settings(@project)
- end
-
- def show
- @matching_branches = @protected_branch.matching(@project.repository.branches)
+ def project_refs
+ @project.repository.branches
end
- def update
- @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch)
-
- if @protected_branch.valid?
- respond_to do |format|
- format.json { render json: @protected_branch, status: :ok }
- end
- else
- respond_to do |format|
- format.json { render json: @protected_branch.errors, status: :unprocessable_entity }
- end
- end
+ def create_service_class
+ ::ProtectedBranches::CreateService
end
- def destroy
- @protected_branch.destroy
-
- respond_to do |format|
- format.html { redirect_to_repository_settings(@project) }
- format.js { head :ok }
- end
+ def update_service_class
+ ::ProtectedBranches::UpdateService
end
- private
-
- def load_protected_branch
- @protected_branch = @project.protected_branches.find(params[:id])
+ def load_protected_ref
+ @protected_ref = @project.protected_branches.find(params[:id])
end
- def protected_branch_params
+ def protected_ref_params
params.require(:protected_branch).permit(:name,
merge_access_levels_attributes: [:access_level, :id],
push_access_levels_attributes: [:access_level, :id])
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
new file mode 100644
index 00000000000..083a70968e5
--- /dev/null
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -0,0 +1,47 @@
+class Projects::ProtectedRefsController < Projects::ApplicationController
+ include RepositorySettingsRedirect
+
+ # Authorize
+ before_action :require_non_empty_project
+ before_action :authorize_admin_project!
+ before_action :load_protected_ref, only: [:show, :update, :destroy]
+
+ layout "project_settings"
+
+ def index
+ redirect_to_repository_settings(@project)
+ end
+
+ def create
+ protected_ref = create_service_class.new(@project, current_user, protected_ref_params).execute
+
+ unless protected_ref.persisted?
+ flash[:alert] = protected_ref.errors.full_messages.join(', ').html_safe
+ end
+
+ redirect_to_repository_settings(@project)
+ end
+
+ def show
+ @matching_refs = @protected_ref.matching(project_refs)
+ end
+
+ def update
+ @protected_ref = update_service_class.new(@project, current_user, protected_ref_params).execute(@protected_ref)
+
+ if @protected_ref.valid?
+ render json: @protected_ref, status: :ok
+ else
+ render json: @protected_ref.errors, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @protected_ref.destroy
+
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.js { head :ok }
+ end
+ end
+end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
new file mode 100644
index 00000000000..c61ddf145e6
--- /dev/null
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -0,0 +1,23 @@
+class Projects::ProtectedTagsController < Projects::ProtectedRefsController
+ protected
+
+ def project_refs
+ @project.repository.tags
+ end
+
+ def create_service_class
+ ::ProtectedTags::CreateService
+ end
+
+ def update_service_class
+ ::ProtectedTags::UpdateService
+ end
+
+ def load_protected_ref
+ @protected_ref = @project.protected_tags.find(params[:id])
+ end
+
+ def protected_ref_params
+ params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id])
+ end
+end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index b6ce4abca45..44de8a49593 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -4,46 +4,48 @@ module Projects
before_action :authorize_admin_project!
def show
- @deploy_keys = DeployKeysPresenter
- .new(@project, current_user: current_user)
+ @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
- define_protected_branches
+ define_protected_refs
end
private
- def define_protected_branches
- load_protected_branches
+ def define_protected_refs
+ @protected_branches = @project.protected_branches.order(:name).page(params[:page])
+ @protected_tags = @project.protected_tags.order(:name).page(params[:page])
@protected_branch = @project.protected_branches.new
+ @protected_tag = @project.protected_tags.new
load_gon_index
end
- def load_protected_branches
- @protected_branches = @project.protected_branches.order(:name).page(params[:page])
- end
-
def access_levels_options
{
- push_access_levels: {
- roles: ProtectedBranch::PushAccessLevel.human_access_levels.map do |id, text|
- { id: id, text: text, before_divider: true }
- end
- },
- merge_access_levels: {
- roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map do |id, text|
- { id: id, text: text, before_divider: true }
- end
- }
+ create_access_levels: levels_for_dropdown(ProtectedTag::CreateAccessLevel),
+ push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel),
+ merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel)
}
end
- def open_branches
- branches = @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }
- { open_branches: branches }
+ def levels_for_dropdown(access_level_type)
+ roles = access_level_type.human_access_levels.map do |id, text|
+ { id: id, text: text, before_divider: true }
+ end
+ { roles: roles }
+ end
+
+ def protectable_tags_for_dropdown
+ { open_tags: ProtectableDropdown.new(@project, :tags).hash }
+ end
+
+ def protectable_branches_for_dropdown
+ { open_branches: ProtectableDropdown.new(@project, :branches).hash }
end
def load_gon_index
- gon.push(open_branches.merge(access_levels_options))
+ gon.push(protectable_tags_for_dropdown)
+ gon.push(protectable_branches_for_dropdown)
+ gon.push(access_levels_options)
end
end
end
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index ea1a97b7cf0..5c9e0d4d1a1 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -1,4 +1,5 @@
class Projects::SnippetsController < Projects::ApplicationController
+ include RendersNotes
include ToggleAwardEmoji
include SpammableActions
include SnippetsActions
@@ -55,8 +56,10 @@ class Projects::SnippetsController < Projects::ApplicationController
def show
@note = @project.notes.new(noteable: @snippet)
- @notes = Banzai::NoteRenderer.render(@snippet.notes.fresh, @project, current_user)
@noteable = @snippet
+
+ @discussions = @snippet.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
end
def destroy
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index c47198c5eb6..afa56de920b 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -11,7 +11,7 @@ class Projects::TriggersController < Projects::ApplicationController
end
def create
- @trigger = project.triggers.create(create_params.merge(owner: current_user))
+ @trigger = project.triggers.create(trigger_params.merge(owner: current_user))
if @trigger.valid?
flash[:notice] = 'Trigger was created successfully.'
@@ -36,7 +36,7 @@ class Projects::TriggersController < Projects::ApplicationController
end
def update
- if trigger.update(update_params)
+ if trigger.update(trigger_params)
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), notice: 'Trigger was successfully updated.'
else
render action: "edit"
@@ -67,11 +67,10 @@ class Projects::TriggersController < Projects::ApplicationController
@trigger ||= project.triggers.find(params[:id]) || render_404
end
- def create_params
- params.require(:trigger).permit(:description)
- end
-
- def update_params
- params.require(:trigger).permit(:description)
+ def trigger_params
+ params.require(:trigger).permit(
+ :description,
+ trigger_schedule_attributes: [:id, :active, :cron, :cron_timezone, :ref]
+ )
end
end
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 6630c6384f2..3c499184b41 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -17,29 +17,46 @@ class NotesFinder
@project = project
@current_user = current_user
@params = params
- init_collection
end
def execute
- @notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at]
- @notes
+ notes = init_collection
+ notes = since_fetch_at(notes)
+ notes.fresh
end
- private
+ def target
+ return @target if defined?(@target)
- def init_collection
- @notes =
- if @params[:target_id]
- on_target(@params[:target_type], @params[:target_id])
+ target_type = @params[:target_type]
+ target_id = @params[:target_id]
+
+ return @target = nil unless target_type && target_id
+
+ @target =
+ if target_type == "commit"
+ if Ability.allowed?(@current_user, :download_code, @project)
+ @project.commit(target_id)
+ end
else
- notes_of_any_type
+ noteables_for_type(target_type).find(target_id)
end
end
+ private
+
+ def init_collection
+ if target
+ notes_on_target
+ else
+ notes_of_any_type
+ end
+ end
+
def notes_of_any_type
types = %w(commit issue merge_request snippet)
note_relations = types.map { |t| notes_for_type(t) }
- note_relations.map!{ |notes| search(@params[:search], notes) } if @params[:search]
+ note_relations.map! { |notes| search(notes) }
UnionFinder.new.find_union(note_relations, Note)
end
@@ -69,17 +86,11 @@ class NotesFinder
end
end
- def on_target(target_type, target_id)
- if target_type == "commit"
- notes_for_type('commit').for_commit_id(target_id)
+ def notes_on_target
+ if target.respond_to?(:related_notes)
+ target.related_notes
else
- target = noteables_for_type(target_type).find(target_id)
-
- if target.respond_to?(:related_notes)
- target.related_notes
- else
- target.notes
- end
+ target.notes
end
end
@@ -87,17 +98,21 @@ class NotesFinder
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
- def search(query, notes_relation = @notes)
+ def search(notes)
+ query = @params[:search]
+ return notes unless query
+
pattern = "%#{query}%"
- notes_relation.where(Note.arel_table[:note].matches(pattern))
+ notes.where(Note.arel_table[:note].matches(pattern))
end
# Notes changed since last fetch
# Uses overlapping intervals to avoid worrying about race conditions
- def since_fetch_at(fetch_time)
+ def since_fetch_at(notes)
+ return notes unless @params[:last_fetched_at]
+
# Default to 0 to remain compatible with old clients
last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i)
-
- @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh
+ notes.updated_after(last_fetched_at - FETCH_OVERLAP)
end
end
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index 3fc85dc6b2b..b7a28b1b4a7 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -1,6 +1,6 @@
module BranchesHelper
def can_remove_branch?(project, branch_name)
- if project.protected_branch? branch_name
+ if ProtectedBranch.protected?(project, branch_name)
false
elsif branch_name == project.repository.root_ref
false
@@ -29,4 +29,8 @@ module BranchesHelper
def project_branches
options_for_select(@project.repository.branch_names, @project.default_branch)
end
+
+ def protected_branch?(project, branch)
+ ProtectedBranch.protected?(project, branch.name)
+ end
end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index aed1d7c839f..5e0886cc599 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -62,19 +62,19 @@ module DiffHelper
end
def parallel_diff_discussions(left, right, diff_file)
- discussion_left = discussion_right = nil
+ discussions_left = discussions_right = nil
if left && (left.unchanged? || left.removed?)
line_code = diff_file.line_code(left)
- discussion_left = @grouped_diff_discussions[line_code]
+ discussions_left = @grouped_diff_discussions[line_code]
end
if right && right.added?
line_code = diff_file.line_code(right)
- discussion_right = @grouped_diff_discussions[line_code]
+ discussions_right = @grouped_diff_discussions[line_code]
end
- [discussion_left, discussion_right]
+ [discussions_left, discussions_right]
end
def inline_diff_btn
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index b0331f36a2f..5f3d89cf6cb 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -24,57 +24,24 @@ module NotesHelper
end
def diff_view_data
- return {} unless @comments_target
+ return {} unless @new_diff_note_attrs
- @comments_target.slice(:noteable_id, :noteable_type, :commit_id)
+ @new_diff_note_attrs.slice(:noteable_id, :noteable_type, :commit_id)
end
def diff_view_line_data(line_code, position, line_type)
return if @diff_notes_disabled
- use_legacy_diff_note = @use_legacy_diff_notes
- # If the controller doesn't force the use of legacy diff notes, we
- # determine this on a line-by-line basis by seeing if there already exist
- # active legacy diff notes at this line, in which case newly created notes
- # will use the legacy technology as well.
- # We do this because the discussion_id values of legacy and "new" diff
- # notes, which are used to group notes on the merge request discussion tab,
- # are incompatible.
- # If we didn't, diff notes that would show for the same line on the changes
- # tab, would show in different discussions on the discussion tab.
- use_legacy_diff_note ||= begin
- discussion = @grouped_diff_discussions[line_code]
- discussion && discussion.legacy_diff_discussion?
- end
-
data = {
line_code: line_code,
line_type: line_type,
}
- if use_legacy_diff_note
- discussion_id = LegacyDiffNote.discussion_id(
- @comments_target[:noteable_type],
- @comments_target[:noteable_id] || @comments_target[:commit_id],
- line_code
- )
-
- data.merge!(
- note_type: LegacyDiffNote.name,
- discussion_id: discussion_id
- )
+ if @use_legacy_diff_notes
+ data[:note_type] = LegacyDiffNote.name
else
- discussion_id = DiffNote.discussion_id(
- @comments_target[:noteable_type],
- @comments_target[:noteable_id] || @comments_target[:commit_id],
- position
- )
-
- data.merge!(
- position: position.to_json,
- note_type: DiffNote.name,
- discussion_id: discussion_id
- )
+ data[:note_type] = DiffNote.name
+ data[:position] = position.to_json
end
data
@@ -83,21 +50,12 @@ module NotesHelper
def link_to_reply_discussion(discussion, line_type = nil)
return unless current_user
- data = discussion.reply_attributes.merge(line_type: line_type)
+ data = { discussion_id: discussion.id, line_type: line_type }
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
data: data, title: 'Add a reply'
end
- def preload_max_access_for_authors(notes, project)
- user_ids = notes.map(&:author_id)
- project.team.max_member_access_for_user_ids(user_ids)
- end
-
- def preload_noteable_for_regular_notes(notes)
- ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable)
- end
-
def note_max_access_for_user(note)
note.project.team.human_max_access(note.author_id)
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 3074921caff..1ea60e39386 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -7,7 +7,7 @@ module SystemNoteHelper
'closed' => 'icon_status_closed',
'time_tracking' => 'icon_stopwatch',
'assignee' => 'icon_user',
- 'title' => 'icon_pencil',
+ 'title' => 'icon_edit',
'task' => 'icon_check_square_o',
'label' => 'icon_tags',
'cross_reference' => 'icon_random',
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index c0ec1634cdb..31aaf9e5607 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -21,4 +21,8 @@ module TagsHelper
html.html_safe
end
+
+ def protected_tag?(project, tag)
+ ProtectedTag.protected?(project, tag.name)
+ end
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 169cedeb796..b4aaf498068 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -85,7 +85,7 @@ module VisibilityLevelHelper
end
def restricted_visibility_levels(show_all = false)
- return [] if current_user.is_admin? && !show_all
+ return [] if current_user.admin? && !show_all
current_application_settings.restricted_visibility_levels || []
end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 46fa6fd9f6d..00707a0023e 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -4,13 +4,8 @@ module Emails
setup_note_mail(note_id, recipient_id)
@commit = @note.noteable
- @discussion = @note.to_discussion if @note.diff_note?
@target_url = namespace_project_commit_url(*note_target_url_options)
-
- mail_answer_thread(@commit,
- from: sender(@note.author_id),
- to: recipient(recipient_id),
- subject: subject("#{@commit.title} (#{@commit.short_id})"))
+ mail_answer_thread(@commit, note_thread_options(recipient_id))
end
def note_issue_email(recipient_id, note_id)
@@ -25,7 +20,6 @@ module Emails
setup_note_mail(note_id, recipient_id)
@merge_request = @note.noteable
- @discussion = @note.to_discussion if @note.diff_note?
@target_url = namespace_project_merge_request_url(*note_target_url_options)
mail_answer_thread(@merge_request, note_thread_options(recipient_id))
end
@@ -56,15 +50,18 @@ module Emails
{
from: sender(@note.author_id),
to: recipient(recipient_id),
- subject: subject("#{@note.noteable.title} (#{@note.noteable.to_reference})")
+ subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})")
}
end
def setup_note_mail(note_id, recipient_id)
- @note = Note.find(note_id)
+ # `note_id` is a `Note` when originating in `NotifyPreview`
+ @note = note_id.is_a?(Note) ? note_id : Note.find(note_id)
@project = @note.project
- @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key)
+ if @project && @note.persisted?
+ @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key)
+ end
end
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 14df6f8f0a3..f315e38bcaa 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -111,7 +111,7 @@ class Notify < BaseMailer
headers["X-GitLab-#{model.class.name}-ID"] = model.id
headers['X-GitLab-Reply-Key'] = reply_key
- if Gitlab::IncomingEmail.enabled?
+ if Gitlab::IncomingEmail.enabled? && @sent_notification
address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key))
address.display_name = @project.name_with_namespace
@@ -176,6 +176,6 @@ class Notify < BaseMailer
end
headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',')
- @sent_notification_url = unsubscribe_sent_notification_url(@sent_notification)
+ @unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification)
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index c197e50ed69..445247f1b41 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -4,9 +4,14 @@ module Ci
include HasStatus
include Importable
include AfterCommitQueue
+ include Presentable
belongs_to :project
belongs_to :user
+ belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
+
+ has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
+ has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
has_many :builds, foreign_key: :commit_id
@@ -26,7 +31,6 @@ module Ci
validate :valid_commit_sha, unless: :importing?
after_create :keep_around_commits, unless: :importing?
- after_create :refresh_build_status_cache
state_machine :status, initial: :created do
event :enqueue do
@@ -71,6 +75,10 @@ module Ci
pipeline.update_duration
end
+ before_transition canceled: any - [:canceled] do |pipeline|
+ pipeline.auto_canceled_by = nil
+ end
+
after_transition [:created, :pending] => :running do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) }
end
@@ -216,9 +224,24 @@ module Ci
cancelable_statuses.any?
end
+ def auto_canceled?
+ canceled? && auto_canceled_by_id?
+ end
+
def cancel_running
Gitlab::OptimisticLocking.retry_lock(cancelable_statuses) do |cancelable|
- cancelable.find_each(&:cancel)
+ cancelable.find_each do |job|
+ yield(job) if block_given?
+ job.cancel
+ end
+ end
+ end
+
+ def auto_cancel_running(pipeline)
+ update(auto_canceled_by: pipeline)
+
+ cancel_running do |job|
+ job.auto_canceled_by = pipeline
end
end
@@ -327,7 +350,6 @@ module Ci
when 'manual' then block
end
end
- refresh_build_status_cache
end
def predefined_variables
@@ -369,10 +391,6 @@ module Ci
.fabricate!
end
- def refresh_build_status_cache
- Ci::PipelineStatus.new(project, sha: sha, status: status).store_in_cache_if_needed
- end
-
private
def pipeline_data
diff --git a/app/models/ci/pipeline_status.rb b/app/models/ci/pipeline_status.rb
deleted file mode 100644
index 048047d0e34..00000000000
--- a/app/models/ci/pipeline_status.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-# This class is not backed by a table in the main database.
-# It loads the latest Pipeline for the HEAD of a repository, and caches that
-# in Redis.
-module Ci
- class PipelineStatus
- attr_accessor :sha, :status, :project, :loaded
-
- delegate :commit, to: :project
-
- def self.load_for_project(project)
- new(project).tap do |status|
- status.load_status
- end
- end
-
- def initialize(project, sha: nil, status: nil)
- @project = project
- @sha = sha
- @status = status
- end
-
- def has_status?
- loaded? && sha.present? && status.present?
- end
-
- def load_status
- return if loaded?
-
- if has_cache?
- load_from_cache
- else
- load_from_commit
- store_in_cache
- end
-
- self.loaded = true
- end
-
- def load_from_commit
- return unless commit
-
- self.sha = commit.sha
- self.status = commit.status
- end
-
- # We only cache the status for the HEAD commit of a project
- # This status is rendered in project lists
- def store_in_cache_if_needed
- return unless sha
- return delete_from_cache unless commit
- store_in_cache if commit.sha == self.sha
- end
-
- def load_from_cache
- Gitlab::Redis.with do |redis|
- self.sha, self.status = redis.hmget(cache_key, :sha, :status)
- end
- end
-
- def store_in_cache
- Gitlab::Redis.with do |redis|
- redis.mapped_hmset(cache_key, { sha: sha, status: status })
- end
- end
-
- def delete_from_cache
- Gitlab::Redis.with do |redis|
- redis.del(cache_key)
- end
- end
-
- def has_cache?
- Gitlab::Redis.with do |redis|
- redis.exists(cache_key)
- end
- end
-
- def loaded?
- self.loaded
- end
-
- def cache_key
- "projects/#{project.id}/build_status"
- end
- end
-end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 0a89f3e0640..b59e235c425 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -14,6 +14,8 @@ module Ci
before_validation :set_default_values
+ accepts_nested_attributes_for :trigger_schedule
+
def set_default_values
self.token = SecureRandom.hex(15) if self.token.blank?
end
@@ -37,5 +39,9 @@ module Ci
def can_access_project?
self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project)
end
+
+ def trigger_schedule
+ super || build_trigger_schedule(project: project)
+ end
end
end
diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/trigger_schedule.rb
index 10ea381ee31..012a18eb439 100644
--- a/app/models/ci/trigger_schedule.rb
+++ b/app/models/ci/trigger_schedule.rb
@@ -8,15 +8,19 @@ module Ci
belongs_to :project
belongs_to :trigger
- delegate :ref, to: :trigger
-
validates :trigger, presence: { unless: :importing? }
- validates :cron, cron: true, presence: { unless: :importing? }
- validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
- validates :ref, presence: { unless: :importing? }
+ validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? }
+ validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? }
+ validates :ref, presence: { unless: :importing_or_inactive? }
before_save :set_next_run_at
+ scope :active, -> { where(active: true) }
+
+ def importing_or_inactive?
+ importing? || !active?
+ end
+
def set_next_run_at
self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
end
@@ -26,5 +30,12 @@ module Ci
rescue ActiveRecord::RecordInvalid
update_attribute(:next_run_at, nil) # update without validation
end
+
+ def real_next_run(
+ worker_cron: Settings.cron_jobs['trigger_schedule_worker']['cron'],
+ worker_time_zone: Time.zone.name)
+ Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone)
+ .next_time_from(next_run_at)
+ end
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index ce92cc369ad..5c452f78546 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -2,6 +2,7 @@ class Commit
extend ActiveModel::Naming
include ActiveModel::Conversion
+ include Noteable
include Participable
include Mentionable
include Referable
@@ -203,6 +204,10 @@ class Commit
project.notes.for_commit_id(self.id)
end
+ def discussion_notes
+ notes.non_diff_notes
+ end
+
def notes_with_associations
notes.includes(:author)
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 17b322b5ae3..2c4033146bf 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -7,6 +7,7 @@ class CommitStatus < ActiveRecord::Base
belongs_to :project
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
+ belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :user
delegate :commit, to: :pipeline
@@ -137,6 +138,10 @@ class CommitStatus < ActiveRecord::Base
false
end
+ def auto_canceled?
+ canceled? && auto_canceled_by_id?
+ end
+
# Added in 9.0 to keep backward compatibility for projects exported in 8.17
# and prior.
def gl_project_id
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
new file mode 100644
index 00000000000..87db0c810c3
--- /dev/null
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -0,0 +1,57 @@
+# Contains functionality shared between `DiffDiscussion` and `LegacyDiffDiscussion`.
+module DiscussionOnDiff
+ extend ActiveSupport::Concern
+
+ included do
+ NUMBER_OF_TRUNCATED_DIFF_LINES = 16
+
+ memoized_values << :active
+
+ delegate :line_code,
+ :original_line_code,
+ :diff_file,
+ :diff_line,
+ :for_line?,
+ :active?,
+
+ to: :first_note
+
+ delegate :file_path,
+ :blob,
+ :highlighted_diff_lines,
+ :diff_lines,
+
+ to: :diff_file,
+ allow_nil: true
+ end
+
+ def diff_discussion?
+ true
+ end
+
+ def active?
+ return @active if @active.present?
+
+ @active = first_note.active?
+ end
+
+ # Returns an array of at most 16 highlighted lines above a diff note
+ def truncated_diff_lines(highlight: true)
+ lines = highlight ? highlighted_diff_lines : diff_lines
+ prev_lines = []
+
+ lines.each do |line|
+ if line.meta?
+ prev_lines.clear
+ else
+ prev_lines << line
+
+ break if for_line?(line)
+
+ prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES
+ end
+ end
+
+ prev_lines
+ end
+end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index 0a1a65da05a..dff7b6e3523 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -68,7 +68,7 @@ module HasStatus
end
scope :created, -> { where(status: 'created') }
- scope :relevant, -> { where.not(status: 'created') }
+ scope :relevant, -> { where(status: AVAILABLE_STATUSES - ['created']) }
scope :running, -> { where(status: 'running') }
scope :pending, -> { where(status: 'pending') }
scope :success, -> { where(status: 'success') }
@@ -76,6 +76,7 @@ module HasStatus
scope :canceled, -> { where(status: 'canceled') }
scope :skipped, -> { where(status: 'skipped') }
scope :manual, -> { where(status: 'manual') }
+ scope :created_or_pending, -> { where(status: [:created, :pending]) }
scope :running_or_pending, -> { where(status: [:running, :pending]) }
scope :finished, -> { where(status: [:success, :failed, :canceled]) }
scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) }
diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb
new file mode 100644
index 00000000000..eb9f3423e48
--- /dev/null
+++ b/app/models/concerns/ignorable_column.rb
@@ -0,0 +1,28 @@
+# Module that can be included into a model to make it easier to ignore database
+# columns.
+#
+# Example:
+#
+# class User < ActiveRecord::Base
+# include IgnorableColumn
+#
+# ignore_column :updated_at
+# end
+#
+module IgnorableColumn
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def columns
+ super.reject { |column| ignored_columns.include?(column.name) }
+ end
+
+ def ignored_columns
+ @ignored_columns ||= Set.new
+ end
+
+ def ignore_column(name)
+ ignored_columns << name.to_s
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index b4dded7e27e..3d2258d5e3e 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -292,17 +292,6 @@ 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/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index b8dd27a7afe..1a5a7007a2b 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -1,3 +1,4 @@
+# Contains functionality shared between `DiffNote` and `LegacyDiffNote`.
module NoteOnDiff
extend ActiveSupport::Concern
@@ -24,12 +25,4 @@ module NoteOnDiff
def diff_attributes
raise NotImplementedError
end
-
- def can_be_award_emoji?
- false
- end
-
- def to_discussion
- Discussion.new([self])
- end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
new file mode 100644
index 00000000000..772ff6a6d2f
--- /dev/null
+++ b/app/models/concerns/noteable.rb
@@ -0,0 +1,68 @@
+module Noteable
+ # Names of all implementers of `Noteable` that support resolvable notes.
+ RESOLVABLE_TYPES = %w(MergeRequest).freeze
+
+ def base_class_name
+ self.class.base_class.name
+ end
+
+ # Convert this Noteable class name to a format usable by notifications.
+ #
+ # Examples:
+ #
+ # noteable.class # => MergeRequest
+ # noteable.human_class_name # => "merge request"
+ def human_class_name
+ @human_class_name ||= base_class_name.titleize.downcase
+ end
+
+ def supports_resolvable_notes?
+ RESOLVABLE_TYPES.include?(base_class_name)
+ end
+
+ def supports_discussions?
+ DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
+ end
+
+ def discussion_notes
+ notes
+ end
+
+ delegate :find_discussion, to: :discussion_notes
+
+ def discussions
+ @discussions ||= discussion_notes
+ .inc_relations_for_view
+ .discussions(self)
+ end
+
+ def grouped_diff_discussions
+ # Doesn't use `discussion_notes`, because this may include commit diff notes
+ # besides MR diff notes, that we do no want to display on the MR Changes tab.
+ notes.inc_relations_for_view.grouped_diff_discussions
+ end
+
+ def resolvable_discussions
+ @resolvable_discussions ||= discussion_notes.resolvable.discussions(self)
+ end
+
+ def discussions_resolvable?
+ resolvable_discussions.any?(&:resolvable?)
+ end
+
+ def discussions_resolved?
+ discussions_resolvable? && resolvable_discussions.none?(&:to_be_resolved?)
+ end
+
+ def discussions_to_be_resolved?
+ discussions_resolvable? && !discussions_resolved?
+ end
+
+ def discussions_to_be_resolved
+ @discussions_to_be_resolved ||= resolvable_discussions.select(&:to_be_resolved?)
+ end
+
+ def discussions_can_be_resolved_by?(user)
+ discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) }
+ end
+end
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
index 9dd4d9c6f24..c41b807df8a 100644
--- a/app/models/concerns/protected_branch_access.rb
+++ b/app/models/concerns/protected_branch_access.rb
@@ -2,20 +2,10 @@ module ProtectedBranchAccess
extend ActiveSupport::Concern
included do
- belongs_to :protected_branch
- delegate :project, to: :protected_branch
-
- scope :master, -> { where(access_level: Gitlab::Access::MASTER) }
- scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) }
- end
+ include ProtectedRefAccess
- def humanize
- self.class.human_access_levels[self.access_level]
- end
-
- def check_access(user)
- return true if user.is_admin?
+ belongs_to :protected_branch
- project.team.max_member_access(user.id) >= access_level
+ delegate :project, to: :protected_branch
end
end
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
new file mode 100644
index 00000000000..62eaec2407f
--- /dev/null
+++ b/app/models/concerns/protected_ref.rb
@@ -0,0 +1,42 @@
+module ProtectedRef
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :project
+
+ validates :name, presence: true
+ validates :project, presence: true
+
+ delegate :matching, :matches?, :wildcard?, to: :ref_matcher
+
+ def self.protected_ref_accessible_to?(ref, user, action:)
+ access_levels_for_ref(ref, action: action).any? do |access_level|
+ access_level.check_access(user)
+ end
+ end
+
+ def self.developers_can?(action, ref)
+ access_levels_for_ref(ref, action: action).any? do |access_level|
+ access_level.access_level == Gitlab::Access::DEVELOPER
+ end
+ end
+
+ def self.access_levels_for_ref(ref, action:)
+ self.matching(ref).map(&:"#{action}_access_levels").flatten
+ end
+
+ def self.matching(ref_name, protected_refs: nil)
+ ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs)
+ end
+ end
+
+ def commit
+ project.commit(self.name)
+ end
+
+ private
+
+ def ref_matcher
+ @ref_matcher ||= ProtectedRefMatcher.new(self)
+ end
+end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
new file mode 100644
index 00000000000..c4f158e569a
--- /dev/null
+++ b/app/models/concerns/protected_ref_access.rb
@@ -0,0 +1,18 @@
+module ProtectedRefAccess
+ extend ActiveSupport::Concern
+
+ included do
+ scope :master, -> { where(access_level: Gitlab::Access::MASTER) }
+ scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) }
+ end
+
+ def humanize
+ self.class.human_access_levels[self.access_level]
+ end
+
+ def check_access(user)
+ return true if user.admin?
+
+ project.team.max_member_access(user.id) >= access_level
+ end
+end
diff --git a/app/models/concerns/protected_tag_access.rb b/app/models/concerns/protected_tag_access.rb
new file mode 100644
index 00000000000..ee65de24dd8
--- /dev/null
+++ b/app/models/concerns/protected_tag_access.rb
@@ -0,0 +1,11 @@
+module ProtectedTagAccess
+ extend ActiveSupport::Concern
+
+ included do
+ include ProtectedRefAccess
+
+ belongs_to :protected_tag
+
+ delegate :project, to: :protected_tag
+ end
+end
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
new file mode 100644
index 00000000000..dd979e7bb17
--- /dev/null
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -0,0 +1,103 @@
+module ResolvableDiscussion
+ extend ActiveSupport::Concern
+
+ included do
+ # A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized.
+ # When this discussion is resolved or unresolved, the values of these properties potentially change.
+ # To make sure all memoized values are reset when this happens, `update` resets all instance variables with names in
+ # `memoized_variables`. If you add a memoized method in `ResolvableDiscussion` or any `Discussion` subclass,
+ # please make sure the instance variable name is added to `memoized_values`, like below.
+ cattr_accessor :memoized_values, instance_accessor: false do
+ []
+ end
+
+ memoized_values.push(
+ :resolvable,
+ :resolved,
+ :first_note,
+ :first_note_to_resolve,
+ :last_resolved_note,
+ :last_note
+ )
+
+ delegate :potentially_resolvable?, to: :first_note
+
+ delegate :resolved_at,
+ :resolved_by,
+
+ to: :last_resolved_note,
+ allow_nil: true
+ end
+
+ def resolvable?
+ return @resolvable if @resolvable.present?
+
+ @resolvable = potentially_resolvable? && notes.any?(&:resolvable?)
+ end
+
+ def resolved?
+ return @resolved if @resolved.present?
+
+ @resolved = resolvable? && notes.none?(&:to_be_resolved?)
+ end
+
+ def first_note
+ @first_note ||= notes.first
+ end
+
+ def first_note_to_resolve
+ return unless resolvable?
+
+ @first_note_to_resolve ||= notes.find(&:to_be_resolved?)
+ end
+
+ def last_resolved_note
+ return unless resolved?
+
+ @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
+ end
+
+ def resolved_notes
+ notes.select(&:resolved?)
+ end
+
+ def to_be_resolved?
+ resolvable? && !resolved?
+ end
+
+ def can_resolve?(current_user)
+ return false unless current_user
+ return false unless resolvable?
+
+ current_user == self.noteable.author ||
+ current_user.can?(:resolve_note, self.project)
+ end
+
+ def resolve!(current_user)
+ return unless resolvable?
+
+ update { |notes| notes.resolve!(current_user) }
+ end
+
+ def unresolve!
+ return unless resolvable?
+
+ update { |notes| notes.unresolve! }
+ end
+
+ private
+
+ def update
+ # Do not select `Note.resolvable`, so that system notes remain in the collection
+ notes_relation = Note.where(id: notes.map(&:id))
+
+ yield(notes_relation)
+
+ # Set the notes array to the updated notes
+ @notes = notes_relation.fresh.to_a
+
+ self.class.memoized_values.each do |var|
+ instance_variable_set(:"@#{var}", nil)
+ end
+ end
+end
diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb
new file mode 100644
index 00000000000..05eb6f86704
--- /dev/null
+++ b/app/models/concerns/resolvable_note.rb
@@ -0,0 +1,72 @@
+module ResolvableNote
+ extend ActiveSupport::Concern
+
+ # Names of all subclasses of `Note` that can be resolvable.
+ RESOLVABLE_TYPES = %w(DiffNote DiscussionNote).freeze
+
+ included do
+ belongs_to :resolved_by, class_name: "User"
+
+ validates :resolved_by, presence: true, if: :resolved?
+
+ # Keep this scope in sync with `#potentially_resolvable?`
+ scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) }
+ # Keep this scope in sync with `#resolvable?`
+ scope :resolvable, -> { potentially_resolvable.user }
+
+ scope :resolved, -> { resolvable.where.not(resolved_at: nil) }
+ scope :unresolved, -> { resolvable.where(resolved_at: nil) }
+ end
+
+ module ClassMethods
+ # This method must be kept in sync with `#resolve!`
+ def resolve!(current_user)
+ unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id)
+ end
+
+ # This method must be kept in sync with `#unresolve!`
+ def unresolve!
+ resolved.update_all(resolved_at: nil, resolved_by_id: nil)
+ end
+ end
+
+ # Keep this method in sync with the `potentially_resolvable` scope
+ def potentially_resolvable?
+ RESOLVABLE_TYPES.include?(self.class.name) && noteable.supports_resolvable_notes?
+ end
+
+ # Keep this method in sync with the `resolvable` scope
+ def resolvable?
+ potentially_resolvable? && !system?
+ end
+
+ def resolved?
+ return false unless resolvable?
+
+ self.resolved_at.present?
+ end
+
+ def to_be_resolved?
+ resolvable? && !resolved?
+ end
+
+ # If you update this method remember to also update `.resolve!`
+ def resolve!(current_user)
+ return unless resolvable?
+ return if resolved?
+
+ self.resolved_at = Time.now
+ self.resolved_by = current_user
+ save!
+ end
+
+ # If you update this method remember to also update `.unresolve!`
+ def unresolve!
+ return unless resolvable?
+ return unless resolved?
+
+ self.resolved_at = nil
+ self.resolved_by = nil
+ save!
+ end
+end
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
new file mode 100644
index 00000000000..d9b7e484e0f
--- /dev/null
+++ b/app/models/diff_discussion.rb
@@ -0,0 +1,26 @@
+# A discussion on merge request or commit diffs consisting of `DiffNote` notes.
+#
+# A discussion of this type can be resolvable.
+class DiffDiscussion < Discussion
+ include DiscussionOnDiff
+
+ def self.note_class
+ DiffNote
+ end
+
+ delegate :position,
+ :original_position,
+
+ to: :first_note
+
+ def legacy_diff_discussion?
+ false
+ end
+
+ def reply_attributes
+ super.merge(
+ original_position: original_position.to_json,
+ position: position.to_json,
+ )
+ end
+end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 895a91139c9..1523244f8a8 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -1,6 +1,11 @@
+# A note on merge request or commit diffs
+#
+# A note of this type can be resolvable.
class DiffNote < Note
include NoteOnDiff
+ NOTEABLE_TYPES = %w(MergeRequest Commit).freeze
+
serialize :original_position, Gitlab::Diff::Position
serialize :position, Gitlab::Diff::Position
@@ -8,59 +13,31 @@ class DiffNote < Note
validates :position, presence: true
validates :diff_line, presence: true
validates :line_code, presence: true, line_code: true
- validates :noteable_type, inclusion: { in: %w(Commit MergeRequest) }
- validates :resolved_by, presence: true, if: :resolved?
+ validates :noteable_type, inclusion: { in: NOTEABLE_TYPES }
validate :positions_complete
validate :verify_supported
- # Keep this scope in sync with the logic in `#resolvable?`
- scope :resolvable, -> { user.where(noteable_type: 'MergeRequest') }
- scope :resolved, -> { resolvable.where.not(resolved_at: nil) }
- scope :unresolved, -> { resolvable.where(resolved_at: nil) }
-
- after_initialize :ensure_original_discussion_id
before_validation :set_original_position, :update_position, on: :create
- before_validation :set_line_code, :set_original_discussion_id
- # We need to do this again, because it's already in `Note`, but is affected by
- # `update_position` and needs to run after that.
- before_validation :set_discussion_id
+ before_validation :set_line_code
after_save :keep_around_commits
- class << self
- def build_discussion_id(noteable_type, noteable_id, position)
- [super(noteable_type, noteable_id), *position.key].join("-")
- end
-
- # This method must be kept in sync with `#resolve!`
- def resolve!(current_user)
- unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id)
- end
-
- # This method must be kept in sync with `#unresolve!`
- def unresolve!
- resolved.update_all(resolved_at: nil, resolved_by_id: nil)
- end
- end
-
- def new_diff_note?
- true
+ def discussion_class(*)
+ DiffDiscussion
end
- def diff_attributes
- { position: position.to_json }
- end
+ %i(original_position position).each do |meth|
+ define_method "#{meth}=" do |new_position|
+ if new_position.is_a?(String)
+ new_position = JSON.parse(new_position) rescue nil
+ end
- def position=(new_position)
- if new_position.is_a?(String)
- new_position = JSON.parse(new_position) rescue nil
- end
+ if new_position.is_a?(Hash)
+ new_position = new_position.with_indifferent_access
+ new_position = Gitlab::Diff::Position.new(new_position)
+ end
- if new_position.is_a?(Hash)
- new_position = new_position.with_indifferent_access
- new_position = Gitlab::Diff::Position.new(new_position)
+ super(new_position)
end
-
- super(new_position)
end
def diff_file
@@ -88,43 +65,6 @@ class DiffNote < Note
self.position.diff_refs == diff_refs
end
- # If you update this method remember to also update the scope `resolvable`
- def resolvable?
- !system? && for_merge_request?
- end
-
- def resolved?
- return false unless resolvable?
-
- self.resolved_at.present?
- end
-
- # If you update this method remember to also update `.resolve!`
- def resolve!(current_user)
- return unless resolvable?
- return if resolved?
-
- self.resolved_at = Time.now
- self.resolved_by = current_user
- save!
- end
-
- # If you update this method remember to also update `.unresolve!`
- def unresolve!
- return unless resolvable?
- return unless resolved?
-
- self.resolved_at = nil
- self.resolved_by = nil
- save!
- end
-
- def discussion
- return unless resolvable?
-
- self.noteable.find_diff_discussion(self.discussion_id)
- end
-
private
def supported?
@@ -140,33 +80,13 @@ class DiffNote < Note
end
def set_original_position
- self.original_position = self.position.dup
+ self.original_position = self.position.dup unless self.original_position&.complete?
end
def set_line_code
self.line_code = self.position.line_code(self.project.repository)
end
- def ensure_original_discussion_id
- return unless self.persisted?
- return if self.original_discussion_id
-
- set_original_discussion_id
- update_column(:original_discussion_id, self.original_discussion_id)
- end
-
- def set_original_discussion_id
- self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id)
- end
-
- def build_discussion_id
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
- end
-
- def build_original_discussion_id
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
- end
-
def update_position
return unless supported?
return if for_commit?
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index bbe813db823..0b6b920ed66 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -1,7 +1,10 @@
+# A non-diff discussion on an issue, merge request, commit, or snippet, consisting of `DiscussionNote` notes.
+#
+# A discussion of this type can be resolvable.
class Discussion
- NUMBER_OF_TRUNCATED_DIFF_LINES = 16
+ include ResolvableDiscussion
- attr_reader :notes
+ attr_reader :notes, :context_noteable
delegate :created_at,
:project,
@@ -11,43 +14,62 @@ class Discussion
:for_commit?,
:for_merge_request?,
- :line_code,
- :original_line_code,
- :diff_file,
- :for_line?,
- :active?,
-
to: :first_note
- delegate :resolved_at,
- :resolved_by,
+ def self.build(notes, context_noteable = nil)
+ notes.first.discussion_class(context_noteable).new(notes, context_noteable)
+ end
- to: :last_resolved_note,
- allow_nil: true
+ def self.build_collection(notes, context_noteable = nil)
+ notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) }
+ end
- delegate :blob,
- :highlighted_diff_lines,
- :diff_lines,
+ # Returns an alphanumeric discussion ID based on `build_discussion_id`
+ def self.discussion_id(note)
+ Digest::SHA1.hexdigest(build_discussion_id(note).join("-"))
+ end
- to: :diff_file,
- allow_nil: true
+ # Returns an array of discussion ID components
+ def self.build_discussion_id(note)
+ [*base_discussion_id(note), SecureRandom.hex]
+ end
- def self.for_notes(notes)
- notes.group_by(&:discussion_id).values.map { |notes| new(notes) }
+ def self.base_discussion_id(note)
+ noteable_id = note.noteable_id || note.commit_id
+ [:discussion, note.noteable_type.try(:underscore), noteable_id]
end
- def self.for_diff_notes(notes)
- notes.group_by(&:line_code).values.map { |notes| new(notes) }
+ # When notes on a commit are displayed in context of a merge request that contains that commit,
+ # these notes are to be displayed as if they were part of one discussion, even though they were actually
+ # individual notes on the commit with different discussion IDs, so that it's clear that these are not
+ # notes on the merge request itself.
+ #
+ # To turn a list of notes into a list of discussions, they are grouped by discussion ID, so to
+ # get these out-of-context notes to end up in the same discussion, we need to get them to return the same
+ # `discussion_id` when this grouping happens. To enable this, `Note#discussion_id` calls out
+ # to the `override_discussion_id` method on the appropriate `Discussion` subclass, as determined by
+ # the `discussion_class` method on `Note` or a subclass of `Note`.
+ #
+ # If no override is necessary, return `nil`.
+ # For the case described above, see `OutOfContextDiscussion.override_discussion_id`.
+ def self.override_discussion_id(note)
+ nil
end
- def initialize(notes)
- @notes = notes
+ def self.note_class
+ DiscussionNote
end
- def last_resolved_note
- return unless resolved?
+ def initialize(notes, context_noteable = nil)
+ @notes = notes
+ @context_noteable = context_noteable
+ end
- @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
+ def ==(other)
+ other.class == self.class &&
+ other.context_noteable == self.context_noteable &&
+ other.id == self.id &&
+ other.notes == self.notes
end
def last_updated_at
@@ -59,91 +81,29 @@ class Discussion
end
def id
- first_note.discussion_id
+ first_note.discussion_id(context_noteable)
end
alias_method :to_param, :id
def diff_discussion?
- first_note.diff_note?
- end
-
- def legacy_diff_discussion?
- notes.any?(&:legacy_diff_note?)
+ false
end
- def resolvable?
- return @resolvable if @resolvable.present?
-
- @resolvable = diff_discussion? && notes.any?(&:resolvable?)
+ def individual_note?
+ false
end
- def resolved?
- return @resolved if @resolved.present?
-
- @resolved = resolvable? && notes.none?(&:to_be_resolved?)
- end
-
- def first_note
- @first_note ||= @notes.first
- end
-
- def first_note_to_resolve
- @first_note_to_resolve ||= notes.detect(&:to_be_resolved?)
+ def new_discussion?
+ notes.length == 1
end
def last_note
- @last_note ||= @notes.last
- end
-
- def resolved_notes
- notes.select(&:resolved?)
- end
-
- def to_be_resolved?
- resolvable? && !resolved?
- end
-
- def can_resolve?(current_user)
- return false unless current_user
- return false unless resolvable?
-
- current_user == self.noteable.author ||
- current_user.can?(:resolve_note, self.project)
- end
-
- def resolve!(current_user)
- return unless resolvable?
-
- update { |notes| notes.resolve!(current_user) }
- end
-
- def unresolve!
- return unless resolvable?
-
- update { |notes| notes.unresolve! }
- end
-
- def for_target?(target)
- self.noteable == target && !diff_discussion?
- end
-
- def active?
- return @active if @active.present?
-
- @active = first_note.active?
+ @last_note ||= notes.last
end
def collapsed?
- return false unless diff_discussion?
-
- if resolvable?
- # New diff discussions only disappear once they are marked resolved
- resolved?
- else
- # Old diff discussions disappear once they become outdated
- !active?
- end
+ resolved?
end
def expanded?
@@ -151,52 +111,6 @@ class Discussion
end
def reply_attributes
- data = {
- noteable_type: first_note.noteable_type,
- noteable_id: first_note.noteable_id,
- commit_id: first_note.commit_id,
- discussion_id: self.id,
- }
-
- if diff_discussion?
- data[:note_type] = first_note.type
-
- data.merge!(first_note.diff_attributes)
- end
-
- data
- end
-
- # Returns an array of at most 16 highlighted lines above a diff note
- def truncated_diff_lines(highlight: true)
- lines = highlight ? highlighted_diff_lines : diff_lines
- prev_lines = []
-
- lines.each do |line|
- if line.meta?
- prev_lines.clear
- else
- prev_lines << line
-
- break if for_line?(line)
-
- prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES
- end
- end
-
- prev_lines
- end
-
- private
-
- def update
- notes_relation = DiffNote.where(id: notes.map(&:id)).fresh
- yield(notes_relation)
-
- # Set the notes array to the updated notes
- @notes = notes_relation.to_a
-
- # Reset the memoized values
- @last_resolved_note = @resolvable = @resolved = @first_note = @last_note = nil
+ first_note.slice(:type, :noteable_type, :noteable_id, :commit_id, :discussion_id)
end
end
diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb
new file mode 100644
index 00000000000..e660b024083
--- /dev/null
+++ b/app/models/discussion_note.rb
@@ -0,0 +1,13 @@
+# A note in a non-diff discussion on an issue, merge request, commit, or snippet.
+#
+# A note of this type can be resolvable.
+class DiscussionNote < Note
+ # Names of all implementers of `Noteable` that support discussions.
+ NOTEABLE_TYPES = %w(MergeRequest Issue Commit Snippet).freeze
+
+ validates :noteable_type, inclusion: { in: NOTEABLE_TYPES }
+
+ def discussion_class(*)
+ Discussion
+ end
+end
diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb
new file mode 100644
index 00000000000..c3f21c55240
--- /dev/null
+++ b/app/models/individual_note_discussion.rb
@@ -0,0 +1,13 @@
+# A discussion to wrap a single `Note` note on the root of an issue, merge request,
+# commit, or snippet, that is not displayed as a discussion.
+#
+# A discussion of this type is never resolvable.
+class IndividualNoteDiscussion < Discussion
+ def self.note_class
+ Note
+ end
+
+ def individual_note?
+ true
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index f9704b0d754..d8d9db477d2 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord'
class Issue < ActiveRecord::Base
include InternalId
include Issuable
+ include Noteable
include Referable
include Sortable
include Spammable
diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb
new file mode 100644
index 00000000000..cb2651a03f8
--- /dev/null
+++ b/app/models/legacy_diff_discussion.rb
@@ -0,0 +1,25 @@
+# A discussion on merge request or commit diffs consisting of `LegacyDiffNote` notes.
+#
+# All new diff discussions are of the type `DiffDiscussion`, but any diff discussions created
+# before the introduction of the new implementation still use `LegacyDiffDiscussion`.
+#
+# A discussion of this type is never resolvable.
+class LegacyDiffDiscussion < Discussion
+ include DiscussionOnDiff
+
+ def legacy_diff_discussion?
+ true
+ end
+
+ def self.note_class
+ LegacyDiffNote
+ end
+
+ def collapsed?
+ !active?
+ end
+
+ def reply_attributes
+ super.merge(line_code: line_code)
+ end
+end
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index 40277a9b139..9a77557ebcd 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -1,3 +1,9 @@
+# A note on merge request or commit diffs, using the legacy implementation.
+#
+# All new diff notes are of the type `DiffNote`, but any diff notes created
+# before the introduction of the new implementation still use `LegacyDiffNote`.
+#
+# A note of this type is never resolvable.
class LegacyDiffNote < Note
include NoteOnDiff
@@ -7,18 +13,8 @@ class LegacyDiffNote < Note
before_create :set_diff
- class << self
- def build_discussion_id(noteable_type, noteable_id, line_code)
- [super(noteable_type, noteable_id), line_code].join("-")
- end
- end
-
- def legacy_diff_note?
- true
- end
-
- def diff_attributes
- { line_code: line_code }
+ def discussion_class(*)
+ LegacyDiffDiscussion
end
def project_repository
@@ -119,8 +115,4 @@ class LegacyDiffNote < Note
diffs = noteable.raw_diffs(Commit.max_diff_options)
diffs.find { |d| d.new_path == self.diff.new_path }
end
-
- def build_discussion_id
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
- end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 8d740adb771..b71a9e17a93 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1,6 +1,7 @@
class MergeRequest < ActiveRecord::Base
include InternalId
include Issuable
+ include Noteable
include Referable
include Sortable
@@ -442,7 +443,7 @@ class MergeRequest < ActiveRecord::Base
end
def can_remove_source_branch?(current_user)
- !source_project.protected_branch?(source_branch) &&
+ !ProtectedBranch.protected?(source_project, source_branch) &&
!source_project.root_ref?(source_branch) &&
Ability.allowed?(current_user, :push_code, source_project) &&
diff_head_commit == source_branch_head
@@ -475,43 +476,7 @@ class MergeRequest < ActiveRecord::Base
)
end
- def discussions
- @discussions ||= self.related_notes.
- inc_relations_for_view.
- fresh.
- discussions
- end
-
- def diff_discussions
- @diff_discussions ||= self.notes.diff_notes.discussions
- end
-
- def resolvable_discussions
- @resolvable_discussions ||= diff_discussions.select(&:to_be_resolved?)
- end
-
- def discussions_can_be_resolved_by?(user)
- resolvable_discussions.all? { |discussion| discussion.can_resolve?(user) }
- end
-
- def find_diff_discussion(discussion_id)
- notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a
- return if notes.empty?
-
- Discussion.new(notes)
- end
-
- def discussions_resolvable?
- diff_discussions.any?(&:resolvable?)
- end
-
- def discussions_resolved?
- discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?)
- end
-
- def discussions_to_be_resolved?
- discussions_resolvable? && !discussions_resolved?
- end
+ alias_method :discussion_notes, :related_notes
def mergeable_discussions_state?
return true unless project.only_allow_merge_if_all_discussions_are_resolved?
@@ -857,8 +822,8 @@ class MergeRequest < ActiveRecord::Base
return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
- active_diff_notes = self.notes.diff_notes.select do |note|
- note.new_diff_note? && note.active?(old_diff_refs)
+ active_diff_notes = self.notes.new_diff_notes.select do |note|
+ note.active?(old_diff_refs)
end
return if active_diff_notes.empty?
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index ac205b9b738..652b1551928 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -153,10 +153,6 @@ class Milestone < ActiveRecord::Base
active? && issues.opened.count.zero?
end
- def is_empty?(user = nil)
- total_items_count(user).zero?
- end
-
def author_id
nil
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 16d66cb1427..1ea7b946061 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -1,3 +1,6 @@
+# A note on the root of an issue, merge request, commit, or snippet.
+#
+# A note of this type is never resolvable.
class Note < ActiveRecord::Base
extend ActiveModel::Naming
include Gitlab::CurrentSettings
@@ -8,6 +11,10 @@ class Note < ActiveRecord::Base
include FasterCacheKeys
include CacheMarkdownField
include AfterCommitQueue
+ include ResolvableNote
+ include IgnorableColumn
+
+ ignore_column :original_discussion_id
cache_markdown_field :note, pipeline: :note
@@ -32,9 +39,6 @@ class Note < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
- # Only used by DiffNote, but defined here so that it can be used in `Note.includes`
- belongs_to :resolved_by, class_name: "User"
-
has_many :todos, dependent: :destroy
has_many :events, as: :target, dependent: :destroy
has_one :system_note_metadata
@@ -54,10 +58,11 @@ class Note < ActiveRecord::Base
validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
validates :commit_id, presence: true, if: :for_commit?
validates :author, presence: true
+ validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note|
unless note.noteable.try(:project) == note.project
- errors.add(:invalid_project, 'Note and noteable project mismatch')
+ errors.add(:project, 'does not match noteable project')
end
end
@@ -69,6 +74,7 @@ class Note < ActiveRecord::Base
scope :user, ->{ where(system: false) }
scope :common, ->{ where(noteable_type: ["", nil]) }
scope :fresh, ->{ order(created_at: :asc, id: :asc) }
+ scope :updated_after, ->(time){ where('updated_at > ?', time) }
scope :inc_author_project, ->{ includes(:project, :author) }
scope :inc_author, ->{ includes(:author) }
scope :inc_relations_for_view, -> do
@@ -76,7 +82,8 @@ class Note < ActiveRecord::Base
end
scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) }
- scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
+ scope :new_diff_notes, ->{ where(type: 'DiffNote') }
+ scope :non_diff_notes, ->{ where(type: ['Note', 'DiscussionNote', nil]) }
scope :with_associations, -> do
# FYI noteable cannot be loaded for LegacyDiffNote for commits
@@ -86,7 +93,7 @@ class Note < ActiveRecord::Base
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
- before_validation :set_discussion_id
+ before_validation :set_discussion_id, on: :create
after_save :keep_around_commit, unless: :for_personal_snippet?
after_save :expire_etag_cache
@@ -95,22 +102,23 @@ class Note < ActiveRecord::Base
ActiveModel::Name.new(self, nil, 'note')
end
- def build_discussion_id(noteable_type, noteable_id)
- [:discussion, noteable_type.try(:underscore), noteable_id].join("-")
+ def discussions(context_noteable = nil)
+ Discussion.build_collection(fresh, context_noteable)
end
- def discussion_id(*args)
- Digest::SHA1.hexdigest(build_discussion_id(*args))
- end
+ def find_discussion(discussion_id)
+ notes = where(discussion_id: discussion_id).fresh.to_a
+ return if notes.empty?
- def discussions
- Discussion.for_notes(fresh)
+ Discussion.build(notes)
end
def grouped_diff_discussions
- active_notes = diff_notes.fresh.select(&:active?)
- Discussion.for_diff_notes(active_notes).
- map { |d| [d.line_code, d] }.to_h
+ diff_notes.
+ fresh.
+ discussions.
+ select(&:active?).
+ group_by(&:line_code)
end
def count_for_collection(ids, type)
@@ -121,37 +129,17 @@ class Note < ActiveRecord::Base
end
def cross_reference?
- system && SystemNoteService.cross_reference?(note)
+ system? && SystemNoteService.cross_reference?(note)
end
def diff_note?
false
end
- def legacy_diff_note?
- false
- end
-
- def new_diff_note?
- false
- end
-
def active?
true
end
- def resolvable?
- false
- end
-
- def resolved?
- false
- end
-
- def to_be_resolved?
- resolvable? && !resolved?
- end
-
def max_attachment_size
current_application_settings.max_attachment_size.megabytes.to_i
end
@@ -228,7 +216,7 @@ class Note < ActiveRecord::Base
end
def can_be_award_emoji?
- noteable.is_a?(Awardable)
+ noteable.is_a?(Awardable) && !part_of_discussion?
end
def contains_emoji_only?
@@ -239,6 +227,63 @@ class Note < ActiveRecord::Base
for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore
end
+ def can_be_discussion_note?
+ self.noteable.supports_discussions? && !part_of_discussion?
+ end
+
+ def discussion_class(noteable = nil)
+ # When commit notes are rendered on an MR's Discussion page, they are
+ # displayed in one discussion instead of individually.
+ # See also `#discussion_id` and `Discussion.override_discussion_id`.
+ if noteable && noteable != self.noteable
+ OutOfContextDiscussion
+ else
+ IndividualNoteDiscussion
+ end
+ end
+
+ # See `Discussion.override_discussion_id` for details.
+ def discussion_id(noteable = nil)
+ discussion_class(noteable).override_discussion_id(self) || super()
+ end
+
+ # Returns a discussion containing just this note.
+ # This method exists as an alternative to `#discussion` to use when the methods
+ # we intend to call on the Discussion object don't require it to have all of its notes,
+ # and just depend on the first note or the type of discussion. This saves us a DB query.
+ def to_discussion(noteable = nil)
+ Discussion.build([self], noteable)
+ end
+
+ # Returns the entire discussion this note is part of.
+ # Consider using `#to_discussion` if we do not need to render the discussion
+ # and all its notes and if we don't care about the discussion's resolvability status.
+ def discussion
+ full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
+ full_discussion || to_discussion
+ end
+
+ def part_of_discussion?
+ !to_discussion.individual_note?
+ end
+
+ def in_reply_to?(other)
+ case other
+ when Note
+ if part_of_discussion?
+ in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion)
+ else
+ in_reply_to?(other.noteable)
+ end
+ when Discussion
+ self.discussion_id == other.id
+ when Noteable
+ self.noteable == other
+ else
+ false
+ end
+ end
+
private
def keep_around_commit
@@ -264,17 +309,7 @@ class Note < ActiveRecord::Base
end
def set_discussion_id
- self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id)
- end
-
- def build_discussion_id
- if for_merge_request?
- # Notes on merge requests are always in a discussion of their own,
- # so we generate a unique discussion ID.
- [:discussion, :note, SecureRandom.hex].join("-")
- else
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
- end
+ self.discussion_id ||= discussion_class.discussion_id(self)
end
def expire_etag_cache
diff --git a/app/models/out_of_context_discussion.rb b/app/models/out_of_context_discussion.rb
new file mode 100644
index 00000000000..85794630f70
--- /dev/null
+++ b/app/models/out_of_context_discussion.rb
@@ -0,0 +1,22 @@
+# When notes on a commit are displayed in the context of a merge request that
+# contains that commit, they are displayed as if they were a discussion.
+#
+# This represents one of those discussions, consisting of `Note` notes.
+#
+# A discussion of this type is never resolvable.
+class OutOfContextDiscussion < Discussion
+ # Returns an array of discussion ID components
+ def self.build_discussion_id(note)
+ base_discussion_id(note)
+ end
+
+ # To make sure all out-of-context notes end up grouped as one discussion,
+ # we override the discussion ID to be a newly generated but consistent ID.
+ def self.override_discussion_id(note)
+ discussion_id(note)
+ end
+
+ def self.note_class
+ Note
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 2b467f95087..a160efba912 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -135,6 +135,7 @@ class Project < ActiveRecord::Base
has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet'
has_many :hooks, dependent: :destroy, class_name: 'ProjectHook'
has_many :protected_branches, dependent: :destroy
+ has_many :protected_tags, dependent: :destroy
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
@@ -262,6 +263,8 @@ class Project < ActiveRecord::Base
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
+ enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
+
# project features may be "disabled", "internal" or "enabled". If "internal",
# they are only available to team members. This scope returns projects where
# the feature is either enabled, or internal with permission for the user.
@@ -857,14 +860,6 @@ class Project < ActiveRecord::Base
@repo_exists = false
end
- # Branches that are not _exactly_ matched by a protected branch.
- def open_branches
- exact_protected_branch_names = protected_branches.reject(&:wildcard?).map(&:name)
- branch_names = repository.branches.map(&:name)
- non_open_branch_names = Set.new(exact_protected_branch_names).intersection(Set.new(branch_names))
- repository.branches.reject { |branch| non_open_branch_names.include? branch.name }
- end
-
def root_ref?(branch)
repository.root_ref == branch
end
@@ -879,16 +874,8 @@ class Project < ActiveRecord::Base
Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url
end
- # Check if current branch name is marked as protected in the system
- def protected_branch?(branch_name)
- return true if empty_repo? && default_branch_protected?
-
- @protected_branches ||= self.protected_branches.to_a
- ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present?
- end
-
def user_can_push_to_empty_repo?(user)
- !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
+ !ProtectedBranch.default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
end
def forked?
@@ -1195,7 +1182,7 @@ class Project < ActiveRecord::Base
end
def pipeline_status
- @pipeline_status ||= Ci::PipelineStatus.load_for_project(self)
+ @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self)
end
def mark_import_as_failed(error_message)
@@ -1351,11 +1338,6 @@ class Project < ActiveRecord::Base
"projects/#{id}/pushes_since_gc"
end
- def default_branch_protected?
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
- 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_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 3b90fd1c2c7..97e997d3899 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -91,7 +91,7 @@ class JiraService < IssueTrackerService
{ type: 'text', name: 'project_key', placeholder: 'Project Key' },
{ type: 'text', name: 'username', placeholder: '' },
{ type: 'password', name: 'password', placeholder: '' },
- { type: 'text', name: 'jira_issue_transition_id', placeholder: '2' }
+ { type: 'text', name: 'jira_issue_transition_id', placeholder: '' }
]
end
diff --git a/app/models/protectable_dropdown.rb b/app/models/protectable_dropdown.rb
new file mode 100644
index 00000000000..122fbce257d
--- /dev/null
+++ b/app/models/protectable_dropdown.rb
@@ -0,0 +1,33 @@
+class ProtectableDropdown
+ def initialize(project, ref_type)
+ @project = project
+ @ref_type = ref_type
+ end
+
+ # Tags/branches which are yet to be individually protected
+ def protectable_ref_names
+ @protectable_ref_names ||= ref_names - non_wildcard_protected_ref_names
+ end
+
+ def hash
+ protectable_ref_names.map { |ref_name| { text: ref_name, id: ref_name, title: ref_name } }
+ end
+
+ private
+
+ def refs
+ @project.repository.public_send(@ref_type)
+ end
+
+ def ref_names
+ refs.map(&:name)
+ end
+
+ def protections
+ @project.public_send("protected_#{@ref_type}")
+ end
+
+ def non_wildcard_protected_ref_names
+ protections.reject(&:wildcard?).map(&:name)
+ end
+end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 39e979ef15b..28b7d5ad072 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -1,9 +1,6 @@
class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
-
- belongs_to :project
- validates :name, presence: true
- validates :project, presence: true
+ include ProtectedRef
has_many :merge_access_levels, dependent: :destroy
has_many :push_access_levels, dependent: :destroy
@@ -14,54 +11,15 @@ class ProtectedBranch < ActiveRecord::Base
accepts_nested_attributes_for :push_access_levels
accepts_nested_attributes_for :merge_access_levels
- def commit
- project.commit(self.name)
- end
-
- # Returns all protected branches that match the given branch name.
- # This realizes all records from the scope built up so far, and does
- # _not_ return a relation.
- #
- # This method optionally takes in a list of `protected_branches` to search
- # through, to avoid calling out to the database.
- def self.matching(branch_name, protected_branches: nil)
- (protected_branches || all).select { |protected_branch| protected_branch.matches?(branch_name) }
- end
-
- # Returns all branches (among the given list of branches [`Gitlab::Git::Branch`])
- # that match the current protected branch.
- def matching(branches)
- branches.select { |branch| self.matches?(branch.name) }
- end
-
- # Checks if the protected branch matches the given branch name.
- def matches?(branch_name)
- return false if self.name.blank?
-
- exact_match?(branch_name) || wildcard_match?(branch_name)
- end
-
- # Checks if this protected branch contains a wildcard
- def wildcard?
- self.name && self.name.include?('*')
- end
-
- protected
-
- def exact_match?(branch_name)
- self.name == branch_name
- end
+ # Check if branch name is marked as protected in the system
+ def self.protected?(project, ref_name)
+ return true if project.empty_repo? && default_branch_protected?
- def wildcard_match?(branch_name)
- wildcard_regex === branch_name
+ self.matching(ref_name, protected_refs: project.protected_branches).present?
end
- def wildcard_regex
- @wildcard_regex ||= begin
- name = self.name.gsub('*', 'STAR_DONT_ESCAPE')
- quoted_name = Regexp.quote(name)
- regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?')
- /\A#{regex_string}\z/
- end
+ def self.default_branch_protected?
+ current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
+ current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end
end
diff --git a/app/models/protected_ref_matcher.rb b/app/models/protected_ref_matcher.rb
new file mode 100644
index 00000000000..d970f2b01fc
--- /dev/null
+++ b/app/models/protected_ref_matcher.rb
@@ -0,0 +1,54 @@
+class ProtectedRefMatcher
+ def initialize(protected_ref)
+ @protected_ref = protected_ref
+ end
+
+ # Returns all protected refs that match the given ref name.
+ # This checks all records from the scope built up so far, and does
+ # _not_ return a relation.
+ #
+ # This method optionally takes in a list of `protected_refs` to search
+ # through, to avoid calling out to the database.
+ def self.matching(type, ref_name, protected_refs: nil)
+ (protected_refs || type.all).select { |protected_ref| protected_ref.matches?(ref_name) }
+ end
+
+ # Returns all branches/tags (among the given list of refs [`Gitlab::Git::Branch`])
+ # that match the current protected ref.
+ def matching(refs)
+ refs.select { |ref| @protected_ref.matches?(ref.name) }
+ end
+
+ # Checks if the protected ref matches the given ref name.
+ def matches?(ref_name)
+ return false if @protected_ref.name.blank?
+
+ exact_match?(ref_name) || wildcard_match?(ref_name)
+ end
+
+ # Checks if this protected ref contains a wildcard
+ def wildcard?
+ @protected_ref.name && @protected_ref.name.include?('*')
+ end
+
+ protected
+
+ def exact_match?(ref_name)
+ @protected_ref.name == ref_name
+ end
+
+ def wildcard_match?(ref_name)
+ return false unless wildcard?
+
+ wildcard_regex === ref_name
+ end
+
+ def wildcard_regex
+ @wildcard_regex ||= begin
+ name = @protected_ref.name.gsub('*', 'STAR_DONT_ESCAPE')
+ quoted_name = Regexp.quote(name)
+ regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?')
+ /\A#{regex_string}\z/
+ end
+ end
+end
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
new file mode 100644
index 00000000000..83964095516
--- /dev/null
+++ b/app/models/protected_tag.rb
@@ -0,0 +1,14 @@
+class ProtectedTag < ActiveRecord::Base
+ include Gitlab::ShellAdapter
+ include ProtectedRef
+
+ has_many :create_access_levels, dependent: :destroy
+
+ validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
+
+ accepts_nested_attributes_for :create_access_levels
+
+ def self.protected?(project, ref_name)
+ self.matching(ref_name, protected_refs: project.protected_tags).present?
+ end
+end
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
new file mode 100644
index 00000000000..c7e1319719d
--- /dev/null
+++ b/app/models/protected_tag/create_access_level.rb
@@ -0,0 +1,21 @@
+class ProtectedTag::CreateAccessLevel < ActiveRecord::Base
+ include ProtectedTagAccess
+
+ validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::NO_ACCESS] }
+
+ def self.human_access_levels
+ {
+ Gitlab::Access::MASTER => "Masters",
+ Gitlab::Access::DEVELOPER => "Developers + Masters",
+ Gitlab::Access::NO_ACCESS => "No one"
+ }.with_indifferent_access
+ end
+
+ def check_access(user)
+ return false if access_level == Gitlab::Access::NO_ACCESS
+
+ super
+ end
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 1293cb1d486..f4c51cdfdf4 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1156,6 +1156,8 @@ class Repository
@project.repository_storage_path
end
+ delegate :gitaly_channel, :gitaly_repository, to: :raw_repository
+
def initialize_raw_repository
Gitlab::Git::Repository.new(project.repository_storage, path_with_namespace + '.git')
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index f4bcb49b34d..bfaf0eb2fae 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -5,10 +5,11 @@ class SentNotification < ActiveRecord::Base
belongs_to :noteable, polymorphic: true
belongs_to :recipient, class_name: "User"
- validates :project, :recipient, :reply_key, presence: true
- validates :reply_key, uniqueness: true
+ validates :project, :recipient, presence: true
+ validates :reply_key, presence: true, uniqueness: true
validates :noteable_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
+ validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true }
validate :note_valid
after_save :keep_around_commit
@@ -22,9 +23,7 @@ class SentNotification < ActiveRecord::Base
find_by(reply_key: reply_key)
end
- def record(noteable, recipient_id, reply_key, attrs = {})
- return unless reply_key
-
+ def record(noteable, recipient_id, reply_key = self.reply_key, attrs = {})
noteable_id = nil
commit_id = nil
if noteable.is_a?(Commit)
@@ -34,23 +33,20 @@ class SentNotification < ActiveRecord::Base
end
attrs.reverse_merge!(
- project: noteable.project,
- noteable_type: noteable.class.name,
- noteable_id: noteable_id,
- commit_id: commit_id,
- recipient_id: recipient_id,
- reply_key: reply_key
+ project: noteable.project,
+ recipient_id: recipient_id,
+ reply_key: reply_key,
+
+ noteable_type: noteable.class.name,
+ noteable_id: noteable_id,
+ commit_id: commit_id,
)
create(attrs)
end
- def record_note(note, recipient_id, reply_key, attrs = {})
- if note.diff_note?
- attrs[:note_type] = note.type
-
- attrs.merge!(note.diff_attributes)
- end
+ def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
+ attrs[:in_reply_to_discussion_id] = note.discussion_id
record(note.noteable, recipient_id, reply_key, attrs)
end
@@ -89,31 +85,45 @@ class SentNotification < ActiveRecord::Base
self.reply_key
end
- def note_attributes
- {
- project: self.project,
- author: self.recipient,
- type: self.note_type,
- noteable_type: self.noteable_type,
- noteable_id: self.noteable_id,
- commit_id: self.commit_id,
- line_code: self.line_code,
- position: self.position.to_json
- }
- end
-
- def create_note(note)
- Notes::CreateService.new(
- self.project,
- self.recipient,
- self.note_attributes.merge(note: note)
- ).execute
+ def create_reply(message, dryrun: false)
+ klass = dryrun ? Notes::BuildService : Notes::CreateService
+ klass.new(self.project, self.recipient, reply_params.merge(note: message)).execute
end
private
+ def reply_params
+ attrs = {
+ noteable_type: self.noteable_type,
+ noteable_id: self.noteable_id,
+ commit_id: self.commit_id
+ }
+
+ if self.in_reply_to_discussion_id.present?
+ attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id
+ else
+ # Remove in GitLab 10.0, when we will not support replying to SentNotifications
+ # that don't have `in_reply_to_discussion_id` anymore.
+ attrs.merge!(
+ type: self.note_type,
+
+ # LegacyDiffNote
+ line_code: self.line_code,
+
+ # DiffNote
+ position: self.position.to_json
+ )
+ end
+
+ attrs
+ end
+
def note_valid
- Note.new(note_attributes.merge(note: "Test")).valid?
+ note = create_reply('Test', dryrun: true)
+
+ unless note.valid?
+ self.errors.add(:base, "Note parameters are invalid: #{note.errors.full_messages.to_sentence}")
+ end
end
def keep_around_commit
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 30aca62499c..380835707e8 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -2,6 +2,7 @@ class Snippet < ActiveRecord::Base
include Gitlab::VisibilityLevel
include Linguist::BlobHelper
include CacheMarkdownField
+ include Noteable
include Participable
include Referable
include Sortable
diff --git a/app/models/user.rb b/app/models/user.rb
index 87eeee204f8..31e975b8e53 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -555,10 +555,6 @@ class User < ActiveRecord::Base
authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled
end
- def is_admin?
- admin
- end
-
def require_ssh_key?
keys.count == 0 && Gitlab::ProtocolAccess.allowed?('ssh')
end
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index 7edd383530d..416d93ffe63 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -3,7 +3,7 @@ module Ci
def rules
return unless @user
- can! :assign_runner if @user.is_admin?
+ can! :assign_runner if @user.admin?
return if @subject.is_shared? || @subject.locked?
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index cb72c2b4590..4757ba71680 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -10,6 +10,7 @@ class GlobalPolicy < BasePolicy
can! :access_api
can! :access_git
can! :receive_notifications
+ can! :use_slash_commands
end
end
end
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index ed72ed14d72..c495c3f39bb 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -11,5 +11,11 @@ module Ci
def erased_by_name
erased_by.name if erased_by_user?
end
+
+ def status_title
+ if auto_canceled?
+ "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
+ end
+ end
end
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
new file mode 100644
index 00000000000..a542bdd8295
--- /dev/null
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -0,0 +1,11 @@
+module Ci
+ class PipelinePresenter < Gitlab::View::Presenter::Delegated
+ presents :pipeline
+
+ def status_title
+ if auto_canceled?
+ "Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 38a85e9fc42..21350be5557 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -53,6 +53,8 @@ module Ci
.execute(pipeline)
end
+ cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
+
pipeline.tap(&:process!)
end
@@ -63,6 +65,22 @@ module Ci
pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i
end
+ def cancel_pending_pipelines
+ Gitlab::OptimisticLocking.retry_lock(auto_cancelable_pipelines) do |cancelables|
+ cancelables.find_each do |cancelable|
+ cancelable.auto_cancel_running(pipeline)
+ end
+ end
+ end
+
+ def auto_cancelable_pipelines
+ project.pipelines
+ .where(ref: pipeline.ref)
+ .where.not(id: pipeline.id)
+ .where.not(sha: project.repository.sha_from_ref(pipeline.ref))
+ .created_or_pending
+ end
+
def commit
@commit ||= project.commit(origin_sha || origin_ref)
end
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
index c0e4a798f6a..91d9c1d2ba1 100644
--- a/app/services/ci/expire_pipeline_cache_service.rb
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -10,6 +10,8 @@ module Ci
store.touch(commit_pipelines_path) if pipeline.commit
store.touch(new_merge_request_pipelines_path)
merge_requests_pipelines_paths.each { |path| store.touch(path) }
+
+ Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(@pipeline)
end
private
diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb
index 297c7d696c3..910a2a15e5d 100644
--- a/app/services/concerns/issues/resolve_discussions.rb
+++ b/app/services/concerns/issues/resolve_discussions.rb
@@ -21,11 +21,11 @@ module Issues
@discussions_to_resolve ||=
if discussion_to_resolve_id
discussion_or_nil = merge_request_to_resolve_discussions_of
- .find_diff_discussion(discussion_to_resolve_id)
+ .find_discussion(discussion_to_resolve_id)
Array(discussion_or_nil)
else
merge_request_to_resolve_discussions_of
- .resolvable_discussions
+ .discussions_to_be_resolved
end
end
end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 11a045f4c31..38a113caec7 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -11,7 +11,7 @@ class DeleteBranchService < BaseService
return error('Cannot remove HEAD branch', 405)
end
- if project.protected_branch?(branch_name)
+ if ProtectedBranch.protected?(project, branch_name)
return error('Protected branch cant be removed', 405)
end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index bc7431c89a8..45411c779cc 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -127,7 +127,7 @@ class GitPushService < BaseService
project.change_head(branch_name)
# Set protection on the default branch if configured
- if current_application_settings.default_branch_protection != PROTECTION_NONE && !@project.protected_branch?(@project.default_branch)
+ if current_application_settings.default_branch_protection != PROTECTION_NONE && !ProtectedBranch.protected?(@project, @project.default_branch)
params = {
name: @project.default_branch,
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 77bced4bd5c..3a4f7b159f1 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -35,14 +35,19 @@ module Issues
end
def item_for_discussion(discussion)
- first_note = discussion.first_note_to_resolve || discussion.first_note
+ first_note_to_resolve = discussion.first_note_to_resolve || discussion.first_note
+
+ is_very_first_note = first_note_to_resolve == discussion.first_note
+ action = is_very_first_note ? "started" : "commented on"
+
+ note_url = Gitlab::UrlBuilder.build(first_note_to_resolve)
+
other_note_count = discussion.notes.size - 1
- note_url = Gitlab::UrlBuilder.build(first_note)
- discussion_info = "- [ ] #{first_note.author.to_reference} commented on a [discussion](#{note_url}): "
+ discussion_info = "- [ ] #{first_note_to_resolve.author.to_reference} #{action} a [discussion](#{note_url}): "
discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0
- note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call
+ note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note_to_resolve.note).call
spaces = ' ' * 4
quote = note_without_block_quotes.lines.map { |line| "#{spaces}> #{line}" }.join
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
new file mode 100644
index 00000000000..ea7cacc956c
--- /dev/null
+++ b/app/services/notes/build_service.rb
@@ -0,0 +1,25 @@
+module Notes
+ class BuildService < ::BaseService
+ def execute
+ in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id)
+
+ if project && in_reply_to_discussion_id.present?
+ discussion = project.notes.find_discussion(in_reply_to_discussion_id)
+
+ unless discussion
+ note = Note.new
+ note.errors.add(:base, 'Discussion to reply to cannot be found')
+ return note
+ end
+
+ params.merge!(discussion.reply_attributes)
+ end
+
+ note = Note.new(params)
+ note.project = project
+ note.author = current_user
+
+ note
+ end
+ end
+end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 61d66a26932..f3954f6f8c4 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -1,12 +1,10 @@
module Notes
- class CreateService < BaseService
+ class CreateService < ::BaseService
def execute
merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
- note = Note.new(params)
- note.project = project
- note.author = current_user
- note.system = false
+ note = Notes::BuildService.new(project, current_user, params).execute
+ return note unless note.valid?
# We execute commands (extracted from `params[:note]`) on the noteable
# **before** we save the note because if the note consists of commands
diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb
index 89d8ba60134..4b3337a5c9d 100644
--- a/app/services/protected_branches/update_service.rb
+++ b/app/services/protected_branches/update_service.rb
@@ -1,13 +1,10 @@
module ProtectedBranches
class UpdateService < BaseService
- attr_reader :protected_branch
-
def execute(protected_branch)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
- @protected_branch = protected_branch
- @protected_branch.update(params)
- @protected_branch
+ protected_branch.update(params)
+ protected_branch
end
end
end
diff --git a/app/services/protected_tags/create_service.rb b/app/services/protected_tags/create_service.rb
new file mode 100644
index 00000000000..faba7865a17
--- /dev/null
+++ b/app/services/protected_tags/create_service.rb
@@ -0,0 +1,11 @@
+module ProtectedTags
+ class CreateService < BaseService
+ attr_reader :protected_tag
+
+ def execute
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
+
+ project.protected_tags.create(params)
+ end
+ end
+end
diff --git a/app/services/protected_tags/update_service.rb b/app/services/protected_tags/update_service.rb
new file mode 100644
index 00000000000..aea6a48968d
--- /dev/null
+++ b/app/services/protected_tags/update_service.rb
@@ -0,0 +1,10 @@
+module ProtectedTags
+ class UpdateService < BaseService
+ def execute(protected_tag)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
+
+ protected_tag.update(params)
+ protected_tag
+ end
+ end
+end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index 595653ea58a..49d45ec9dbd 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -7,6 +7,8 @@ module SlashCommands
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
def execute(content, issuable)
+ return [content, {}] unless current_user.can?(:use_slash_commands)
+
@issuable = issuable
@updates = {}
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 35cfcc3682e..c9e25c7aaa2 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -228,12 +228,10 @@ module SystemNoteService
def discussion_continued_in_issue(discussion, project, author, issue)
body = "created #{issue.to_reference} to continue this discussion"
+ note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
- note_params = discussion.reply_attributes.merge(project: project, author: author, note: body)
- note_params[:type] = note_params.delete(:note_type)
-
- note = Note.create(note_params.merge(system: true))
- note.system_note_metadata = SystemNoteMetadata.new({ action: 'discussion' })
+ note = Note.create(note_attributes.merge(system: true))
+ note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion')
note
end
diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb
index a847a71a66a..93ca7b1141a 100644
--- a/app/services/users/create_service.rb
+++ b/app/services/users/create_service.rb
@@ -11,7 +11,7 @@ module Users
user = User.new(build_user_params)
- if current_user&.is_admin?
+ if current_user&.admin?
if params[:reset_password]
@reset_token = user.generate_reset_token
params[:force_random_password] = true
@@ -47,7 +47,7 @@ module Users
private
def can_create_user?
- (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.is_admin?
+ (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin?
end
# Allowed params for creating a user (admins only)
@@ -94,7 +94,7 @@ module Users
end
def build_user_params
- if current_user&.is_admin?
+ if current_user&.admin?
user_params = params.slice(*admin_create_params)
user_params[:created_by_id] = current_user&.id
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index d6ccf0dc92c..d2783ce5b2f 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -38,10 +38,6 @@ class FileUploader < GitlabUploader
File.join(dynamic_path_segment, @secret)
end
- def cache_dir
- File.join(base_dir, 'tmp', @project.path_with_namespace, @secret)
- end
-
def model
project
end
diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml
index c00c7f7407e..39c7fb0eba2 100644
--- a/app/views/ci/status/_badge.html.haml
+++ b/app/views/ci/status/_badge.html.haml
@@ -1,12 +1,13 @@
- status = local_assigns.fetch(:status)
-- link = local_assigns.fetch(:link, true)
-- css_classes = "ci-status ci-#{status.group}"
+- link = local_assigns.fetch(:link, true)
+- title = local_assigns.fetch(:title, nil)
+- css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}"
- if link && status.has_details?
- = link_to status.details_path, class: css_classes do
+ = link_to status.details_path, class: css_classes, title: title do
= custom_icon(status.icon)
= status.text
- else
- %span{ class: css_classes }
+ %span{ class: css_classes, title: title }
= custom_icon(status.icon)
= status.text
diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml
index ee452add394..e6d307e5568 100644
--- a/app/views/discussions/_diff_discussion.html.haml
+++ b/app/views/discussions/_diff_discussion.html.haml
@@ -3,4 +3,4 @@
%td.notes_line{ colspan: 2 }
%td.notes_content
.content{ class: ('hide' unless expanded) }
- = render "discussions/notes", discussion: discussion
+ = render partial: "discussions/notes", collection: discussions, as: :discussion
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 94408b92374..549364761e6 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -7,7 +7,7 @@
.diff-content.code.js-syntax-highlight
%table
- - discussions = { discussion.original_line_code => discussion }
+ - discussions = { discussion.original_line_code => [discussion] }
= render partial: "projects/diffs/line",
collection: discussion.truncated_diff_lines,
as: :line,
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 2d78c55211e..e04958817e4 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -5,7 +5,7 @@
= 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.js-toggle-container{ data: { discussion_id: discussion.id } }
.discussion-header
.discussion-actions
%button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" }
@@ -18,19 +18,21 @@
.inline.discussion-headline-light
= discussion.author.to_reference
- started a discussion on
+ started a discussion
- - if discussion.for_commit?
+ - if discussion.for_commit? && @noteable != discussion.noteable
+ on
- commit = discussion.noteable
- if commit
commit
- = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code), class: 'monospace'
+ - anchor = discussion.line_code if discussion.diff_discussion?
+ = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor), class: 'monospace'
- else
a deleted commit
- - else
+ - elsif discussion.diff_discussion?
+ on
- if discussion.active?
- = link_to diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) do
- the diff
+ = link_to 'the diff', discussion_diff_path(discussion)
- else
an outdated diff
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 2789391819c..34789808f10 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,18 +1,20 @@
-%ul.notes{ data: { discussion_id: discussion.id } }
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
+.discussion-notes
+ %ul.notes{ data: { discussion_id: discussion.id } }
+ = render partial: "projects/notes/note", collection: discussion.notes, as: :note
-- if current_user
- .discussion-reply-holder
- - if discussion.diff_discussion?
- - line_type = local_assigns.fetch(:line_type, nil)
+ - if current_user
+ .discussion-reply-holder
+ - if discussion.potentially_resolvable?
+ - line_type = local_assigns.fetch(:line_type, nil)
+
+ .btn-group-justified.discussion-with-resolve-btn{ role: "group" }
+ .btn-group{ role: "group" }
+ = link_to_reply_discussion(discussion, line_type)
+
+ = render "discussions/resolve_all", discussion: discussion
- .btn-group-justified.discussion-with-resolve-btn{ role: "group" }
- .btn-group{ role: "group" }
- = link_to_reply_discussion(discussion, line_type)
- = render "discussions/resolve_all", discussion: discussion
- - if discussion.for_merge_request?
.btn-group.discussion-actions
= render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable
= render "discussions/jump_to_next", discussion: discussion
- - else
- = link_to_reply_discussion(discussion)
+ - else
+ = link_to_reply_discussion(discussion)
diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml
index 3a19e021643..253cd336882 100644
--- a/app/views/discussions/_parallel_diff_discussion.html.haml
+++ b/app/views/discussions/_parallel_diff_discussion.html.haml
@@ -1,20 +1,20 @@
-- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?)
+- expanded = [*discussions_left, *discussions_right].any?(&:expanded?)
%tr.notes_holder{ class: ('hide' unless expanded) }
- - if discussion_left
+ - if discussions_left
%td.notes_line.old
%td.notes_content.parallel.old
- .content{ class: ('hide' unless discussion_left.expanded?) }
- = render "discussions/notes", discussion: discussion_left, line_type: 'old'
+ .content{ class: ('hide' unless discussions_left.any?(&:expanded?)) }
+ = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old'
- else
%td.notes_line.old= ("")
%td.notes_content.parallel.old
.content
- - if discussion_right
+ - if discussions_right
%td.notes_line.new
%td.notes_content.parallel.new
- .content{ class: ('hide' unless discussion_right.expanded?) }
- = render "discussions/notes", discussion: discussion_right, line_type: 'new'
+ .content{ class: ('hide' unless discussions_right.any?(&:expanded?)) }
+ = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new'
- else
%td.notes_line.new= ("")
%td.notes_content.parallel.new
diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml
index e30ee1b0e05..689a22acd27 100644
--- a/app/views/discussions/_resolve_all.html.haml
+++ b/app/views/discussions/_resolve_all.html.haml
@@ -1,9 +1,8 @@
-- if discussion.for_merge_request?
- %resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
- ":merge-request-id" => discussion.noteable.iid,
- ":can-resolve" => discussion.can_resolve?(current_user),
- "inline-template" => true }
- .btn-group{ role: "group", "v-if" => "showButton" }
- %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" }
- = icon("spinner spin", "v-show" => "loading")
- {{ buttonText }}
+%resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
+ ":merge-request-id" => discussion.noteable.iid,
+ ":can-resolve" => discussion.can_resolve?(current_user),
+ "inline-template" => true }
+ .btn-group{ role: "group", "v-if" => "showButton" }
+ %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" }
+ = icon("spinner spin", "v-show" => "loading")
+ {{ buttonText }}
diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml
index c0c11d00519..af97e9588a5 100644
--- a/app/views/events/event/_common.html.haml
+++ b/app/views/events/event/_common.html.haml
@@ -10,6 +10,7 @@
= custom_icon("icon_code_fork")
.event-title
+ %span.author_name= link_to_author event
%span{ class: event.action_name }
- if event.target
= event.action_name
diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml
index 340d8c61026..fee85c94277 100644
--- a/app/views/events/event/_created_project.html.haml
+++ b/app/views/events/event/_created_project.html.haml
@@ -2,6 +2,7 @@
= custom_icon("icon_status_open")
.event-title
+ %span.author_name= link_to_author event
%span{ class: event.action_name }
= event_action_name(event)
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index cbcfe0ff71f..83709f5e4d0 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -2,6 +2,7 @@
= custom_icon("icon_comment_o")
.event-title
+ %span.author_name= link_to_author event
= event.action_name
= event_note_title_html(event)
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index 1583f380737..efdc8764acf 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -7,6 +7,7 @@
= custom_icon("icon_commit")
.event-title
+ %span.author_name= link_to_author event
%span.pushed #{event.action_name} #{event.ref_type}
%strong
- commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name)
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 7408fe3f6d0..a9893dea68f 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -31,7 +31,7 @@
%li.impersonation
= link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('user-secret fw')
- - if current_user.is_admin?
+ - if current_user.admin?
%li
= link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
@@ -47,17 +47,19 @@
%li
= link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('hashtag fw')
- %span.badge.issues-count
- = number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened))
+ - issues_count = cached_assigned_issuables_count(current_user, :issues, :opened)
+ %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
+ = number_with_delimiter(issues_count)
%li
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('mr_bold')
- %span.badge.merge-requests-count
- = number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened))
+ - merge_requests_count = cached_assigned_issuables_count(current_user, :merge_requests, :opened)
+ %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
+ = number_with_delimiter(merge_requests_count)
%li
= link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('check-circle fw')
- %span.badge.todos-count
+ %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
%li.header-user.dropdown
= link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
new file mode 100644
index 00000000000..198f30a1dc4
--- /dev/null
+++ b/app/views/layouts/mailer.text.erb
@@ -0,0 +1,4 @@
+<%= yield -%>
+
+---
+You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>.
diff --git a/app/views/layouts/mailer.text.haml b/app/views/layouts/mailer.text.haml
deleted file mode 100644
index 6a9c6ced9cc..00000000000
--- a/app/views/layouts/mailer.text.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-= yield
-
-You're receiving this email because of your account on #{Gitlab.config.gitlab.host}.
-Manage all notifications: #{profile_notifications_url}
-Help: #{help_url}
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index 76268c1b705..40bf45cece7 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -25,8 +25,8 @@
- if @labels_url
adjust your #{link_to 'label subscriptions', @labels_url}.
- else
- - if @sent_notification_url
- = link_to "unsubscribe", @sent_notification_url
+ - if @unsubscribe_url
+ = link_to "unsubscribe", @unsubscribe_url
from this thread or
adjust your notification settings.
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
new file mode 100644
index 00000000000..b4ce02eead8
--- /dev/null
+++ b/app/views/layouts/notify.text.erb
@@ -0,0 +1,12 @@
+<%= yield -%>
+
+---
+<% if @target_url -%>
+<% if @reply_by_email -%>
+<%= "Reply to this email directly or view it on GitLab: #{@target_url}" -%>
+<% else -%>
+<%= "View it on GitLab: #{@target_url}" -%>
+<% end -%>
+<% end -%>
+
+You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>.
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
new file mode 100644
index 00000000000..a80518f7986
--- /dev/null
+++ b/app/views/notify/_note_email.html.haml
@@ -0,0 +1,37 @@
+- discussion = @note.discussion if @note.part_of_discussion?
+- if discussion
+ %p.details
+ = succeed ':' do
+ = link_to @note.author_name, user_url(@note.author)
+
+ - if discussion.diff_discussion?
+ - if discussion.new_discussion?
+ started a new discussion
+ - else
+ commented on a discussion
+
+ on #{link_to discussion.file_path, @target_url}
+ - else
+ - if discussion.new_discussion?
+ started a new discussion
+ - else
+ commented on a #{link_to 'discussion', @target_url}
+
+- elsif current_application_settings.email_author_in_body
+ %p.details
+ #{link_to @note.author_name, user_url(@note.author)} commented:
+
+- if discussion&.diff_discussion?
+ = content_for :head do
+ = stylesheet_link_tag 'mailers/highlighted_diff_email'
+
+ %table
+ = render partial: "projects/diffs/line",
+ collection: discussion.truncated_diff_lines,
+ as: :line,
+ locals: { diff_file: discussion.diff_file,
+ plain: true,
+ email: true }
+
+%div
+ = markdown(@note.note, pipeline: :email, author: @note.author)
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
new file mode 100644
index 00000000000..cb2e7fab6d5
--- /dev/null
+++ b/app/views/notify/_note_email.text.erb
@@ -0,0 +1,26 @@
+<% discussion = @note.discussion if @note.part_of_discussion? -%>
+<% if discussion && !discussion.individual_note? -%>
+<%= @note.author_name -%>
+<% if discussion.new_discussion? -%>
+<%= " started a new discussion" -%>
+<% else -%>
+<%= " commented on a discussion" -%>
+<% end -%>
+<% if discussion.diff_discussion? -%>
+<%= " on #{discussion.file_path}" -%>
+<% end -%>
+<%= ":" -%>
+
+
+<% elsif current_application_settings.email_author_in_body -%>
+<%= "#{@note.author_name} commented:" -%>
+
+
+<% end -%>
+<% if discussion&.diff_discussion? -%>
+<% discussion.truncated_diff_lines(highlight: false).each do |line| -%>
+<%= "> #{line.text}\n" -%>
+<% end -%>
+
+<% end -%>
+<%= @note.note -%>
diff --git a/app/views/notify/_note_message.html.haml b/app/views/notify/_note_message.html.haml
deleted file mode 100644
index e9c66170877..00000000000
--- a/app/views/notify/_note_message.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- if current_application_settings.email_author_in_body
- %div
- #{link_to @note.author_name, user_url(@note.author)} wrote:
-%div
- = markdown(@note.note, pipeline: :email, author: @note.author)
diff --git a/app/views/notify/_note_message.text.erb b/app/views/notify/_note_message.text.erb
deleted file mode 100644
index f82cbc9a3fc..00000000000
--- a/app/views/notify/_note_message.text.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<% if current_application_settings.email_author_in_body %>
- <%= @note.author_name %> wrote:
-<% end -%>
-
-<%= @note.note %>
diff --git a/app/views/notify/_note_mr_or_commit_email.html.haml b/app/views/notify/_note_mr_or_commit_email.html.haml
deleted file mode 100644
index edf8dfe7e9e..00000000000
--- a/app/views/notify/_note_mr_or_commit_email.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-= content_for :head do
- = stylesheet_link_tag 'mailers/highlighted_diff_email'
-
-New comment
-
-- if @discussion && @discussion.diff_file
- on
- = link_to @note.diff_file.file_path, @target_url, class: 'details'
- \:
- %table
- = render partial: "projects/diffs/line",
- collection: @discussion.truncated_diff_lines,
- as: :line,
- locals: { diff_file: @note.diff_file,
- plain: true,
- email: true }
-
-= render 'note_message'
diff --git a/app/views/notify/_note_mr_or_commit_email.text.erb b/app/views/notify/_note_mr_or_commit_email.text.erb
deleted file mode 100644
index b4fcdf6b1e9..00000000000
--- a/app/views/notify/_note_mr_or_commit_email.text.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% if @discussion && @discussion.diff_file -%>
- on <%= @note.diff_file.file_path -%>
-<% end -%>:
-
-<%= url %>
-
-<%= render 'simple_diff' if @discussion -%>
-<%= render 'note_message' %>
diff --git a/app/views/notify/_simple_diff.text.erb b/app/views/notify/_simple_diff.text.erb
deleted file mode 100644
index c28d1cc34d3..00000000000
--- a/app/views/notify/_simple_diff.text.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<% @discussion.truncated_diff_lines(highlight: false).each do |line| %>
-> <%= line.text %>
-<% end %>
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index d1855568215..c762578971a 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -1,9 +1,11 @@
- if current_application_settings.email_author_in_body
- %div
- #{link_to @issue.author_name, user_url(@issue.author)} wrote:
-- if @issue.description
- = markdown(@issue.description, pipeline: :email, author: @issue.author)
+ %p.details
+ #{link_to @issue.author_name, user_url(@issue.author)} created an issue:
- if @issue.assignee_id.present?
%p
Assignee: #{@issue.assignee_name}
+
+- if @issue.description
+ %div
+ = markdown(@issue.description, pipeline: :email, author: @issue.author)
diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml
index 02f21baa368..6b45ac265f7 100644
--- a/app/views/notify/new_mention_in_issue_email.html.haml
+++ b/app/views/notify/new_mention_in_issue_email.html.haml
@@ -1,12 +1,4 @@
%p
You have been mentioned in an issue.
-- if current_application_settings.email_author_in_body
- %div
- #{link_to @issue.author_name, user_url(@issue.author)} wrote:
-- if @issue.description
- = markdown(@issue.description, pipeline: :email, author: @issue.author)
-
-- if @issue.assignee_id.present?
- %p
- Assignee: #{@issue.assignee_name}
+= render template: 'notify/new_issue_email'
diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml
index cbd434be02a..b061f9c106e 100644
--- a/app/views/notify/new_mention_in_merge_request_email.html.haml
+++ b/app/views/notify/new_mention_in_merge_request_email.html.haml
@@ -1,15 +1,4 @@
%p
You have been mentioned in Merge Request #{@merge_request.to_reference}
-- if current_application_settings.email_author_in_body
- %div
- #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote:
-%p.details
- != merge_path_description(@merge_request, '&rarr;')
-
-- if @merge_request.assignee_id.present?
- %p
- Assignee: #{@merge_request.author_name} &rarr; #{@merge_request.assignee_name}
-
-- if @merge_request.description
- = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
+= render template: 'notify/new_merge_request_email'
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 8890b300f7d..951c96bdb9c 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -1,12 +1,14 @@
- if current_application_settings.email_author_in_body
- %div
- #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote:
+ %p.details
+ #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request:
+
%p.details
!= merge_path_description(@merge_request, '&rarr;')
- if @merge_request.assignee_id.present?
%p
- Assignee: #{@merge_request.author_name} &rarr; #{@merge_request.assignee_name}
+ Assignee: #{@merge_request.assignee_name}
- if @merge_request.description
- = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
+ %div
+ = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
diff --git a/app/views/notify/note_commit_email.html.haml b/app/views/notify/note_commit_email.html.haml
index 0a650e3b2ca..5e69f01a486 100644
--- a/app/views/notify/note_commit_email.html.haml
+++ b/app/views/notify/note_commit_email.html.haml
@@ -1,2 +1 @@
-%p.details
- = render 'note_mr_or_commit_email'
+= render 'note_email'
diff --git a/app/views/notify/note_commit_email.text.erb b/app/views/notify/note_commit_email.text.erb
index 6aa085a172e..413d9e6e9ac 100644
--- a/app/views/notify/note_commit_email.text.erb
+++ b/app/views/notify/note_commit_email.text.erb
@@ -1,2 +1 @@
-New comment for Commit <%= @commit.short_id -%>
-<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url } %>
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_issue_email.html.haml b/app/views/notify/note_issue_email.html.haml
index 2fa2f784661..5e69f01a486 100644
--- a/app/views/notify/note_issue_email.html.haml
+++ b/app/views/notify/note_issue_email.html.haml
@@ -1 +1 @@
-= render 'note_message'
+= render 'note_email'
diff --git a/app/views/notify/note_issue_email.text.erb b/app/views/notify/note_issue_email.text.erb
index e33cbcd70f2..413d9e6e9ac 100644
--- a/app/views/notify/note_issue_email.text.erb
+++ b/app/views/notify/note_issue_email.text.erb
@@ -1,9 +1 @@
-New comment for Issue <%= @issue.iid %>
-
-<%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue, anchor: "note_#{@note.id}")) %>
-
-
-Author: <%= @note.author_name %>
-
-<%= @note.note %>
-
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_merge_request_email.html.haml b/app/views/notify/note_merge_request_email.html.haml
index 0a650e3b2ca..5e69f01a486 100644
--- a/app/views/notify/note_merge_request_email.html.haml
+++ b/app/views/notify/note_merge_request_email.html.haml
@@ -1,2 +1 @@
-%p.details
- = render 'note_mr_or_commit_email'
+= render 'note_email'
diff --git a/app/views/notify/note_merge_request_email.text.erb b/app/views/notify/note_merge_request_email.text.erb
index 2ce64c494cf..413d9e6e9ac 100644
--- a/app/views/notify/note_merge_request_email.text.erb
+++ b/app/views/notify/note_merge_request_email.text.erb
@@ -1,2 +1 @@
-New comment for Merge Request <%= @merge_request.to_reference -%>
-<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url }%>
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_personal_snippet_email.html.haml b/app/views/notify/note_personal_snippet_email.html.haml
index 2fa2f784661..5e69f01a486 100644
--- a/app/views/notify/note_personal_snippet_email.html.haml
+++ b/app/views/notify/note_personal_snippet_email.html.haml
@@ -1 +1 @@
-= render 'note_message'
+= render 'note_email'
diff --git a/app/views/notify/note_personal_snippet_email.text.erb b/app/views/notify/note_personal_snippet_email.text.erb
index b2a8809a23b..413d9e6e9ac 100644
--- a/app/views/notify/note_personal_snippet_email.text.erb
+++ b/app/views/notify/note_personal_snippet_email.text.erb
@@ -1,8 +1 @@
-New comment for Snippet <%= @snippet.id %>
-
-<%= url_for(snippet_url(@snippet, anchor: "note_#{@note.id}")) %>
-
-
-Author: <%= @note.author_name %>
-
-<%= @note.note %>
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_snippet_email.html.haml
index 2fa2f784661..5e69f01a486 100644
--- a/app/views/notify/note_snippet_email.html.haml
+++ b/app/views/notify/note_snippet_email.html.haml
@@ -1 +1 @@
-= render 'note_message'
+= render 'note_email'
diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_snippet_email.text.erb
index 4d5a406f4b0..413d9e6e9ac 100644
--- a/app/views/notify/note_snippet_email.text.erb
+++ b/app/views/notify/note_snippet_email.text.erb
@@ -1,8 +1 @@
-New comment for Snippet <%= @snippet.id %>
-
-<%= url_for(namespace_project_snippet_url(@snippet.project.namespace, @snippet.project, @snippet, anchor: "note_#{@note.id}")) %>
-
-
-Author: <%= @note.author_name %>
-
-<%= @note.note %>
+<%= render 'note_email' %>
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 4ad77b6266d..35885b2c7b4 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -7,7 +7,7 @@
#blob-content-holder.tree-holder
.file-holder
- = render "projects/blob/header", blob: @blob
+ = render "projects/blob/header", blob: @blob, blame: true
.table-responsive.file-content.blame.code.js-syntax-highlight
%table
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 828f8e073a9..6c7d389e707 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -1,3 +1,4 @@
+- blame = local_assigns.fetch(:blame, false)
.js-file-title.file-title-flex-parent
.file-header-content
= blob_icon blob.mode, blob.name
@@ -12,14 +13,14 @@
.file-actions.hidden-xs
.btn-group{ role: "group" }<
- = copy_blob_content_button(blob) if blob_text_viewable?(blob)
+ = copy_blob_content_button(blob) if !blame && blob_text_viewable?(blob)
= open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id))
= view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }<
-# only show normal/blame view links for text files
- if blob_text_viewable?(blob)
- - if current_page? namespace_project_blame_path(@project.namespace, @project, @id)
+ - if blame
= link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id),
class: 'btn btn-sm'
- else
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 724675e659c..0f9ef3eded3 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -15,7 +15,7 @@
%span.label.label-info.has-tooltip{ title: "Merged into #{@repository.root_ref}" }
merged
- - if @project.protected_branch? branch.name
+ - if protected_branch?(@project, branch)
%span.label.label-success
protected
.controls.hidden-xs<
diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml
index 7eb17e887e7..104db85809c 100644
--- a/app/views/projects/builds/_header.html.haml
+++ b/app/views/projects/builds/_header.html.haml
@@ -1,14 +1,16 @@
+- pipeline = @build.pipeline
+
.content-block.build-header.top-area
.header-content
- = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false
+ = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title
Job
%strong.js-build-id ##{@build.id}
in pipeline
- = link_to pipeline_path(@build.pipeline) do
- %strong ##{@build.pipeline.id}
+ = link_to pipeline_path(pipeline) do
+ %strong ##{pipeline.id}
for commit
- = link_to namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha) do
- %strong= @build.pipeline.short_sha
+ = link_to namespace_project_commit_path(@project.namespace, @project, pipeline.sha) do
+ %strong= pipeline.short_sha
from
= link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do
%code
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
index d5fe771613c..0faad57a312 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/builds/show.html.haml
@@ -71,6 +71,11 @@
= custom_icon('scroll_down_hover_active')
#up-build-trace
%pre.build-trace#build-trace
+ .js-truncated-info.truncated-info.hidden
+ %span<
+ Showing last
+ %span.js-truncated-info-size><
+ KiB of log
%code.bash.js-build-output
.build-loader-animation.js-build-refresh
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 508465cbfb7..4700b7a9a45 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -1,3 +1,5 @@
+- job = build.present(current_user: current_user)
+- pipeline = job.pipeline
- admin = local_assigns.fetch(:admin, false)
- ref = local_assigns.fetch(:ref, nil)
- commit_sha = local_assigns.fetch(:commit_sha, nil)
@@ -8,101 +10,101 @@
%tr.build.commit{ class: ('retried' if retried) }
%td.status
- = render "ci/status/badge", status: build.detailed_status(current_user)
+ = render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title
%td.branch-commit
- - if can?(current_user, :read_build, build)
- = link_to namespace_project_build_url(build.project.namespace, build.project, build) do
- %span.build-link ##{build.id}
+ - if can?(current_user, :read_build, job)
+ = link_to namespace_project_build_url(job.project.namespace, job.project, job) do
+ %span.build-link ##{job.id}
- else
- %span.build-link ##{build.id}
+ %span.build-link ##{job.id}
- if ref
- - if build.ref
+ - if job.ref
.icon-container
- = build.tag? ? icon('tag') : icon('code-fork')
- = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name"
+ = job.tag? ? icon('tag') : icon('code-fork')
+ = link_to job.ref, namespace_project_commits_path(job.project.namespace, job.project, job.ref), class: "monospace branch-name"
- else
.light none
.icon-container.commit-icon
= custom_icon("icon_commit")
- if commit_sha
- = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace"
+ = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-id monospace"
- - if build.stuck?
+ - if job.stuck?
= icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.')
- if retried
= icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried')
.label-container
- - if build.tags.any?
- - build.tags.each do |tag|
+ - if job.tags.any?
+ - job.tags.each do |tag|
%span.label.label-primary
= tag
- - if build.try(:trigger_request)
+ - if job.try(:trigger_request)
%span.label.label-info triggered
- - if build.try(:allow_failure)
+ - if job.try(:allow_failure)
%span.label.label-danger allowed to fail
- - if build.action?
+ - if job.action?
%span.label.label-info manual
- if pipeline_link
%td
- = link_to pipeline_path(build.pipeline) do
- %span.pipeline-id ##{build.pipeline.id}
+ = link_to pipeline_path(pipeline) do
+ %span.pipeline-id ##{pipeline.id}
%span by
- - if build.pipeline.user
- = user_avatar(user: build.pipeline.user, size: 20)
+ - if pipeline.user
+ = user_avatar(user: pipeline.user, size: 20)
- else
%span.monospace API
- if admin
%td
- - if build.project
- = link_to build.project.name_with_namespace, admin_namespace_project_path(build.project.namespace, build.project)
+ - if job.project
+ = link_to job.project.name_with_namespace, admin_namespace_project_path(job.project.namespace, job.project)
%td
- - if build.try(:runner)
- = runner_link(build.runner)
+ - if job.try(:runner)
+ = runner_link(job.runner)
- else
.light none
- if stage
%td
- = build.stage
+ = job.stage
%td
- = build.name
+ = job.name
%td
- - if build.duration
+ - if job.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(build.duration)
+ = duration_in_numbers(job.duration)
- - if build.finished_at
+ - if job.finished_at
%p.finished-at
= icon("calendar")
- %span= time_ago_with_tooltip(build.finished_at)
+ %span= time_ago_with_tooltip(job.finished_at)
%td.coverage
- - if build.try(:coverage)
- #{build.coverage}%
+ - if job.try(:coverage)
+ #{job.coverage}%
%td
.pull-right
- - if can?(current_user, :read_build, build) && build.artifacts?
- = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
+ - if can?(current_user, :read_build, job) && job.artifacts?
+ = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
= icon('download')
- - if can?(current_user, :update_build, build)
- - if build.active?
- = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
+ - if can?(current_user, :update_build, job)
+ - if job.active?
+ = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
= icon('remove', class: 'cred')
- elsif allow_retry
- - if build.playable? && !admin
- = link_to play_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
+ - if job.playable? && !admin
+ = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
= custom_icon('icon_play')
- - elsif build.retryable?
- = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
+ - elsif job.retryable?
+ = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
= icon('repeat')
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index d5fc283aa8d..0d11da2451a 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -10,6 +10,7 @@
- else
.block-connector
= render "projects/diffs/diffs", diffs: @diffs, environment: @environment
+
= render "projects/notes/notes_with_form"
- if can_collaborate_with_project?
- %w(revert cherry-pick).each do |type|
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index c09c7b87e24..3e426ee9e7d 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -4,7 +4,7 @@
- type = line.type
- line_code = diff_file.line_code(line)
- if discussions && !line.meta?
- - discussion = discussions[line_code]
+ - line_discussions = discussions[line_code]
%tr.line_holder{ class: type, id: (line_code unless plain) }
- case type
- when 'match'
@@ -20,6 +20,7 @@
= link_text
- else
%a{ href: "##{line_code}", data: { linenumber: link_text } }
+ - discussion = line_discussions.try(:first)
- if discussion && discussion.resolvable? && !plain
%diff-note-avatars{ "discussion-id" => discussion.id }
%td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } }
@@ -34,6 +35,6 @@
- else
= diff_line_content(line.text)
-- if discussion
- - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?)
- = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded
+- if line_discussions
+ - discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?))
+ = render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index b7346f27ddb..f920f359de2 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -6,7 +6,7 @@
- right = line[:right]
- last_line = right.new_pos if right
- unless @diff_notes_disabled
- - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file)
+ - discussions_left, discussions_right = parallel_diff_discussions(left, right, diff_file)
%tr.line_holder.parallel
- if left
- case left.type
@@ -20,6 +20,7 @@
- left_position = diff_file.position(left)
%td.old_line.diff-line-num.js-avatar-container{ id: left_line_code, class: left.type, data: { linenumber: left.old_pos } }
%a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } }
+ - discussion_left = discussions_left.try(:first)
- if discussion_left && discussion_left.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_left.id }
%td.line_content.parallel.noteable_line{ class: left.type, data: diff_view_line_data(left_line_code, left_position, 'old') }= diff_line_content(left.text)
@@ -39,6 +40,7 @@
- right_position = diff_file.position(right)
%td.new_line.diff-line-num.js-avatar-container{ id: right_line_code, class: right.type, data: { linenumber: right.new_pos } }
%a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } }
+ - discussion_right = discussions_right.try(:first)
- if discussion_right && discussion_right.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_right.id }
%td.line_content.parallel.noteable_line{ class: right.type, data: diff_view_line_data(right_line_code, right_position, 'new') }= diff_line_content(right.text)
@@ -46,8 +48,8 @@
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel
- - if discussion_left || discussion_right
- = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right
+ - if discussions_left || discussions_right
+ = render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right
- if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any?
- last_line = diff_file.diff_lines.last
- if last_line.new_pos < total_lines
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index ff6aaebda22..7315e671056 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -8,9 +8,9 @@
%h3.page-title= @environment.name
.col-md-5
.nav-controls
- = render 'projects/environments/metrics_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment
+ = render 'projects/environments/metrics_button', environment: @environment
- if can?(current_user, :update_environment, @environment)
= link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn'
- if can?(current_user, :create_deployment, @environment) && @environment.can_stop?
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index cfb44bd206c..15b5a51c1d0 100644
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ b/app/views/projects/merge_requests/_discussion.html.haml
@@ -1,9 +1,9 @@
- content_for :note_actions do
- if can?(current_user, :update_merge_request, @merge_request)
- if @merge_request.open?
- = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"}
+ = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- if @merge_request.reopenable?
- = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
+ = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
%comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" }
%button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
{{ buttonText }}
diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/projects/notes/_comment_button.html.haml
new file mode 100644
index 00000000000..6bb55f04b6e
--- /dev/null
+++ b/app/views/projects/notes/_comment_button.html.haml
@@ -0,0 +1,30 @@
+- noteable_name = @note.noteable.human_class_name
+
+.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown
+ %input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' }
+
+ - if @note.can_be_discussion_note?
+ = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => 'Open comment type dropdown' do
+ = icon('caret-down', class: 'toggle-icon')
+
+ %ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } }
+ %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } }
+ %a{ href: '#' }
+ = icon('check')
+ .description
+ %strong Comment
+ %p
+ Add a general comment to this #{noteable_name}.
+
+ %li.divider
+
+ %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } }
+ %a{ href: '#' }
+ = icon('check')
+ .description
+ %strong Start discussion
+ %p
+ = succeed '.' do
+ Discuss a specific suggestion or question
+ - if @note.noteable.supports_resolvable_notes?
+ that needs to be resolved
diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml
index b561052e721..0d835a9e949 100644
--- a/app/views/projects/notes/_form.html.haml
+++ b/app/views/projects/notes/_form.html.haml
@@ -4,12 +4,18 @@
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
+ = hidden_field_tag :in_reply_to_discussion_id
+
= note_target_fields(@note)
- = f.hidden_field :commit_id
- = f.hidden_field :line_code
- = f.hidden_field :noteable_id
= f.hidden_field :noteable_type
+ = f.hidden_field :noteable_id
+ = f.hidden_field :commit_id
= f.hidden_field :type
+
+ -# LegacyDiffNote
+ = f.hidden_field :line_code
+
+ -# DiffNote
= f.hidden_field :position
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
@@ -22,7 +28,9 @@
.error-alert
.note-form-actions.clearfix
- = f.submit 'Comment', class: "btn btn-nr btn-create append-right-10 comment-btn js-comment-button"
+ = render partial: 'projects/notes/comment_button'
+
= yield(:note_actions)
+
%a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } }
Discard draft
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
index 9130dc128fa..c12c05eeb73 100644
--- a/app/views/projects/notes/_note.html.haml
+++ b/app/views/projects/notes/_note.html.haml
@@ -34,7 +34,7 @@
- if note.resolvable?
- can_resolve = can?(current_user, :resolve_note, note)
%resolve-btn{ "project-path" => project_path(note.project),
- "discussion-id" => note.discussion_id,
+ "discussion-id" => note.discussion_id(@noteable),
":note-id" => note.id,
":resolved" => note.resolved?,
":can-resolve" => can_resolve,
diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/projects/notes/_notes.html.haml
index 022578bd6db..2b2bab09c74 100644
--- a/app/views/projects/notes/_notes.html.haml
+++ b/app/views/projects/notes/_notes.html.haml
@@ -1,7 +1,7 @@
-- if @discussions.present?
+- if defined?(@discussions)
- @discussions.each do |discussion|
- - if discussion.for_target?(@noteable)
- = render partial: "projects/notes/note", object: discussion.first_note, as: :note
+ - if discussion.individual_note?
+ = render partial: "projects/notes/note", collection: discussion.notes, as: :note
- else
= render 'discussions/discussion', discussion: discussion
- else
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index fc1d0989787..ab6baaf35b6 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,6 +1,6 @@
.page-content-header
.header-main-content
- = render 'ci/status/badge', status: @pipeline.detailed_status(current_user)
+ = render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title
%strong Pipeline ##{@pipeline.id}
triggered #{time_ago_with_tooltip(@pipeline.created_at)}
- if @pipeline.user
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index 132f6372e40..a3f84476dea 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -21,7 +21,7 @@
Git strategy for pipelines
%p
Choose between <code>clone</code> or <code>fetch</code> to get the recent application code
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank'
.radio
= f.label :build_allow_git_fetch_false do
= f.radio_button :build_allow_git_fetch, 'false'
@@ -43,7 +43,7 @@
= f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0'
%p.help-block
Per job in minutes. If a job passes this threshold, it will be marked as failed.
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank'
%hr
.form-group
@@ -53,7 +53,16 @@
%strong Public pipelines
.help-block
Allow everyone to access pipelines for public and internal projects
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank'
+ %hr
+ .form-group
+ .checkbox
+ = f.label :auto_cancel_pending_pipelines do
+ = f.check_box :auto_cancel_pending_pipelines, {}, 'enabled', 'disabled'
+ %strong Auto-cancel redundant, pending pipelines
+ .help-block
+ New pipelines will cancel older, pending pipelines on the same branch
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank'
%hr
.form-group
@@ -65,7 +74,7 @@
%p.help-block
A regular expression that will be used to find the test coverage
output in the job trace. Leave blank to disable
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank'
.bs-callout.bs-callout-info
%p Below are examples of regex for existing tools:
%ul
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
index 4d8169815b3..f8cfe5e4b11 100644
--- a/app/views/projects/protected_branches/show.html.haml
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -1,13 +1,13 @@
-- page_title @protected_branch.name, "Protected Branches"
+- page_title @protected_ref.name, "Protected Branches"
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
- = @protected_branch.name
+ = @protected_ref.name
.col-lg-9
%h5 Matching Branches
- - if @matching_branches.present?
+ - if @matching_refs.present?
.table-responsive
%table.table.protected-branches-list
%colgroup
@@ -18,7 +18,7 @@
%th Branch
%th Last commit
%tbody
- - @matching_branches.each do |matching_branch|
+ - @matching_refs.each do |matching_branch|
= render partial: "matching_branch", object: matching_branch
- else
%p.settings-message.text-center
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
new file mode 100644
index 00000000000..6e187b54a59
--- /dev/null
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -0,0 +1,32 @@
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f|
+ .panel.panel-default
+ .panel-heading
+ %h3.panel-title
+ Protect a tag
+ .panel-body
+ .form-horizontal
+ = form_errors(@protected_tag)
+ .form-group
+ = f.label :name, class: 'col-md-2 text-right' do
+ Tag:
+ .col-md-10
+ = render partial: "projects/protected_tags/dropdown", locals: { f: f }
+ .help-block
+ = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags')
+ such as
+ %code v*
+ or
+ %code *-release
+ are supported
+ .form-group
+ %label.col-md-2.text-right{ for: 'create_access_levels_attributes' }
+ Allowed to create:
+ .col-md-10
+ .create_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-create wide',
+ dropdown_class: 'dropdown-menu-selectable',
+ data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
+
+ .panel-footer
+ = f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
new file mode 100644
index 00000000000..74851519077
--- /dev/null
+++ b/app/views/projects/protected_tags/_dropdown.html.haml
@@ -0,0 +1,15 @@
+= f.hidden_field(:name)
+
+= dropdown_tag('Select tag or create wildcard',
+ options: { toggle_class: 'js-protected-tag-select js-filter-submit wide',
+ filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag",
+ footer_content: true,
+ data: { show_no: true, show_any: true, show_upcoming: true,
+ selected: params[:protected_tag_name],
+ project_id: @project.try(:id) } }) do
+
+ %ul.dropdown-footer-list
+ %li
+ = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do
+ Create wildcard
+ %code
diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml
new file mode 100644
index 00000000000..0bfb1ad191d
--- /dev/null
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('protected_tags')
+
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Protected tags
+ %p.prepend-top-20
+ By default, Protected tags are designed to:
+ %ul
+ %li Prevent tag creation by everybody except Masters
+ %li Prevent <strong>anyone</strong> from updating the tag
+ %li Prevent <strong>anyone</strong> from deleting the tag
+ .col-lg-9
+ - if can? current_user, :admin_project, @project
+ = render 'projects/protected_tags/create_protected_tag'
+
+ = render "projects/protected_tags/tags_list"
diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml
new file mode 100644
index 00000000000..97e5cd6f9d2
--- /dev/null
+++ b/app/views/projects/protected_tags/_matching_tag.html.haml
@@ -0,0 +1,9 @@
+%tr
+ %td
+ = link_to matching_tag.name, namespace_project_tree_path(@project.namespace, @project, matching_tag.name)
+ - if @project.root_ref?(matching_tag.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - commit = @project.commit(matching_tag.name)
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml
new file mode 100644
index 00000000000..26bd3a1f5ed
--- /dev/null
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -0,0 +1,21 @@
+%tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } }
+ %td
+ = protected_tag.name
+ - if @project.root_ref?(protected_tag.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - if protected_tag.wildcard?
+ - matching_tags = protected_tag.matching(repository.tags)
+ = link_to pluralize(matching_tags.count, "matching tag"), namespace_project_protected_tag_path(@project.namespace, @project, protected_tag)
+ - else
+ - if commit = protected_tag.commit
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = time_ago_with_tooltip(commit.committed_date)
+ - else
+ (tag was removed from repository)
+
+ = render partial: 'projects/protected_tags/update_protected_tag', locals: { protected_tag: protected_tag }
+
+ - if can_admin_project
+ %td
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml
new file mode 100644
index 00000000000..728afd75b50
--- /dev/null
+++ b/app/views/projects/protected_tags/_tags_list.html.haml
@@ -0,0 +1,28 @@
+.panel.panel-default.protected-tags-list.js-protected-tags-list
+ - if @protected_tags.empty?
+ .panel-heading
+ %h3.panel-title
+ Protected tag (#{@protected_tags.size})
+ %p.settings-message.text-center
+ There are currently no protected tags, protect a tag with the form above.
+ - else
+ - can_admin_project = can?(current_user, :admin_project, @project)
+
+ %table.table.table-bordered
+ %colgroup
+ %col{ width: "25%" }
+ %col{ width: "25%" }
+ %col{ width: "50%" }
+ %thead
+ %tr
+ %th Protected tag (#{@protected_tags.size})
+ %th Last commit
+ %th Allowed to create
+ - if can_admin_project
+ %th
+ %tbody
+ %tr
+ %td.flash-container{ colspan: 4 }
+ = render partial: 'projects/protected_tags/protected_tag', collection: @protected_tags, locals: { can_admin_project: can_admin_project}
+
+ = paginate @protected_tags, theme: 'gitlab'
diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml
new file mode 100644
index 00000000000..62823bee46e
--- /dev/null
+++ b/app/views/projects/protected_tags/_update_protected_tag.haml
@@ -0,0 +1,5 @@
+%td
+ = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level
+ = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') ,
+ options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container',
+ data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }})
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
new file mode 100644
index 00000000000..63743f28b3c
--- /dev/null
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -0,0 +1,25 @@
+- page_title @protected_ref.name, "Protected Tags"
+
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ = @protected_ref.name
+
+ .col-lg-9
+ %h5 Matching Tags
+ - if @matching_refs.present?
+ .table-responsive
+ %table.table.protected-tags-list
+ %colgroup
+ %col{ width: "30%" }
+ %col{ width: "30%" }
+ %thead
+ %tr
+ %th Tag
+ %th Last commit
+ %tbody
+ - @matching_refs.each do |matching_tag|
+ = render partial: "matching_tag", object: matching_tag
+ - else
+ %p.settings-message.text-center
+ Couldn't find any matching tags.
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 4c02302e161..5402320cb66 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -3,3 +3,4 @@
= render @deploy_keys
= render "projects/protected_branches/index"
+= render "projects/protected_tags/index"
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index dffe908e85a..451e011a4b8 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -6,6 +6,11 @@
%span.item-title
= icon('tag')
= tag.name
+
+ - if protected_tag?(@project, tag)
+ %span.label.label-success
+ protected
+
- if tag.message.present?
&nbsp;
= strip_gpg_signature(tag.message)
@@ -30,5 +35,5 @@
= icon("pencil")
- if can?(current_user, :admin_project, @project)
- = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do
+ = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do
= icon("trash-o")
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index fad3c5c2173..1c4135c8a54 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -7,6 +7,9 @@
.nav-text
.title
%span.item-title= @tag.name
+ - if protected_tag?(@project, @tag)
+ %span.label.label-success
+ protected
- if @commit
= render 'projects/branches/commit', commit: @commit, project: @project
- else
@@ -24,7 +27,7 @@
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_project, @project)
.btn-container.controls-item-full
- = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do
+ = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do
%i.fa.fa-trash-o
- if @tag.message.present?
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index 5f708b3a2ed..8582bcbb8cc 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -8,4 +8,26 @@
.form-group
= f.label :key, "Description", class: "label-light"
= f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description"
+ - if @trigger.persisted?
+ %hr
+ = f.fields_for :trigger_schedule do |schedule_fields|
+ = schedule_fields.hidden_field :id
+ .form-group
+ .checkbox
+ = schedule_fields.label :active do
+ = schedule_fields.check_box :active
+ %strong Schedule trigger (experimental)
+ .help-block
+ If checked, this trigger will be executed periodically according to cron and timezone.
+ = link_to icon('question-circle'), help_page_path('ci/triggers', anchor: 'schedule')
+ .form-group
+ = schedule_fields.label :cron, "Cron", class: "label-light"
+ = schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *"
+ .form-group
+ = schedule_fields.label :cron, "Timezone", class: "label-light"
+ = schedule_fields.text_field :cron_timezone, class: "form-control", title: 'Timezone is required.', placeholder: "UTC"
+ .form-group
+ = schedule_fields.label :ref, "Branch or tag", class: "label-light"
+ = schedule_fields.text_field :ref, class: "form-control", title: 'Branch or tag is required.', placeholder: "master"
+ .help-block Existing branch name, tag
= f.submit btn_text, class: "btn btn-save"
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index cc74e50a5e3..84e945ee0df 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -22,6 +22,8 @@
%th
%strong Last used
%th
+ %strong Next run at
+ %th
= render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- else
%p.settings-message.text-center.append-bottom-default
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 9b5f63ae81a..ebd91a8e2af 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -29,6 +29,12 @@
- else
Never
+ %td
+ - if trigger.trigger_schedule&.active?
+ = trigger.trigger_schedule.real_next_run
+ - else
+ Never
+
%td.text-right.trigger-actions
- take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?"
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 8869d510aef..90ae3f06a98 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -1,12 +1,8 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('group')
- parent = GroupFinder.new(current_user).execute(id: params[:parent_id] || @group.parent_id)
- group_path = root_url
- group_path << parent.full_path + '/' if parent
-- if @group.persisted?
- .form-group
- = f.label :name, class: 'control-label' do
- Group name
- .col-sm-10
- = f.text_field :name, placeholder: 'open-source', class: 'form-control'
.form-group
= f.label :path, class: 'control-label' do
@@ -20,7 +16,7 @@
= f.text_field :path, placeholder: 'open-source', class: 'form-control',
autofocus: local_assigns[:autofocus] || false, required: true,
pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS,
- title: 'Please choose a group name with no special characters.',
+ title: 'Please choose a group path with no special characters.',
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- if parent
= f.hidden_field :parent_id, value: parent.id
@@ -33,6 +29,14 @@
%li It will change web url for access group and group projects.
%li It will change the git path to repositories under this group.
+.form-group.group-name-holder
+ = f.label :name, class: 'control-label' do
+ Group name
+ .col-sm-10
+ = f.text_field :name, class: 'form-control',
+ required: true,
+ title: 'You can choose a descriptive name different from the path.'
+
.form-group.group-description-holder
= f.label :description, class: 'control-label'
.col-sm-10
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 9e241c3ea12..b447996a8ab 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -23,7 +23,7 @@
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
- %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } }
+ %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } }
= icon('filter')
%button.clear-search.hidden{ type: 'button' }
= icon('times')
diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb
index 440c579b99d..9c1baf7e6c5 100644
--- a/app/workers/trigger_schedule_worker.rb
+++ b/app/workers/trigger_schedule_worker.rb
@@ -3,7 +3,7 @@ class TriggerScheduleWorker
include CronjobQueue
def perform
- Ci::TriggerSchedule.where("next_run_at < ?", Time.now).find_each do |trigger_schedule|
+ Ci::TriggerSchedule.active.where("next_run_at < ?", Time.now).find_each do |trigger_schedule|
begin
Ci::CreateTriggerRequestService.new.execute(trigger_schedule.project,
trigger_schedule.trigger,
diff --git a/changelogs/unreleased/18471-restrict-tag-pushes-protected-tags.yml b/changelogs/unreleased/18471-restrict-tag-pushes-protected-tags.yml
new file mode 100644
index 00000000000..fabe24e485a
--- /dev/null
+++ b/changelogs/unreleased/18471-restrict-tag-pushes-protected-tags.yml
@@ -0,0 +1,4 @@
+---
+title: Tags can be protected, restricting creation of matching tags by user role
+merge_request: 10356
+author:
diff --git a/changelogs/unreleased/24240-add-monitoring-endpoints.yml b/changelogs/unreleased/24240-add-monitoring-endpoints.yml
new file mode 100644
index 00000000000..a22458965fc
--- /dev/null
+++ b/changelogs/unreleased/24240-add-monitoring-endpoints.yml
@@ -0,0 +1,4 @@
+---
+title: Add /-/readiness /-/liveness and /-/metrics endpoints to track application health
+merge_request: 10416
+author:
diff --git a/changelogs/unreleased/27580-fix-show-go-back.yml b/changelogs/unreleased/27580-fix-show-go-back.yml
new file mode 100644
index 00000000000..c7dbbe7a236
--- /dev/null
+++ b/changelogs/unreleased/27580-fix-show-go-back.yml
@@ -0,0 +1,4 @@
+---
+title: Shows 'Go Back' link only when browser history is available
+merge_request: 9017
+author:
diff --git a/changelogs/unreleased/28574-jira-trigers.yml b/changelogs/unreleased/28574-jira-trigers.yml
new file mode 100644
index 00000000000..6ebd2c0c2c2
--- /dev/null
+++ b/changelogs/unreleased/28574-jira-trigers.yml
@@ -0,0 +1,4 @@
+---
+title: Remove confusing placeholder for JIRA transition_id
+merge_request:
+author:
diff --git a/changelogs/unreleased/30056-rename-milestones-empty.yml b/changelogs/unreleased/30056-rename-milestones-empty.yml
new file mode 100644
index 00000000000..85db342b6df
--- /dev/null
+++ b/changelogs/unreleased/30056-rename-milestones-empty.yml
@@ -0,0 +1,4 @@
+---
+title: Removed Milestone#is_empty?
+merge_request: 10523
+author: Jacopo Beschi @jacopo-beschi
diff --git a/changelogs/unreleased/30587-pipeline-icon-z.yml b/changelogs/unreleased/30587-pipeline-icon-z.yml
new file mode 100644
index 00000000000..548d16ce142
--- /dev/null
+++ b/changelogs/unreleased/30587-pipeline-icon-z.yml
@@ -0,0 +1,4 @@
+---
+title: fix Status icons overlapping sidebar on mobile
+merge_request:
+author:
diff --git a/changelogs/unreleased/30588-fix-javascript-sourcemaps-w-chrome-breakpoints.yml b/changelogs/unreleased/30588-fix-javascript-sourcemaps-w-chrome-breakpoints.yml
new file mode 100644
index 00000000000..9cff3c2776f
--- /dev/null
+++ b/changelogs/unreleased/30588-fix-javascript-sourcemaps-w-chrome-breakpoints.yml
@@ -0,0 +1,4 @@
+---
+title: Upgrade webpack to v2.3.3 and webpack-dev-server to v2.4.2
+merge_request: 10552
+author:
diff --git a/changelogs/unreleased/8998_skip_pending_commits_if_not_head.yml b/changelogs/unreleased/8998_skip_pending_commits_if_not_head.yml
new file mode 100644
index 00000000000..9852cd6e4ff
--- /dev/null
+++ b/changelogs/unreleased/8998_skip_pending_commits_if_not_head.yml
@@ -0,0 +1,4 @@
+---
+title: Cancel pending pipelines if commits not HEAD
+merge_request: 9362
+author: Rydkin Maxim
diff --git a/changelogs/unreleased/adam-finish-5993-closed-issuable.yml b/changelogs/unreleased/adam-finish-5993-closed-issuable.yml
new file mode 100644
index 00000000000..b324566313f
--- /dev/null
+++ b/changelogs/unreleased/adam-finish-5993-closed-issuable.yml
@@ -0,0 +1,4 @@
+---
+title: Add indication for closed or merged issuables in GFM
+merge_request: 9462
+author: Adam Buckland
diff --git a/changelogs/unreleased/add-field-for-group-name.yml b/changelogs/unreleased/add-field-for-group-name.yml
new file mode 100644
index 00000000000..0fe511a4fa1
--- /dev/null
+++ b/changelogs/unreleased/add-field-for-group-name.yml
@@ -0,0 +1,4 @@
+---
+title: Add a name field to the group form
+merge_request: 9891
+author: Douglas Lovell
diff --git a/changelogs/unreleased/add-ui-for-trigger-schedule.yml b/changelogs/unreleased/add-ui-for-trigger-schedule.yml
new file mode 100644
index 00000000000..9ca78983605
--- /dev/null
+++ b/changelogs/unreleased/add-ui-for-trigger-schedule.yml
@@ -0,0 +1,4 @@
+---
+title: Add UI for Trigger Schedule
+merge_request: 10533
+author: dosuken123
diff --git a/changelogs/unreleased/clean_carrierwave_tempfiles.yml b/changelogs/unreleased/clean_carrierwave_tempfiles.yml
new file mode 100644
index 00000000000..53fa69700ff
--- /dev/null
+++ b/changelogs/unreleased/clean_carrierwave_tempfiles.yml
@@ -0,0 +1,4 @@
+---
+title: Periodically clean up temporary upload files to recover storage space
+merge_request: 9466
+author: blackst0ne
diff --git a/changelogs/unreleased/dz-hide-zero-counter.yml b/changelogs/unreleased/dz-hide-zero-counter.yml
new file mode 100644
index 00000000000..45f35044c48
--- /dev/null
+++ b/changelogs/unreleased/dz-hide-zero-counter.yml
@@ -0,0 +1,4 @@
+---
+title: Hide header counters for issue/mr/todos if zero
+merge_request: 10506
+author:
diff --git a/changelogs/unreleased/metrics-button-misplaced.yml b/changelogs/unreleased/metrics-button-misplaced.yml
new file mode 100644
index 00000000000..6c685ff32a5
--- /dev/null
+++ b/changelogs/unreleased/metrics-button-misplaced.yml
@@ -0,0 +1,4 @@
+---
+title: Moved the monitoring button inside the show view for the environments page
+merge_request:
+author:
diff --git a/changelogs/unreleased/new-resolvable-discussion.yml b/changelogs/unreleased/new-resolvable-discussion.yml
new file mode 100644
index 00000000000..f4dc4ea3ede
--- /dev/null
+++ b/changelogs/unreleased/new-resolvable-discussion.yml
@@ -0,0 +1,4 @@
+---
+title: Add option to start a new resolvable discussion in an MR
+merge_request: 7527
+author:
diff --git a/changelogs/unreleased/optimise-builds-view.yml b/changelogs/unreleased/optimise-builds-view.yml
new file mode 100644
index 00000000000..1d715ab4f47
--- /dev/null
+++ b/changelogs/unreleased/optimise-builds-view.yml
@@ -0,0 +1,4 @@
+---
+title: Optimise builds endpoint
+merge_request:
+author:
diff --git a/changelogs/unreleased/remove_is_admin.yml b/changelogs/unreleased/remove_is_admin.yml
new file mode 100644
index 00000000000..f6baf1942de
--- /dev/null
+++ b/changelogs/unreleased/remove_is_admin.yml
@@ -0,0 +1,4 @@
+---
+title: Remove the User#is_admin? method
+merge_request: 10520
+author: blackst0ne
diff --git a/config/routes.rb b/config/routes.rb
index 1a851da6203..1da226a3b57 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -39,6 +39,12 @@ Rails.application.routes.draw do
# Health check
get 'health_check(/:checks)' => 'health_check#index', as: :health_check
+ scope path: '-', controller: 'health' do
+ get :liveness
+ get :readiness
+ get :metrics
+ end
+
# Koding route
get 'koding' => 'koding#index'
diff --git a/config/routes/project.rb b/config/routes/project.rb
index e38f0537143..f5009186344 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -135,6 +135,8 @@ constraints(ProjectUrlConstrainer.new) do
end
resources :protected_branches, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex }
+ resources :protected_tags, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex }
+
resources :variables, only: [:index, :show, :update, :create, :destroy]
resources :triggers, only: [:index, :create, :edit, :update, :destroy] do
member do
diff --git a/config/webpack.config.js b/config/webpack.config.js
index dc431e4d566..e3bc939d578 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -41,6 +41,7 @@ var config = {
pdf_viewer: './blob/pdf_viewer.js',
profile: './profile/profile_bundle.js',
protected_branches: './protected_branches/protected_branches_bundle.js',
+ protected_tags: './protected_tags',
snippet: './snippet/snippet_bundle.js',
stl_viewer: './blob/stl_viewer.js',
terminal: './terminal/terminal_bundle.js',
@@ -48,6 +49,7 @@ var config = {
users: './users/users_bundle.js',
vue_pipelines: './vue_pipelines_index/index.js',
issue_show: './issue_show/index.js',
+ group: './group.js',
},
output: {
diff --git a/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb b/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb
new file mode 100644
index 00000000000..d56d83ca1d3
--- /dev/null
+++ b/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb
@@ -0,0 +1,29 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddInReplyToDiscussionIdToSentNotifications < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ # 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 = ''
+
+ # 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 :sent_notifications, :in_reply_to_discussion_id, :string
+ end
+end
diff --git a/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb b/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb
new file mode 100644
index 00000000000..aa64f2dddca
--- /dev/null
+++ b/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb
@@ -0,0 +1,15 @@
+class AddAutoCancelPendingPipelinesToProject < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:projects, :auto_cancel_pending_pipelines, :integer, default: 0)
+ end
+
+ def down
+ remove_column(:projects, :auto_cancel_pending_pipelines)
+ end
+end
diff --git a/db/migrate/20170309173138_create_protected_tags.rb b/db/migrate/20170309173138_create_protected_tags.rb
new file mode 100644
index 00000000000..796f3c90344
--- /dev/null
+++ b/db/migrate/20170309173138_create_protected_tags.rb
@@ -0,0 +1,27 @@
+class CreateProtectedTags < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ GITLAB_ACCESS_MASTER = 40
+
+ def change
+ create_table :protected_tags do |t|
+ t.integer :project_id, null: false
+ t.string :name, null: false
+ t.timestamps null: false
+ end
+
+ add_index :protected_tags, :project_id
+
+ create_table :protected_tag_create_access_levels do |t|
+ t.references :protected_tag, index: { name: "index_protected_tag_create_access" }, foreign_key: true, null: false
+ t.integer :access_level, default: GITLAB_ACCESS_MASTER, null: true
+ t.references :user, foreign_key: true, index: true
+ t.integer :group_id
+ t.timestamps null: false
+ end
+
+ add_foreign_key :protected_tag_create_access_levels, :namespaces, column: :group_id # rubocop: disable Migration/AddConcurrentForeignKey
+ end
+end
diff --git a/db/migrate/20170312114329_add_auto_canceled_by_id_to_pipeline.rb b/db/migrate/20170312114329_add_auto_canceled_by_id_to_pipeline.rb
new file mode 100644
index 00000000000..1690ce90564
--- /dev/null
+++ b/db/migrate/20170312114329_add_auto_canceled_by_id_to_pipeline.rb
@@ -0,0 +1,9 @@
+class AddAutoCanceledByIdToPipeline < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_pipelines, :auto_canceled_by_id, :integer
+ end
+end
diff --git a/db/migrate/20170312114529_add_auto_canceled_by_id_foreign_key_to_pipeline.rb b/db/migrate/20170312114529_add_auto_canceled_by_id_foreign_key_to_pipeline.rb
new file mode 100644
index 00000000000..1e7b02ecf0e
--- /dev/null
+++ b/db/migrate/20170312114529_add_auto_canceled_by_id_foreign_key_to_pipeline.rb
@@ -0,0 +1,22 @@
+class AddAutoCanceledByIdForeignKeyToPipeline < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ on_delete =
+ if Gitlab::Database.mysql?
+ :nullify
+ else
+ 'SET NULL'
+ end
+
+ add_concurrent_foreign_key :ci_pipelines, :ci_pipelines, column: :auto_canceled_by_id, on_delete: on_delete
+ end
+
+ def down
+ remove_foreign_key :ci_pipelines, column: :auto_canceled_by_id
+ end
+end
diff --git a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb
index 0237c3189a5..9d4380ef960 100644
--- a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb
+++ b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb
@@ -15,7 +15,7 @@ class RemoveIndexForUsersCurrentSignInAt < ActiveRecord::Migration
if Gitlab::Database.postgresql?
execute 'DROP INDEX CONCURRENTLY index_users_on_current_sign_in_at;'
else
- remove_index :users, :current_sign_in_at
+ remove_concurrent_index :users, :current_sign_in_at
end
end
end
diff --git a/db/migrate/20170406114958_add_auto_canceled_by_id_to_ci_builds.rb b/db/migrate/20170406114958_add_auto_canceled_by_id_to_ci_builds.rb
new file mode 100644
index 00000000000..c1d803b4308
--- /dev/null
+++ b/db/migrate/20170406114958_add_auto_canceled_by_id_to_ci_builds.rb
@@ -0,0 +1,9 @@
+class AddAutoCanceledByIdToCiBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_builds, :auto_canceled_by_id, :integer
+ end
+end
diff --git a/db/migrate/20170406115029_add_auto_canceled_by_id_foreign_key_to_ci_builds.rb b/db/migrate/20170406115029_add_auto_canceled_by_id_foreign_key_to_ci_builds.rb
new file mode 100644
index 00000000000..3004683933b
--- /dev/null
+++ b/db/migrate/20170406115029_add_auto_canceled_by_id_foreign_key_to_ci_builds.rb
@@ -0,0 +1,22 @@
+class AddAutoCanceledByIdForeignKeyToCiBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ on_delete =
+ if Gitlab::Database.mysql?
+ :nullify
+ else
+ 'SET NULL'
+ end
+
+ add_concurrent_foreign_key :ci_builds, :ci_pipelines, column: :auto_canceled_by_id, on_delete: on_delete
+ end
+
+ def down
+ remove_foreign_key :ci_builds, column: :auto_canceled_by_id
+ end
+end
diff --git a/db/migrate/20170407114956_add_ref_to_ci_trigger_schedule.rb b/db/migrate/20170407114956_add_ref_to_ci_trigger_schedule.rb
new file mode 100644
index 00000000000..523a306f127
--- /dev/null
+++ b/db/migrate/20170407114956_add_ref_to_ci_trigger_schedule.rb
@@ -0,0 +1,9 @@
+class AddRefToCiTriggerSchedule < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_trigger_schedules, :ref, :string
+ end
+end
diff --git a/db/migrate/20170407122426_add_active_to_ci_trigger_schedule.rb b/db/migrate/20170407122426_add_active_to_ci_trigger_schedule.rb
new file mode 100644
index 00000000000..36892118ac0
--- /dev/null
+++ b/db/migrate/20170407122426_add_active_to_ci_trigger_schedule.rb
@@ -0,0 +1,9 @@
+class AddActiveToCiTriggerSchedule < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_trigger_schedules, :active, :boolean
+ end
+end
diff --git a/db/migrate/20170407140450_add_index_to_next_run_at_and_active.rb b/db/migrate/20170407140450_add_index_to_next_run_at_and_active.rb
new file mode 100644
index 00000000000..626c2a67fdc
--- /dev/null
+++ b/db/migrate/20170407140450_add_index_to_next_run_at_and_active.rb
@@ -0,0 +1,18 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndexToNextRunAtAndActive < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :ci_trigger_schedules, [:active, :next_run_at]
+ end
+
+ def down
+ remove_concurrent_index :ci_trigger_schedules, [:active, :next_run_at]
+ end
+end
diff --git a/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb b/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb
new file mode 100644
index 00000000000..0c3b3bd5eb3
--- /dev/null
+++ b/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb
@@ -0,0 +1,23 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveNotesOriginalDiscussionId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ # 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
+ remove_column :notes, :original_discussion_id, :string
+ end
+end
diff --git a/db/post_migrate/20170408033905_remove_old_cache_directories.rb b/db/post_migrate/20170408033905_remove_old_cache_directories.rb
new file mode 100644
index 00000000000..b23b52896b9
--- /dev/null
+++ b/db/post_migrate/20170408033905_remove_old_cache_directories.rb
@@ -0,0 +1,23 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+# Remove all files from old custom carrierwave's cache directories.
+# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9466
+
+class RemoveOldCacheDirectories < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # FileUploader cache.
+ FileUtils.rm_rf(Dir[Rails.root.join('public', 'uploads', 'tmp', '*')])
+ end
+
+ def down
+ # Old cache is not supposed to be recoverable.
+ # So the down method is empty.
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a7a8f5bd38f..3422847d729 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,8 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20170405080720) do
-
+ActiveRecord::Schema.define(version: 20170408033905) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "pg_trgm"
@@ -223,6 +222,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.string "token"
t.integer "lock_version"
t.string "coverage_regex"
+ t.integer "auto_canceled_by_id"
end
add_index "ci_builds", ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree
@@ -251,6 +251,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.integer "duration"
t.integer "user_id"
t.integer "lock_version"
+ t.integer "auto_canceled_by_id"
end
add_index "ci_pipelines", ["project_id", "ref", "status"], name: "index_ci_pipelines_on_project_id_and_ref_and_status", using: :btree
@@ -309,8 +310,11 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.string "cron"
t.string "cron_timezone"
t.datetime "next_run_at"
+ t.string "ref"
+ t.boolean "active"
end
+ add_index "ci_trigger_schedules", ["active", "next_run_at"], name: "index_ci_trigger_schedules_on_active_and_next_run_at", using: :btree
add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at", using: :btree
add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree
@@ -752,7 +756,6 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.datetime "resolved_at"
t.integer "resolved_by_id"
t.string "discussion_id"
- t.string "original_discussion_id"
t.text "note_html"
end
@@ -947,6 +950,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.boolean "lfs_enabled"
t.text "description_html"
t.boolean "only_allow_merge_if_all_discussions_are_resolved"
+ t.integer "auto_cancel_pending_pipelines", default: 0, null: false
t.boolean "printing_merge_request_link_enabled", default: true, null: false
t.string "import_jid"
end
@@ -993,6 +997,27 @@ ActiveRecord::Schema.define(version: 20170405080720) do
add_index "protected_branches", ["project_id"], name: "index_protected_branches_on_project_id", using: :btree
+ create_table "protected_tag_create_access_levels", force: :cascade do |t|
+ t.integer "protected_tag_id", null: false
+ t.integer "access_level", default: 40
+ t.integer "user_id"
+ t.integer "group_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "protected_tag_create_access_levels", ["protected_tag_id"], name: "index_protected_tag_create_access", using: :btree
+ add_index "protected_tag_create_access_levels", ["user_id"], name: "index_protected_tag_create_access_levels_on_user_id", using: :btree
+
+ create_table "protected_tags", force: :cascade do |t|
+ t.integer "project_id", null: false
+ t.string "name", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "protected_tags", ["project_id"], name: "index_protected_tags_on_project_id", using: :btree
+
create_table "releases", force: :cascade do |t|
t.string "tag"
t.text "description"
@@ -1028,6 +1053,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.string "line_code"
t.string "note_type"
t.text "position"
+ t.string "in_reply_to_discussion_id"
end
add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree
@@ -1328,6 +1354,8 @@ ActiveRecord::Schema.define(version: 20170405080720) do
add_foreign_key "boards", "projects"
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
+ add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify
+ add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify
add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade
add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade
add_foreign_key "container_repositories", "projects"
@@ -1348,6 +1376,9 @@ ActiveRecord::Schema.define(version: 20170405080720) do
add_foreign_key "project_statistics", "projects", 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 "protected_tag_create_access_levels", "namespaces", column: "group_id"
+ add_foreign_key "protected_tag_create_access_levels", "protected_tags"
+ add_foreign_key "protected_tag_create_access_levels", "users"
add_foreign_key "subscriptions", "projects", on_delete: :cascade
add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade
add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade
diff --git a/doc/README.md b/doc/README.md
index df11d5e49a8..b0b3d2156a7 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -18,62 +18,62 @@ All technical content published by GitLab lives in the documentation, including:
- [Account Security](user/profile/account/two_factor_authentication.md) Securing your account via two-factor authentication, etc.
- [API](api/README.md) Automate GitLab via a simple and powerful API.
- [CI/CD](ci/README.md) GitLab Continuous Integration (CI) and Continuous Delivery (CD) getting started, `.gitlab-ci.yml` options, and examples.
-- [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab.
- [Container Registry](user/project/container_registry.md) Learn how to use GitLab Container Registry.
+- [Git Attributes](user/project/git_attributes.md) Managing Git attributes using a `.gitattributes` file.
+- [Git cheatsheet](https://gitlab.com/gitlab-com/marketing/raw/master/design/print/git-cheatsheet/print-pdf/git-cheatsheet.pdf) Download a PDF describing the most used Git operations.
+- [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab.
- [GitLab basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab.
- [GitLab Pages](user/project/pages/index.md) Using GitLab Pages.
-- [Importing to GitLab](workflow/importing/README.md) Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab.
- [Importing and exporting projects between instances](user/project/settings/import_export.md).
+- [Importing to GitLab](workflow/importing/README.md) Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab.
- [Markdown](user/markdown.md) GitLab's advanced formatting system.
- [Migrating from SVN](workflow/importing/migrating_from_svn.md) Convert a SVN repository to Git and GitLab.
- [Permissions](user/permissions.md) Learn what each role in a project (external/guest/reporter/developer/master/owner) can do.
- [Profile Settings](profile/README.md)
- [Project Services](user/project/integrations/project_services.md) Integrate a project with external services, such as CI and chat.
- [Public access](public_access/public_access.md) Learn how you can allow public and internal access to projects.
+- [Search through GitLab](user/search/index.md): Search for issues, merge requests, projects, groups, todos, and issues in Issue Boards.
- [Snippets](user/snippets.md) Snippets allow you to create little bits of code.
- [SSH](ssh/README.md) Setup your ssh keys and deploy keys for secure access to your projects.
- [Webhooks](user/project/integrations/webhooks.md) Let GitLab notify you when new code has been pushed to your project.
- [Workflow](workflow/README.md) Using GitLab functionality and importing projects from GitHub and SVN.
-- [Git Attributes](user/project/git_attributes.md) Managing Git attributes using a `.gitattributes` file.
-- [Git cheatsheet](https://gitlab.com/gitlab-com/marketing/raw/master/design/print/git-cheatsheet/print-pdf/git-cheatsheet.pdf) Download a PDF describing the most used Git operations.
## Administrator documentation
- [Access restrictions](user/admin_area/settings/visibility_and_access_controls.md#enabled-git-access-protocols) Define which Git access protocols can be used to talk to GitLab
-- [Authentication/Authorization](administration/auth/README.md) Configure
- external authentication with LDAP, SAML, CAS and additional Omniauth providers.
+- [Authentication/Authorization](administration/auth/README.md) Configure external authentication with LDAP, SAML, CAS and additional Omniauth providers.
+- [Container Registry](administration/container_registry.md) Configure Docker Registry with GitLab.
- [Custom Git hooks](administration/custom_hooks.md) Custom Git hooks (on the filesystem) for when webhooks aren't enough.
+- [Debugging Tips](administration/troubleshooting/debug.md) Tips to debug problems when things go wrong
+- [Environment Variables](administration/environment_variables.md) to configure GitLab.
+- [Git LFS configuration](workflow/lfs/lfs_administration.md)
+- [GitLab Pages configuration](administration/pages/index.md) Configure GitLab Pages.
+- [GitLab performance monitoring with InfluxDB](administration/monitoring/performance/introduction.md) Configure GitLab and InfluxDB for measuring performance metrics.
+- [GitLab performance monitoring with Prometheus](administration/monitoring/prometheus/index.md) Configure GitLab and Prometheus for measuring performance metrics.
+- [Header logo](customization/branded_page_and_email_header.md) Change the logo on the overall page and email header.
+- [High Availability](administration/high_availability/README.md) Configure multiple servers for scaling or high availability.
+- [Housekeeping](administration/housekeeping.md) Keep your Git repository tidy and fast.
- [Install](install/README.md) Requirements, directory structures and installation from source.
-- [Restart GitLab](administration/restart_gitlab.md) Learn how to restart GitLab and its components.
- [Integration](integration/README.md) How to integrate with systems such as JIRA, Redmine, Twitter.
- [Issue closing pattern](administration/issue_closing_pattern.md) Customize how to close an issue from commit messages.
- [Koding](administration/integration/koding.md) Set up Koding to use with GitLab.
-- [Web terminals](administration/integration/terminal.md) Provide terminal access to environments from within GitLab.
- [Libravatar](customization/libravatar.md) Use Libravatar instead of Gravatar for user avatars.
- [Log system](administration/logs.md) Log system.
-- [Environment Variables](administration/environment_variables.md) to configure GitLab.
+- [Migrate GitLab CI to CE/EE](migrate_ci_to_ce/README.md) Follow this guide to migrate your existing GitLab CI data to GitLab CE/EE.
+- [Monitoring uptime](user/admin_area/monitoring/health_check.md) Check the server status using the health check endpoint.
- [Operations](administration/operations.md) Keeping GitLab up and running.
- [Raketasks](raketasks/README.md) Backups, maintenance, automatic webhook setup and the importing of projects.
+- [Reply by email](administration/reply_by_email.md) Allow users to comment on issues and merge requests by replying to notification emails.
- [Repository checks](administration/repository_checks.md) Periodic Git repository checks.
- [Repository storage paths](administration/repository_storage_paths.md) Manage the paths used to store repositories.
+- [Request Profiling](administration/monitoring/performance/request_profiling.md) Get a detailed profile on slow requests.
+- [Restart GitLab](administration/restart_gitlab.md) Learn how to restart GitLab and its components.
- [Security](security/README.md) Learn what you can do to further secure your GitLab instance.
+- [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md) Debug when Sidekiq appears hung and is not processing jobs.
- [System hooks](system_hooks/system_hooks.md) Notifications when users, projects and keys are changed.
- [Update](update/README.md) Update guides to upgrade your installation.
+- [Web terminals](administration/integration/terminal.md) Provide terminal access to environments from within GitLab.
- [Welcome message](customization/welcome_message.md) Add a custom welcome message to the sign-in page.
-- [Header logo](customization/branded_page_and_email_header.md) Change the logo on the overall page and email header.
-- [Reply by email](administration/reply_by_email.md) Allow users to comment on issues and merge requests by replying to notification emails.
-- [Migrate GitLab CI to CE/EE](migrate_ci_to_ce/README.md) Follow this guide to migrate your existing GitLab CI data to GitLab CE/EE.
-- [Git LFS configuration](workflow/lfs/lfs_administration.md)
-- [Housekeeping](administration/housekeeping.md) Keep your Git repository tidy and fast.
-- [GitLab Pages configuration](administration/pages/index.md) Configure GitLab Pages.
-- [GitLab performance monitoring with InfluxDB](administration/monitoring/performance/introduction.md) Configure GitLab and InfluxDB for measuring performance metrics.
-- [GitLab performance monitoring with Prometheus](administration/monitoring/prometheus/index.md) Configure GitLab and Prometheus for measuring performance metrics.
-- [Request Profiling](administration/monitoring/performance/request_profiling.md) Get a detailed profile on slow requests.
-- [Monitoring uptime](user/admin_area/monitoring/health_check.md) Check the server status using the health check endpoint.
-- [Debugging Tips](administration/troubleshooting/debug.md) Tips to debug problems when things go wrong
-- [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md) Debug when Sidekiq appears hung and is not processing jobs.
-- [High Availability](administration/high_availability/README.md) Configure multiple servers for scaling or high availability.
-- [Container Registry](administration/container_registry.md) Configure Docker Registry with GitLab.
## Contributor documentation
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index 53c29a4fd98..045d3821f66 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -311,7 +311,7 @@ Running on runner-8a2f473d-project-1796893-concurrent-0 via runner-8a2f473d-mach
++ export GITLAB_USER_ID=42
++ GITLAB_USER_ID=42
++ export GITLAB_USER_EMAIL=user@example.com
-++ GITLAB_USER_EMAIL=axilleas@axilleas.me
+++ GITLAB_USER_EMAIL=user@example.com
++ export VERY_SECURE_VARIABLE=imaverysecurevariable
++ VERY_SECURE_VARIABLE=imaverysecurevariable
++ mkdir -p /builds/gitlab-examples/ci-debug-trace.tmp
diff --git a/doc/gitlab-basics/create-group.md b/doc/gitlab-basics/create-group.md
index 64274ccd5eb..b4889bb8818 100644
--- a/doc/gitlab-basics/create-group.md
+++ b/doc/gitlab-basics/create-group.md
@@ -25,6 +25,8 @@ To create a group:
1. Set the "Group path" which will be the namespace under which your projects
will be hosted (path can contain only letters, digits, underscores, dashes
and dots; it cannot start with dashes or end in dot).
+ 1. The "Group name" will populate with the path. Optionally, you can change
+ it. This is the name that will display in the group views.
1. Optionally, you can add a description so that others can briefly understand
what this group is about.
1. Optionally, choose and avatar for your project.
diff --git a/doc/gitlab-basics/img/create_new_group_info.png b/doc/gitlab-basics/img/create_new_group_info.png
index 020b4ac00d6..8d2501d9f7a 100644
--- a/doc/gitlab-basics/img/create_new_group_info.png
+++ b/doc/gitlab-basics/img/create_new_group_info.png
Binary files differ
diff --git a/doc/university/glossary/README.md b/doc/university/glossary/README.md
index ec565c3e7bf..0b17e4ff7c1 100644
--- a/doc/university/glossary/README.md
+++ b/doc/university/glossary/README.md
@@ -411,6 +411,10 @@ An [object-relational](https://en.wikipedia.org/wiki/PostgreSQL) database. Toute
A [feature](https://docs.gitlab.com/ce/user/project/protected_branches.html) that protects branches from unauthorized pushes, force pushing or deletion.
+### Protected Tags
+
+A [feature](https://docs.gitlab.com/ce/user/project/protected_tags.html) that protects tags from unauthorized creation, update or deletion
+
### Pull
Git command to [synchronize](https://git-scm.com/docs/git-pull) the local repository with the remote repository, by fetching all remote changes and merging them into the local repository.
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 0ea6d01411f..3122e95fc0e 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -55,6 +55,7 @@ The following table depicts the various user permission levels in a project.
| Push to protected branches | | | | ✓ | ✓ |
| Enable/disable branch protection | | | | ✓ | ✓ |
| Turn on/off protected branch push for devs| | | | ✓ | ✓ |
+| Enable/disable tag protections | | | | ✓ | ✓ |
| Rewrite/remove Git tags | | | | ✓ | ✓ |
| Edit project | | | | ✓ | ✓ |
| Add deploy keys to project | | | | ✓ | ✓ |
diff --git a/doc/user/project/img/project_repository_settings.png b/doc/user/project/img/project_repository_settings.png
new file mode 100644
index 00000000000..1aa7efc36f1
--- /dev/null
+++ b/doc/user/project/img/project_repository_settings.png
Binary files differ
diff --git a/doc/user/project/img/protected_tag_matches.png b/doc/user/project/img/protected_tag_matches.png
new file mode 100644
index 00000000000..a36a11a1271
--- /dev/null
+++ b/doc/user/project/img/protected_tag_matches.png
Binary files differ
diff --git a/doc/user/project/img/protected_tags_list.png b/doc/user/project/img/protected_tags_list.png
new file mode 100644
index 00000000000..c5e42dc0705
--- /dev/null
+++ b/doc/user/project/img/protected_tags_list.png
Binary files differ
diff --git a/doc/user/project/img/protected_tags_page.png b/doc/user/project/img/protected_tags_page.png
new file mode 100644
index 00000000000..3848d91ebd6
--- /dev/null
+++ b/doc/user/project/img/protected_tags_page.png
Binary files differ
diff --git a/doc/user/project/img/protected_tags_permissions_dropdown.png b/doc/user/project/img/protected_tags_permissions_dropdown.png
new file mode 100644
index 00000000000..9e0fc4e2a43
--- /dev/null
+++ b/doc/user/project/img/protected_tags_permissions_dropdown.png
Binary files differ
diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md
index e02f81fd972..f611029afdc 100644
--- a/doc/user/project/integrations/jira.md
+++ b/doc/user/project/integrations/jira.md
@@ -101,7 +101,7 @@ in the table below.
| `Project key` | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. |
| `Username` | The user name created in [configuring JIRA step](#configuring-jira). |
| `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). |
-| `JIRA issue transition` | This is the ID of a transition that moves issues to a closed state. You can find this number under JIRA workflow administration ([see screenshot](img/jira_workflow_screenshot.png)). |
+| `JIRA issue transition` | This is the ID of a transition that moves issues to a closed state. You can find this number under JIRA workflow administration ([see screenshot](img/jira_workflow_screenshot.png)). **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** |
After saving the configuration, your GitLab project will be able to interact
with the linked JIRA project.
diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md
index c398ac2eb25..88246e22391 100644
--- a/doc/user/project/pipelines/settings.md
+++ b/doc/user/project/pipelines/settings.md
@@ -60,6 +60,14 @@ anyone and those logged in respectively. If you wish to hide it so that only
the members of the project or group have access to it, uncheck the **Public
pipelines** checkbox and save the changes.
+## Auto-cancel pending pipelines
+
+> [Introduced][ce-9362] in GitLab 9.1.
+
+If you want to auto-cancel all pending non-HEAD pipelines on branch, when
+new pipeline will be created (after your git push or manually from UI),
+check **Auto-cancel pending pipelines** checkbox and save the changes.
+
## Badges
In the pipelines settings page you can find pipeline status and test coverage
@@ -111,3 +119,4 @@ into your `README.md`:
[var]: ../../../ci/yaml/README.md#git-strategy
[coverage report]: #test-coverage-parsing
+[ce-9362]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9362
diff --git a/doc/user/project/protected_tags.md b/doc/user/project/protected_tags.md
new file mode 100644
index 00000000000..0cb7aefdb2f
--- /dev/null
+++ b/doc/user/project/protected_tags.md
@@ -0,0 +1,60 @@
+# Protected Tags
+
+> [Introduced][ce-10356] in GitLab 9.1.
+
+Protected Tags allow control over who has permission to create tags as well as preventing accidental update or deletion once created. Each rule allows you to match either an individual tag name, or use wildcards to control multiple tags at once.
+
+This feature evolved out of [Protected Branches](protected_branches.md)
+
+## Overview
+
+Protected tags will prevent anyone from updating or deleting the tag, as and will prevent creation of matching tags based on the permissions you have selected. By default, anyone without Master permission will be prevented from creating tags.
+
+
+## Configuring protected tags
+
+To protect a tag, you need to have at least Master permission level.
+
+1. Navigate to the project's Settings -> Repository page
+
+ ![Repository Settings](img/project_repository_settings.png)
+
+1. From the **Tag** dropdown menu, select the tag you want to protect or type and click `Create wildcard`. In the screenshot below, we chose to protect all tags matching `v*`.
+
+ ![Protected tags page](img/protected_tags_page.png)
+
+1. From the `Allowed to create` dropdown, select who will have permission to create matching tags and then click `Protect`.
+
+ ![Allowed to create tags dropdown](img/protected_tags_permissions_dropdown.png)
+
+1. Once done, the protected tag will appear in the "Protected tags" list.
+
+ ![Protected tags list](img/protected_tags_list.png)
+
+## Wildcard protected tags
+
+You can specify a wildcard protected tag, which will protect all tags
+matching the wildcard. For example:
+
+| Wildcard Protected Tag | Matching Tags |
+|------------------------+-------------------------------|
+| `v*` | `v1.0.0`, `version-9.1` |
+| `*-deploy` | `march-deploy`, `1.0-deploy` |
+| `*gitlab*` | `gitlab`, `gitlab/v1` |
+| `*` | `v1.0.1rc2`, `accidental-tag` |
+
+
+Two different wildcards can potentially match the same tag. For example,
+`*-stable` and `production-*` would both match a `production-stable` tag.
+In that case, if _any_ of these protected tags have a setting like
+"Allowed to create", then `production-stable` will also inherit this setting.
+
+If you click on a protected tag's name, you will be presented with a list of
+all matching tags:
+
+![Protected tag matches](img/protected_tag_matches.png)
+
+
+---
+
+[ce-10356]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10356 "Protected Tags"
diff --git a/doc/user/search/img/filter_issues_project.gif b/doc/user/search/img/filter_issues_project.gif
new file mode 100644
index 00000000000..d547588be5d
--- /dev/null
+++ b/doc/user/search/img/filter_issues_project.gif
Binary files differ
diff --git a/doc/user/search/img/issues_any_assignee.png b/doc/user/search/img/issues_any_assignee.png
new file mode 100755
index 00000000000..2f902bcc66c
--- /dev/null
+++ b/doc/user/search/img/issues_any_assignee.png
Binary files differ
diff --git a/doc/user/search/img/issues_assigned_to_you.png b/doc/user/search/img/issues_assigned_to_you.png
new file mode 100755
index 00000000000..36c670eedd5
--- /dev/null
+++ b/doc/user/search/img/issues_assigned_to_you.png
Binary files differ
diff --git a/doc/user/search/img/issues_author.png b/doc/user/search/img/issues_author.png
new file mode 100755
index 00000000000..792f9746db6
--- /dev/null
+++ b/doc/user/search/img/issues_author.png
Binary files differ
diff --git a/doc/user/search/img/issues_mrs_shortcut.png b/doc/user/search/img/issues_mrs_shortcut.png
new file mode 100755
index 00000000000..6380b337b54
--- /dev/null
+++ b/doc/user/search/img/issues_mrs_shortcut.png
Binary files differ
diff --git a/doc/user/search/img/left_menu_bar.png b/doc/user/search/img/left_menu_bar.png
new file mode 100755
index 00000000000..d68a71cba8e
--- /dev/null
+++ b/doc/user/search/img/left_menu_bar.png
Binary files differ
diff --git a/doc/user/search/img/project_search.png b/doc/user/search/img/project_search.png
new file mode 100755
index 00000000000..3150b40de29
--- /dev/null
+++ b/doc/user/search/img/project_search.png
Binary files differ
diff --git a/doc/user/search/img/search_issues_board.png b/doc/user/search/img/search_issues_board.png
new file mode 100755
index 00000000000..84048ae6a02
--- /dev/null
+++ b/doc/user/search/img/search_issues_board.png
Binary files differ
diff --git a/doc/user/search/img/sort_projects.png b/doc/user/search/img/sort_projects.png
new file mode 100755
index 00000000000..9bf2770b299
--- /dev/null
+++ b/doc/user/search/img/sort_projects.png
Binary files differ
diff --git a/doc/user/search/index.md b/doc/user/search/index.md
new file mode 100644
index 00000000000..9d1ca1adcb2
--- /dev/null
+++ b/doc/user/search/index.md
@@ -0,0 +1,94 @@
+# Search through GitLab
+
+## Issues and merge requests
+
+To search through issues and merge requests in multiple projects, you can use the left-sidebar.
+
+Click the menu bar, then **Issues** or **Merge Requests**, which work in the same way,
+therefore, the following notes are valid for both.
+
+The number displayed on their right represents the number of issues and merge requests assigned to you.
+
+![menu bar - issues and MRs assigned to you](img/left_menu_bar.png)
+
+When you click **Issues**, you'll see the opened issues assigned to you straight away:
+
+![Issues assigned to you](img/issues_assigned_to_you.png)
+
+You can filter them by **Author**, **Assignee**, **Milestone**, and **Labels**,
+searching through **Open**, **Closed**, and **All** issues.
+
+Of course, you can combine all filters together.
+
+### Issues and MRs assigned to you or created by you
+
+You'll find a shortcut to issues and merge requests create by you or assigned to you
+on the search field on the top-right of your screen:
+
+![shortcut to your issues and mrs](img/issues_mrs_shortcut.png)
+
+## Issues and merge requests per project
+
+If you want to search for issues present in a specific project, navigate to
+a project's **Issues** tab, and click on the field **Search or filter results...**. It will
+display a dropdown menu, from which you can add filters per author, assignee, milestone, label,
+and weight. When done, press **Enter** on your keyboard to filter the issues.
+
+![filter issues in a project](img/filter_issues_project.gif)
+
+The same process is valid for merge requests. Navigate to your project's **Merge Requests** tab,
+and click **Search or filter results...**. Merge requests can be filtered by author, assignee,
+milestone, and label.
+
+### Shortcut
+
+You'll also find a shortcut on the search field on the top-right of the project's dashboard to
+quickly access issues and merge requests created or assigned to you within that project:
+
+![search per project - shortcut](img/project_search.png)
+
+## Todos
+
+Your [todos](../../workflow/todos.md#gitlab-todos) can be searched by "to do" and "done".
+You can [filter](../../workflow/todos.md#filtering-your-todos) them per project,
+author, type, and action. Also, you can sort them by
+[**Label priority**](../../user/project/labels.md#prioritize-labels),
+**Last created** and **Oldest created**.
+
+## Projects
+
+You can search through your projects from the left menu, by clicking the menu bar, then **Projects**.
+On the field **Filter by name**, type the project or group name you want to find, and GitLab
+will filter them for you as you type.
+
+You can also look for the projects you starred (**Starred projects**), and **Explore** all
+public and internal projects available in GitLab.com, from which you can filter by visibitily,
+through **Trending**, best rated with **Most starts**, or **All** of them.
+
+You can also sort them by **Name**, **Last created**, **Oldest created**, **Last updated**,
+**Oldest updated**, **Owner**, and choose to hide or show **archived projects**:
+
+![sort projects](img/sort_projects.png)
+
+## Groups
+
+Similarly to [projects search](#projects), you can search through your groups from
+the left menu, by clicking the menu bar, then **Groups**.
+
+On the field **Filter by name**, type the group name you want to find, and GitLab
+will filter them for you as you type.
+
+You can also **Explore** all public and internal groups available in GitLab.com,
+and sort them by **Last created**, **Oldest created**, **Last updated**, or **Oldest updated**.
+
+## Issue Boards
+
+From an [Issue Board](../../user/project/issue_board.md), you can filter issues by **Author**, **Assignee**, **Milestone**, and **Labels**.
+You can also filter them by name (issue title), from the field **Filter by name**, which is loaded as you type.
+
+When you want to search for issues to add to lists present in your Issue Board, click
+the button **Add issues** on the top-right of your screen, opening a modal window from which
+you'll be able to, besides filtering them by **Name**, **Author**, **Assignee**, **Milestone**,
+and **Labels**, select multiple issues to add to a list of your choice:
+
+![search and select issues to add to board](img/search_issues_board.png)
diff --git a/doc/workflow/README.md b/doc/workflow/README.md
index 6a8de51a199..a1852650cfb 100644
--- a/doc/workflow/README.md
+++ b/doc/workflow/README.md
@@ -20,6 +20,7 @@
- [Project forking workflow](forking_workflow.md)
- [Project users](add-user/add-user.md)
- [Protected branches](../user/project/protected_branches.md)
+- [Protected tags](../user/project/protected_tags.md)
- [Slash commands](../user/project/slash_commands.md)
- [Sharing a project with a group](share_with_group.md)
- [Share projects with other groups](share_projects_with_other_groups.md)
diff --git a/doc/workflow/groups.md b/doc/workflow/groups.md
index 6237a5d5e18..882747e14e9 100644
--- a/doc/workflow/groups.md
+++ b/doc/workflow/groups.md
@@ -11,9 +11,9 @@ You can create a group by going to the 'Groups' tab of the GitLab dashboard and
![Click the 'New group' button in the 'Groups' tab](groups/new_group_button.png)
-Next, enter the name (required) and the optional description and group avatar.
+Next, enter the path and name (required) and the optional description and group avatar.
-![Fill in the name for your new group](groups/new_group_form.png)
+![Fill in the path for your new group](groups/new_group_form.png)
When your group has been created you are presented with the group dashboard feed, which will be empty.
diff --git a/doc/workflow/groups/new_group_form.png b/doc/workflow/groups/new_group_form.png
index 0d798cd4b84..91727ab5336 100644
--- a/doc/workflow/groups/new_group_form.png
+++ b/doc/workflow/groups/new_group_form.png
Binary files differ
diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb
index 263ec321a34..3985fe8f2f7 100644
--- a/features/steps/project/merge_requests.rb
+++ b/features/steps/project/merge_requests.rb
@@ -347,6 +347,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I should see a discussion by user "John Doe" has started on diff' do
+ # Trigger a refresh of notes
+ execute_script("$(document).trigger('visibilitychange');")
+ wait_for_ajax
page.within(".notes .discussion") do
page.should have_content "#{user_exists("John Doe").name} #{user_exists("John Doe").to_reference} started a discussion"
page.should have_content sample_commit.line_code_path
diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb
index bdc7a616ba9..d7167352e02 100644
--- a/features/steps/project/merge_requests/acceptance.rb
+++ b/features/steps/project/merge_requests/acceptance.rb
@@ -1,6 +1,7 @@
class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps
include LoginHelpers
include GitlabRoutingHelper
+ include WaitForAjax
step 'I am on the Merge Request detail page' do
visit merge_request_path(@merge_request)
@@ -20,10 +21,18 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps
step 'I should see the Remove Source Branch button' do
expect(page).to have_link('Remove source branch')
+
+ # Wait for AJAX requests to complete so they don't blow up if they are
+ # only handled after `DatabaseCleaner` has already run
+ wait_for_ajax
end
step 'I should not see the Remove Source Branch button' do
expect(page).not_to have_link('Remove source branch')
+
+ # Wait for AJAX requests to complete so they don't blow up if they are
+ # only handled after `DatabaseCleaner` has already run
+ wait_for_ajax
end
step 'There is an open Merge Request' do
diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb
index 4ee879fe922..15625e045f5 100644
--- a/features/steps/shared/project.rb
+++ b/features/steps/shared/project.rb
@@ -97,7 +97,7 @@ module SharedProject
step 'I should see project "Shop" activity feed' do
project = Project.find_by(name: "Shop")
- expect(page).to have_content "pushed new branch fix at #{project.name_with_namespace}"
+ expect(page).to have_content "#{@user.name} pushed new branch fix at #{project.name_with_namespace}"
end
step 'I should see project settings' do
@@ -251,7 +251,8 @@ module SharedProject
step 'project "Shop" has CI build' do
project = Project.find_by(name: "Shop")
- create :ci_pipeline, project: project, sha: project.commit.sha, ref: 'master', status: 'skipped'
+ pipeline = create :ci_pipeline, project: project, sha: project.commit.sha, ref: 'master'
+ pipeline.skip
end
step 'I should see last commit with CI status' do
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 00d44821e3f..9919762cd82 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -14,7 +14,7 @@ module API
class User < UserBasic
expose :created_at
- expose :is_admin?, as: :is_admin
+ expose :admin?, as: :is_admin
expose :bio, :location, :skype, :linkedin, :twitter, :website_url, :organization
end
@@ -184,19 +184,15 @@ module API
end
expose :protected do |repo_branch, options|
- options[:project].protected_branch?(repo_branch.name)
+ ProtectedBranch.protected?(options[:project], repo_branch.name)
end
expose :developers_can_push do |repo_branch, options|
- project = options[:project]
- access_levels = project.protected_branches.matching(repo_branch.name).map(&:push_access_levels).flatten
- access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER }
+ options[:project].protected_branches.developers_can?(:push, repo_branch.name)
end
expose :developers_can_merge do |repo_branch, options|
- project = options[:project]
- access_levels = project.protected_branches.matching(repo_branch.name).map(&:merge_access_levels).flatten
- access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER }
+ options[:project].protected_branches.developers_can?(:merge, repo_branch.name)
end
end
@@ -615,9 +611,9 @@ module API
expose :locked
expose :version, :revision, :platform, :architecture
expose :contacted_at
- expose :token, if: lambda { |runner, options| options[:current_user].is_admin? || !runner.is_shared? }
+ expose :token, if: lambda { |runner, options| options[:current_user].admin? || !runner.is_shared? }
expose :projects, with: Entities::BasicProjectDetails do |runner, options|
- if options[:current_user].is_admin?
+ if options[:current_user].admin?
runner.projects
else
options[:current_user].authorized_projects.where(id: runner.projects)
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index 605769eddde..32bbf956d7f 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -56,7 +56,7 @@ module API
groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present?
groups = groups.reorder(params[:order_by] => params[:sort])
- present_groups groups, statistics: params[:statistics] && current_user.is_admin?
+ present_groups groups, statistics: params[:statistics] && current_user.admin?
end
desc 'Create a group. Available only for users who can create groups.' do
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 61527c1e20b..ddff3c8c1e8 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -118,7 +118,7 @@ module API
def authenticated_as_admin!
authenticate!
- forbidden! unless current_user.is_admin?
+ forbidden! unless current_user.admin?
end
def authorize!(action, subject = :global)
@@ -358,7 +358,7 @@ module API
return unless sudo_identifier
return unless initial_current_user
- unless initial_current_user.is_admin?
+ unless initial_current_user.admin?
forbidden!('Must be admin to use sudo')
end
diff --git a/lib/api/internal.rb b/lib/api/internal.rb
index 56c597dffcb..70d0d57204d 100644
--- a/lib/api/internal.rb
+++ b/lib/api/internal.rb
@@ -142,7 +142,7 @@ module API
project = Project.find_by_full_path(relative_path.sub(/\.(git|wiki)\z/, ''))
begin
- Gitlab::GitalyClient::Notifications.new(project.repository_storage, relative_path).post_receive
+ Gitlab::GitalyClient::Notifications.new(project.repository).post_receive
rescue GRPC::Unavailable => e
render_api_error(e, 500)
end
diff --git a/lib/api/notes.rb b/lib/api/notes.rb
index de39e579ac3..e281e3230fd 100644
--- a/lib/api/notes.rb
+++ b/lib/api/notes.rb
@@ -78,7 +78,7 @@ module API
}
if can?(current_user, noteable_read_ability_name(noteable), noteable)
- if params[:created_at] && (current_user.is_admin? || user_project.owner == current_user)
+ if params[:created_at] && (current_user.admin? || user_project.owner == current_user)
opts[:created_at] = params[:created_at]
end
diff --git a/lib/api/runners.rb b/lib/api/runners.rb
index a77c876a749..db6c7c59092 100644
--- a/lib/api/runners.rb
+++ b/lib/api/runners.rb
@@ -161,18 +161,18 @@ module API
end
def authenticate_show_runner!(runner)
- return if runner.is_shared || current_user.is_admin?
+ return if runner.is_shared || current_user.admin?
forbidden!("No access granted") unless user_can_access_runner?(runner)
end
def authenticate_update_runner!(runner)
- return if current_user.is_admin?
+ return if current_user.admin?
forbidden!("Runner is shared") if runner.is_shared?
forbidden!("No access granted") unless user_can_access_runner?(runner)
end
def authenticate_delete_runner!(runner)
- return if current_user.is_admin?
+ return if current_user.admin?
forbidden!("Runner is shared") if runner.is_shared?
forbidden!("Runner associated with more than one project") if runner.projects.count > 1
forbidden!("No access granted") unless user_can_access_runner?(runner)
@@ -181,7 +181,7 @@ module API
def authenticate_enable_runner!(runner)
forbidden!("Runner is shared") if runner.is_shared?
forbidden!("Runner is locked") if runner.locked?
- return if current_user.is_admin?
+ return if current_user.admin?
forbidden!("No access granted") unless user_can_access_runner?(runner)
end
diff --git a/lib/api/services.rb b/lib/api/services.rb
index 65f86caaa51..23ef62c2258 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -642,7 +642,7 @@ module API
service_params = declared_params(include_missing: false).merge(active: true)
if service.update_attributes(service_params)
- present service, with: Entities::ProjectService, include_passwords: current_user.is_admin?
+ present service, with: Entities::ProjectService, include_passwords: current_user.admin?
else
render_api_error!('400 Bad Request', 400)
end
@@ -673,7 +673,7 @@ module API
end
get ":id/services/:service_slug" do
service = user_project.find_or_initialize_service(params[:service_slug].underscore)
- present service, with: Entities::ProjectService, include_passwords: current_user.is_admin?
+ present service, with: Entities::ProjectService, include_passwords: current_user.admin?
end
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 992a751b37d..6f40f92240a 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -56,10 +56,10 @@ module API
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?
+ users = users.external if params[:external] && current_user.admin?
end
- entity = current_user.is_admin? ? Entities::UserPublic : Entities::UserBasic
+ entity = current_user.admin? ? Entities::UserPublic : Entities::UserBasic
present paginate(users), with: entity
end
@@ -73,7 +73,7 @@ module API
user = User.find_by(id: params[:id])
not_found!('User') unless user
- if current_user && current_user.is_admin?
+ if current_user && current_user.admin?
present user, with: Entities::UserPublic
elsif can?(current_user, :read_user, user)
present user, with: Entities::User
diff --git a/lib/api/v3/groups.rb b/lib/api/v3/groups.rb
index 9b27411ae21..63d464b926b 100644
--- a/lib/api/v3/groups.rb
+++ b/lib/api/v3/groups.rb
@@ -54,7 +54,7 @@ module API
groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present?
groups = groups.reorder(params[:order_by] => params[:sort])
- present_groups groups, statistics: params[:statistics] && current_user.is_admin?
+ present_groups groups, statistics: params[:statistics] && current_user.admin?
end
desc 'Get list of owned groups for authenticated user' do
diff --git a/lib/api/v3/notes.rb b/lib/api/v3/notes.rb
index 4f8e0eff4ff..009ec5c6bbd 100644
--- a/lib/api/v3/notes.rb
+++ b/lib/api/v3/notes.rb
@@ -79,7 +79,7 @@ module API
noteable = user_project.send(noteables_str.to_sym).find(params[:noteable_id])
if can?(current_user, noteable_read_ability_name(noteable), noteable)
- if params[:created_at] && (current_user.is_admin? || user_project.owner == current_user)
+ if params[:created_at] && (current_user.admin? || user_project.owner == current_user)
opts[:created_at] = params[:created_at]
end
diff --git a/lib/api/v3/runners.rb b/lib/api/v3/runners.rb
index 1934d6e578c..faa265f3314 100644
--- a/lib/api/v3/runners.rb
+++ b/lib/api/v3/runners.rb
@@ -50,7 +50,7 @@ module API
helpers do
def authenticate_delete_runner!(runner)
- return if current_user.is_admin?
+ return if current_user.admin?
forbidden!("Runner is shared") if runner.is_shared?
forbidden!("Runner associated with more than one project") if runner.projects.count > 1
forbidden!("No access granted") unless user_can_access_runner?(runner)
diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb
index bbe07ed4212..61629a04174 100644
--- a/lib/api/v3/services.rb
+++ b/lib/api/v3/services.rb
@@ -602,7 +602,7 @@ module API
end
get ":id/services/:service_slug" do
service = user_project.find_or_initialize_service(params[:service_slug].underscore)
- present service, with: Entities::ProjectService, include_passwords: current_user.is_admin?
+ present service, with: Entities::ProjectService, include_passwords: current_user.admin?
end
end
diff --git a/lib/banzai/filter/issuable_state_filter.rb b/lib/banzai/filter/issuable_state_filter.rb
new file mode 100644
index 00000000000..6b78aa795b4
--- /dev/null
+++ b/lib/banzai/filter/issuable_state_filter.rb
@@ -0,0 +1,35 @@
+module Banzai
+ module Filter
+ # HTML filter that appends state information to issuable links.
+ # Runs as a post-process filter as issuable state might change whilst
+ # Markdown is in the cache.
+ #
+ # This filter supports cross-project references.
+ class IssuableStateFilter < HTML::Pipeline::Filter
+ VISIBLE_STATES = %w(closed merged).freeze
+
+ def call
+ extractor = Banzai::IssuableExtractor.new(project, current_user)
+ issuables = extractor.extract([doc])
+
+ issuables.each do |node, issuable|
+ if VISIBLE_STATES.include?(issuable.state)
+ node.children.last.content += " [#{issuable.state}]"
+ end
+ end
+
+ doc
+ end
+
+ private
+
+ def current_user
+ context[:current_user]
+ end
+
+ def project
+ context[:project]
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/redactor_filter.rb b/lib/banzai/filter/redactor_filter.rb
index c59a80dd1c7..9f9882b3b40 100644
--- a/lib/banzai/filter/redactor_filter.rb
+++ b/lib/banzai/filter/redactor_filter.rb
@@ -7,7 +7,7 @@ module Banzai
#
class RedactorFilter < HTML::Pipeline::Filter
def call
- Redactor.new(project, current_user).redact([doc])
+ Redactor.new(project, current_user).redact([doc]) unless context[:skip_redaction]
doc
end
diff --git a/lib/banzai/issuable_extractor.rb b/lib/banzai/issuable_extractor.rb
new file mode 100644
index 00000000000..c5ce360e172
--- /dev/null
+++ b/lib/banzai/issuable_extractor.rb
@@ -0,0 +1,36 @@
+module Banzai
+ # Extract references to issuables from multiple documents
+
+ # This populates RequestStore cache used in Banzai::ReferenceParser::IssueParser
+ # and Banzai::ReferenceParser::MergeRequestParser
+ # Populating the cache should happen before processing documents one-by-one
+ # so we can avoid N+1 queries problem
+
+ class IssuableExtractor
+ QUERY = %q(
+ descendant-or-self::a[contains(concat(" ", @class, " "), " gfm ")]
+ [@data-reference-type="issue" or @data-reference-type="merge_request"]
+ ).freeze
+
+ attr_reader :project, :user
+
+ def initialize(project, user)
+ @project = project
+ @user = user
+ end
+
+ # Returns Hash in the form { node => issuable_instance }
+ def extract(documents)
+ nodes = documents.flat_map do |document|
+ document.xpath(QUERY)
+ end
+
+ issue_parser = Banzai::ReferenceParser::IssueParser.new(project, user)
+ merge_request_parser = Banzai::ReferenceParser::MergeRequestParser.new(project, user)
+
+ issue_parser.issues_for_nodes(nodes).merge(
+ merge_request_parser.merge_requests_for_nodes(nodes)
+ )
+ end
+ end
+end
diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb
index 9f8eb0931b8..002a3341ccd 100644
--- a/lib/banzai/object_renderer.rb
+++ b/lib/banzai/object_renderer.rb
@@ -31,7 +31,8 @@ module Banzai
#
# Returns the same input objects.
def render(objects, attribute)
- documents = render_objects(objects, attribute)
+ documents = render_documents(objects, attribute)
+ documents = post_process_documents(documents, objects, attribute)
redacted = redact_documents(documents)
objects.each_with_index do |object, index|
@@ -41,9 +42,24 @@ module Banzai
end
end
- # Renders the attribute of every given object.
- def render_objects(objects, attribute)
- render_attributes(objects, attribute)
+ private
+
+ def render_documents(objects, attribute)
+ pipeline = HTML::Pipeline.new([])
+
+ objects.map do |object|
+ pipeline.to_document(Banzai.render_field(object, attribute))
+ end
+ end
+
+ def post_process_documents(documents, objects, attribute)
+ # Called here to populate cache, refer to IssuableExtractor docs
+ IssuableExtractor.new(project, user).extract(documents)
+
+ documents.zip(objects).map do |document, object|
+ context = context_for(object, attribute)
+ Banzai::Pipeline[:post_process].to_document(document, context)
+ end
end
# Redacts the list of documents.
@@ -57,25 +73,15 @@ module Banzai
# Returns a Banzai context for the given object and attribute.
def context_for(object, attribute)
- context = base_context.dup
- context = context.merge(object.banzai_render_context(attribute))
- context
- end
-
- # Renders the attributes of a set of objects.
- #
- # Returns an Array of `Nokogiri::HTML::Document`.
- def render_attributes(objects, attribute)
- objects.map do |object|
- string = Banzai.render_field(object, attribute)
- context = context_for(object, attribute)
-
- Banzai::Pipeline[:relative_link].to_document(string, context)
- end
+ base_context.merge(object.banzai_render_context(attribute))
end
def base_context
- @base_context ||= @redaction_context.merge(current_user: user, project: project)
+ @base_context ||= @redaction_context.merge(
+ current_user: user,
+ project: project,
+ skip_redaction: true
+ )
end
end
end
diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb
index ecff094b1e5..131ac3b0eec 100644
--- a/lib/banzai/pipeline/post_process_pipeline.rb
+++ b/lib/banzai/pipeline/post_process_pipeline.rb
@@ -4,6 +4,7 @@ module Banzai
def self.filters
FilterArray[
Filter::RelativeLinkFilter,
+ Filter::IssuableStateFilter,
Filter::RedactorFilter
]
end
diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb
index 52fdb9a2140..dabf71d6aeb 100644
--- a/lib/banzai/reference_parser/base_parser.rb
+++ b/lib/banzai/reference_parser/base_parser.rb
@@ -62,8 +62,7 @@ module Banzai
nodes.select do |node|
if node.has_attribute?(project_attr)
- node_id = node.attr(project_attr).to_i
- can_read_reference?(user, projects[node_id])
+ can_read_reference?(user, projects[node])
else
true
end
@@ -112,12 +111,12 @@ module Banzai
per_project
end
- # Returns a Hash containing objects for an attribute grouped per their
- # IDs.
+ # Returns a Hash containing objects for an attribute grouped per the
+ # nodes that reference them.
#
# The returned Hash uses the following format:
#
- # { id value => row }
+ # { node => row }
#
# nodes - An Array of HTML nodes to process.
#
@@ -132,9 +131,14 @@ module Banzai
return {} if nodes.empty?
ids = unique_attribute_values(nodes, attribute)
- rows = collection_objects_for_ids(collection, ids)
+ collection_objects = collection_objects_for_ids(collection, ids)
+ objects_by_id = collection_objects.index_by(&:id)
- rows.index_by(&:id)
+ nodes.each_with_object({}) do |node, hash|
+ if node.has_attribute?(attribute)
+ hash[node] = objects_by_id[node.attr(attribute).to_i]
+ end
+ end
end
# Returns an Array containing all unique values of an attribute of the
@@ -201,7 +205,7 @@ module Banzai
#
# The returned Hash uses the following format:
#
- # { project ID => project }
+ # { node => project }
#
def projects_for_nodes(nodes)
@projects_for_nodes ||=
diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb
index 6c20dec5734..e02b360924a 100644
--- a/lib/banzai/reference_parser/issue_parser.rb
+++ b/lib/banzai/reference_parser/issue_parser.rb
@@ -13,14 +13,14 @@ module Banzai
issues_readable_by_user(issues.values, user).to_set
nodes.select do |node|
- readable_issues.include?(issue_for_node(issues, node))
+ readable_issues.include?(issues[node])
end
end
def referenced_by(nodes)
issues = issues_for_nodes(nodes)
- nodes.map { |node| issue_for_node(issues, node) }.uniq
+ nodes.map { |node| issues[node] }.compact.uniq
end
def issues_for_nodes(nodes)
@@ -44,12 +44,6 @@ module Banzai
self.class.data_attribute
)
end
-
- private
-
- def issue_for_node(issues, node)
- issues[node.attr(self.class.data_attribute).to_i]
- end
end
end
end
diff --git a/lib/banzai/reference_parser/merge_request_parser.rb b/lib/banzai/reference_parser/merge_request_parser.rb
index 40451947e6c..7d7dcce017e 100644
--- a/lib/banzai/reference_parser/merge_request_parser.rb
+++ b/lib/banzai/reference_parser/merge_request_parser.rb
@@ -3,6 +3,14 @@ module Banzai
class MergeRequestParser < BaseParser
self.reference_type = :merge_request
+ def merge_requests_for_nodes(nodes)
+ @merge_requests_for_nodes ||= grouped_objects_for_nodes(
+ nodes,
+ MergeRequest.all,
+ self.class.data_attribute
+ )
+ end
+
def references_relation
MergeRequest.includes(:author, :assignee, :target_project)
end
diff --git a/lib/banzai/reference_parser/user_parser.rb b/lib/banzai/reference_parser/user_parser.rb
index 7adaffa19c1..09b66cbd8fb 100644
--- a/lib/banzai/reference_parser/user_parser.rb
+++ b/lib/banzai/reference_parser/user_parser.rb
@@ -49,7 +49,7 @@ module Banzai
# Check if project belongs to a group which
# user can read.
def can_read_group_reference?(node, user, groups)
- node_group = groups[node.attr('data-group').to_i]
+ node_group = groups[node]
node_group && can?(user, :read_group, node_group)
end
@@ -74,8 +74,8 @@ module Banzai
if project && project_id && project.id == project_id.to_i
true
elsif project_id && user_id
- project = projects[project_id.to_i]
- user = users[user_id.to_i]
+ project = projects[node]
+ user = users[node]
project && user ? project.team.member?(user) : false
else
diff --git a/lib/gitlab/cache/ci/project_pipeline_status.rb b/lib/gitlab/cache/ci/project_pipeline_status.rb
new file mode 100644
index 00000000000..b358f2efa4f
--- /dev/null
+++ b/lib/gitlab/cache/ci/project_pipeline_status.rb
@@ -0,0 +1,103 @@
+# This class is not backed by a table in the main database.
+# It loads the latest Pipeline for the HEAD of a repository, and caches that
+# in Redis.
+module Gitlab
+ module Cache
+ module Ci
+ class ProjectPipelineStatus
+ attr_accessor :sha, :status, :ref, :project, :loaded
+
+ delegate :commit, to: :project
+
+ def self.load_for_project(project)
+ new(project).tap do |status|
+ status.load_status
+ end
+ end
+
+ def self.update_for_pipeline(pipeline)
+ new(pipeline.project,
+ sha: pipeline.sha,
+ status: pipeline.status,
+ ref: pipeline.ref).store_in_cache_if_needed
+ end
+
+ def initialize(project, sha: nil, status: nil, ref: nil)
+ @project = project
+ @sha = sha
+ @ref = ref
+ @status = status
+ end
+
+ def has_status?
+ loaded? && sha.present? && status.present?
+ end
+
+ def load_status
+ return if loaded?
+
+ if has_cache?
+ load_from_cache
+ else
+ load_from_project
+ store_in_cache
+ end
+
+ self.loaded = true
+ end
+
+ def load_from_project
+ return unless commit
+
+ self.sha = commit.sha
+ self.status = commit.status
+ self.ref = project.default_branch
+ end
+
+ # We only cache the status for the HEAD commit of a project
+ # This status is rendered in project lists
+ def store_in_cache_if_needed
+ return delete_from_cache unless commit
+ return unless sha
+ return unless ref
+
+ if commit.sha == sha && project.default_branch == ref
+ store_in_cache
+ end
+ end
+
+ def load_from_cache
+ Gitlab::Redis.with do |redis|
+ self.sha, self.status, self.ref = redis.hmget(cache_key, :sha, :status, :ref)
+ end
+ end
+
+ def store_in_cache
+ Gitlab::Redis.with do |redis|
+ redis.mapped_hmset(cache_key, { sha: sha, status: status, ref: ref })
+ end
+ end
+
+ def delete_from_cache
+ Gitlab::Redis.with do |redis|
+ redis.del(cache_key)
+ end
+ end
+
+ def has_cache?
+ Gitlab::Redis.with do |redis|
+ redis.exists(cache_key)
+ end
+ end
+
+ def loaded?
+ self.loaded
+ end
+
+ def cache_key
+ "projects/#{project.id}/build_status"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb
index c85f79127bc..eb2f2e144fd 100644
--- a/lib/gitlab/checks/change_access.rb
+++ b/lib/gitlab/checks/change_access.rb
@@ -10,6 +10,7 @@ module Gitlab
)
@oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref)
@branch_name = Gitlab::Git.branch_name(@ref)
+ @tag_name = Gitlab::Git.tag_name(@ref)
@user_access = user_access
@project = project
@env = env
@@ -32,11 +33,11 @@ module Gitlab
def protected_branch_checks
return if skip_authorization
return unless @branch_name
- return unless project.protected_branch?(@branch_name)
+ return unless ProtectedBranch.protected?(project, @branch_name)
if forced_push?
return "You are not allowed to force push code to a protected branch on this project."
- elsif Gitlab::Git.blank_ref?(@newrev)
+ elsif deletion?
return "You are not allowed to delete protected branches from this project."
end
@@ -58,13 +59,29 @@ module Gitlab
def tag_checks
return if skip_authorization
- tag_ref = Gitlab::Git.tag_name(@ref)
+ return unless @tag_name
- if tag_ref && protected_tag?(tag_ref) && user_access.cannot_do_action?(:admin_project)
- "You are not allowed to change existing tags on this project."
+ if tag_exists? && user_access.cannot_do_action?(:admin_project)
+ return "You are not allowed to change existing tags on this project."
+ end
+
+ protected_tag_checks
+ end
+
+ def protected_tag_checks
+ return unless tag_protected?
+ return "Protected tags cannot be updated." if update?
+ return "Protected tags cannot be deleted." if deletion?
+
+ unless user_access.can_create_tag?(@tag_name)
+ return "You are not allowed to create this tag as it is protected."
end
end
+ def tag_protected?
+ ProtectedTag.protected?(project, @tag_name)
+ end
+
def push_checks
return if skip_authorization
@@ -75,14 +92,22 @@ module Gitlab
private
- def protected_tag?(tag_name)
- project.repository.tag_exists?(tag_name)
+ def tag_exists?
+ project.repository.tag_exists?(@tag_name)
end
def forced_push?
Gitlab::Checks::ForcePush.force_push?(@project, @oldrev, @newrev, env: @env)
end
+ def update?
+ !Gitlab::Git.blank_ref?(@oldrev) && !deletion?
+ end
+
+ def deletion?
+ Gitlab::Git.blank_ref?(@newrev)
+ end
+
def matching_merge_request?
Checks::MatchingMergeRequest.new(@newrev, @branch_name, @project).match?
end
diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb
index 114656958e3..0a15c6d9358 100644
--- a/lib/gitlab/diff/line.rb
+++ b/lib/gitlab/diff/line.rb
@@ -33,6 +33,10 @@ module Gitlab
new_pos unless removed? || meta?
end
+ def line
+ new_line || old_line
+ end
+
def unchanged?
type.nil?
end
diff --git a/lib/gitlab/email/handler.rb b/lib/gitlab/email/handler.rb
index 35ea2e0ef59..b07c68d1498 100644
--- a/lib/gitlab/email/handler.rb
+++ b/lib/gitlab/email/handler.rb
@@ -5,7 +5,11 @@ require 'gitlab/email/handler/unsubscribe_handler'
module Gitlab
module Email
module Handler
- HANDLERS = [UnsubscribeHandler, CreateNoteHandler, CreateIssueHandler].freeze
+ HANDLERS = [
+ UnsubscribeHandler,
+ CreateNoteHandler,
+ CreateIssueHandler
+ ].freeze
def self.for(mail, mail_key)
HANDLERS.find do |klass|
diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb
index d87ba427f4b..0e22f2189ee 100644
--- a/lib/gitlab/email/handler/create_note_handler.rb
+++ b/lib/gitlab/email/handler/create_note_handler.rb
@@ -1,4 +1,3 @@
-
require 'gitlab/email/handler/base_handler'
require 'gitlab/email/handler/reply_processing'
@@ -42,17 +41,7 @@ module Gitlab
end
def create_note
- Notes::CreateService.new(
- project,
- author,
- note: message,
- noteable_type: sent_notification.noteable_type,
- noteable_id: sent_notification.noteable_id,
- commit_id: sent_notification.commit_id,
- line_code: sent_notification.line_code,
- position: sent_notification.position,
- type: sent_notification.note_type
- ).execute
+ sent_notification.create_reply(message)
end
end
end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 9e338282e96..fc473b2c21e 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -968,6 +968,14 @@ module Gitlab
@attributes.attributes(path)
end
+ def gitaly_repository
+ Gitlab::GitalyClient::Util.repository(@repository_storage, @relative_path)
+ end
+
+ def gitaly_channel
+ Gitlab::GitalyClient.get_channel(@repository_storage)
+ end
+
private
# Get the content of a blob for a given commit. If the blob is a commit
@@ -1247,7 +1255,7 @@ module Gitlab
end
def gitaly_ref_client
- @gitaly_ref_client ||= Gitlab::GitalyClient::Ref.new(@repository_storage, @relative_path)
+ @gitaly_ref_client ||= Gitlab::GitalyClient::Ref.new(self)
end
end
end
diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb
index f15faebe27e..b7f39f3ef0b 100644
--- a/lib/gitlab/gitaly_client/commit.rb
+++ b/lib/gitlab/gitaly_client/commit.rb
@@ -7,14 +7,13 @@ module Gitlab
class << self
def diff_from_parent(commit, options = {})
- project = commit.project
- channel = GitalyClient.get_channel(project.repository_storage)
- stub = Gitaly::Diff::Stub.new(nil, nil, channel_override: channel)
- repo = Gitaly::Repository.new(path: project.repository.path_to_repo)
- parent = commit.parents[0]
+ repository = commit.project.repository
+ gitaly_repo = repository.gitaly_repository
+ stub = Gitaly::Diff::Stub.new(nil, nil, channel_override: repository.gitaly_channel)
+ parent = commit.parents[0]
parent_id = parent ? parent.id : EMPTY_TREE_ID
- request = Gitaly::CommitDiffRequest.new(
- repository: repo,
+ request = Gitaly::CommitDiffRequest.new(
+ repository: gitaly_repo,
left_commit_id: parent_id,
right_commit_id: commit.id
)
@@ -23,12 +22,10 @@ module Gitlab
end
def is_ancestor(repository, ancestor_id, child_id)
- project = Project.find_by_path(repository.path)
- channel = GitalyClient.get_channel(project.repository_storage)
- stub = Gitaly::Commit::Stub.new(nil, nil, channel_override: channel)
- repo = Gitaly::Repository.new(path: repository.path_to_repo)
+ gitaly_repo = repository.gitaly_repository
+ stub = Gitaly::Commit::Stub.new(nil, nil, channel_override: repository.gitaly_channel)
request = Gitaly::CommitIsAncestorRequest.new(
- repository: repo,
+ repository: gitaly_repo,
ancestor_id: ancestor_id,
child_id: child_id
)
diff --git a/lib/gitlab/gitaly_client/notifications.rb b/lib/gitlab/gitaly_client/notifications.rb
index f0d93ded91b..a94a54883db 100644
--- a/lib/gitlab/gitaly_client/notifications.rb
+++ b/lib/gitlab/gitaly_client/notifications.rb
@@ -3,13 +3,14 @@ module Gitlab
class Notifications
attr_accessor :stub
- def initialize(repository_storage, relative_path)
- @channel, @repository = Util.process_path(repository_storage, relative_path)
- @stub = Gitaly::Notifications::Stub.new(nil, nil, channel_override: @channel)
+ # 'repository' is a Gitlab::Git::Repository
+ def initialize(repository)
+ @gitaly_repo = repository.gitaly_repository
+ @stub = Gitaly::Notifications::Stub.new(nil, nil, channel_override: repository.gitaly_channel)
end
def post_receive
- request = Gitaly::PostReceiveRequest.new(repository: @repository)
+ request = Gitaly::PostReceiveRequest.new(repository: @gitaly_repo)
@stub.post_receive(request)
end
end
diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb
index fcdf452d567..d3c0743db4e 100644
--- a/lib/gitlab/gitaly_client/ref.rb
+++ b/lib/gitlab/gitaly_client/ref.rb
@@ -3,23 +3,24 @@ module Gitlab
class Ref
attr_accessor :stub
- def initialize(repository_storage, relative_path)
- @channel, @repository = Util.process_path(repository_storage, relative_path)
- @stub = Gitaly::Ref::Stub.new(nil, nil, channel_override: @channel)
+ # 'repository' is a Gitlab::Git::Repository
+ def initialize(repository)
+ @gitaly_repo = repository.gitaly_repository
+ @stub = Gitaly::Ref::Stub.new(nil, nil, channel_override: repository.gitaly_channel)
end
def default_branch_name
- request = Gitaly::FindDefaultBranchNameRequest.new(repository: @repository)
+ request = Gitaly::FindDefaultBranchNameRequest.new(repository: @gitaly_repo)
stub.find_default_branch_name(request).name.gsub(/^refs\/heads\//, '')
end
def branch_names
- request = Gitaly::FindAllBranchNamesRequest.new(repository: @repository)
+ request = Gitaly::FindAllBranchNamesRequest.new(repository: @gitaly_repo)
consume_refs_response(stub.find_all_branch_names(request), prefix: 'refs/heads/')
end
def tag_names
- request = Gitaly::FindAllTagNamesRequest.new(repository: @repository)
+ request = Gitaly::FindAllTagNamesRequest.new(repository: @gitaly_repo)
consume_refs_response(stub.find_all_tag_names(request), prefix: 'refs/tags/')
end
diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb
index d272c25d1f9..4acd297f5cb 100644
--- a/lib/gitlab/gitaly_client/util.rb
+++ b/lib/gitlab/gitaly_client/util.rb
@@ -1,12 +1,14 @@
module Gitlab
module GitalyClient
module Util
- def self.process_path(repository_storage, relative_path)
- channel = GitalyClient.get_channel(repository_storage)
- storage_path = Gitlab.config.repositories.storages[repository_storage]['path']
- repository = Gitaly::Repository.new(path: File.join(storage_path, relative_path))
-
- [channel, repository]
+ class << self
+ def repository(repository_storage, relative_path)
+ Gitaly::Repository.new(
+ path: File.join(Gitlab.config.repositories.storages[repository_storage]['path'], relative_path),
+ storage_name: repository_storage,
+ relative_path: relative_path,
+ )
+ end
end
end
end
diff --git a/lib/gitlab/health_checks/base_abstract_check.rb b/lib/gitlab/health_checks/base_abstract_check.rb
new file mode 100644
index 00000000000..7de6d4d9367
--- /dev/null
+++ b/lib/gitlab/health_checks/base_abstract_check.rb
@@ -0,0 +1,45 @@
+module Gitlab
+ module HealthChecks
+ module BaseAbstractCheck
+ def name
+ super.demodulize.underscore
+ end
+
+ def human_name
+ name.sub(/_check$/, '').capitalize
+ end
+
+ def readiness
+ raise NotImplementedError
+ end
+
+ def liveness
+ HealthChecks::Result.new(true)
+ end
+
+ def metrics
+ []
+ end
+
+ protected
+
+ def metric(name, value, **labels)
+ Metric.new(name, value, labels)
+ end
+
+ def with_timing(proc)
+ start = Time.now
+ result = proc.call
+ yield result, Time.now.to_f - start.to_f
+ end
+
+ def catch_timeout(seconds, &block)
+ begin
+ Timeout.timeout(seconds.to_i, &block)
+ rescue Timeout::Error => ex
+ ex
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/db_check.rb b/lib/gitlab/health_checks/db_check.rb
new file mode 100644
index 00000000000..fd94984f8a2
--- /dev/null
+++ b/lib/gitlab/health_checks/db_check.rb
@@ -0,0 +1,29 @@
+module Gitlab
+ module HealthChecks
+ class DbCheck
+ extend SimpleAbstractCheck
+
+ class << self
+ private
+
+ def metric_prefix
+ 'db_ping'
+ end
+
+ def is_successful?(result)
+ result == '1'
+ end
+
+ def check
+ catch_timeout 10.seconds do
+ if Gitlab::Database.postgresql?
+ ActiveRecord::Base.connection.execute('SELECT 1 as ping')&.first&.[]('ping')
+ else
+ ActiveRecord::Base.connection.execute('SELECT 1 as ping')&.first&.first&.to_s
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/fs_shards_check.rb b/lib/gitlab/health_checks/fs_shards_check.rb
new file mode 100644
index 00000000000..df962d203b7
--- /dev/null
+++ b/lib/gitlab/health_checks/fs_shards_check.rb
@@ -0,0 +1,117 @@
+module Gitlab
+ module HealthChecks
+ class FsShardsCheck
+ extend BaseAbstractCheck
+
+ class << self
+ def readiness
+ repository_storages.map do |storage_name|
+ begin
+ tmp_file_path = tmp_file_path(storage_name)
+
+ if !storage_stat_test(storage_name)
+ HealthChecks::Result.new(false, 'cannot stat storage', shard: storage_name)
+ elsif !storage_write_test(tmp_file_path)
+ HealthChecks::Result.new(false, 'cannot write to storage', shard: storage_name)
+ elsif !storage_read_test(tmp_file_path)
+ HealthChecks::Result.new(false, 'cannot read from storage', shard: storage_name)
+ else
+ HealthChecks::Result.new(true, nil, shard: storage_name)
+ end
+ rescue RuntimeError => ex
+ message = "unexpected error #{ex} when checking storage #{storage_name}"
+ Rails.logger.error(message)
+ HealthChecks::Result.new(false, message, shard: storage_name)
+ ensure
+ delete_test_file(tmp_file_path)
+ end
+ end
+ end
+
+ def metrics
+ repository_storages.flat_map do |storage_name|
+ tmp_file_path = tmp_file_path(storage_name)
+ [
+ operation_metrics(:filesystem_accessible, :filesystem_access_latency, -> { storage_stat_test(storage_name) }, shard: storage_name),
+ operation_metrics(:filesystem_writable, :filesystem_write_latency, -> { storage_write_test(tmp_file_path) }, shard: storage_name),
+ operation_metrics(:filesystem_readable, :filesystem_read_latency, -> { storage_read_test(tmp_file_path) }, shard: storage_name)
+ ].flatten
+ end
+ end
+
+ private
+
+ RANDOM_STRING = SecureRandom.hex(1000).freeze
+
+ def operation_metrics(ok_metric, latency_metric, operation, **labels)
+ with_timing operation do |result, elapsed|
+ [
+ metric(latency_metric, elapsed, **labels),
+ metric(ok_metric, result ? 1 : 0, **labels)
+ ]
+ end
+ rescue RuntimeError => ex
+ Rails.logger("unexpected error #{ex} when checking #{ok_metric}")
+ [metric(ok_metric, 0, **labels)]
+ end
+
+ def repository_storages
+ @repository_storage ||= Gitlab::CurrentSettings.current_application_settings.repository_storages
+ end
+
+ def storages_paths
+ @storage_paths ||= Gitlab.config.repositories.storages
+ end
+
+ def with_timeout(args)
+ %w{timeout 1}.concat(args)
+ end
+
+ def tmp_file_path(storage_name)
+ Dir::Tmpname.create(%w(fs_shards_check +deleted), path(storage_name)) { |path| path }
+ end
+
+ def path(storage_name)
+ storages_paths&.dig(storage_name, 'path')
+ end
+
+ def storage_stat_test(storage_name)
+ stat_path = File.join(path(storage_name), '.')
+ begin
+ _, status = Gitlab::Popen.popen(with_timeout(%W{ stat #{stat_path} }))
+ status == 0
+ rescue Errno::ENOENT
+ File.exist?(stat_path) && File::Stat.new(stat_path).readable?
+ end
+ end
+
+ def storage_write_test(tmp_path)
+ _, status = Gitlab::Popen.popen(with_timeout(%W{ tee #{tmp_path} })) do |stdin|
+ stdin.write(RANDOM_STRING)
+ end
+ status == 0
+ rescue Errno::ENOENT
+ written_bytes = File.write(tmp_path, RANDOM_STRING) rescue Errno::ENOENT
+ written_bytes == RANDOM_STRING.length
+ end
+
+ def storage_read_test(tmp_path)
+ _, status = Gitlab::Popen.popen(with_timeout(%W{ diff #{tmp_path} - })) do |stdin|
+ stdin.write(RANDOM_STRING)
+ end
+ status == 0
+ rescue Errno::ENOENT
+ file_contents = File.read(tmp_path) rescue Errno::ENOENT
+ file_contents == RANDOM_STRING
+ end
+
+ def delete_test_file(tmp_path)
+ _, status = Gitlab::Popen.popen(with_timeout(%W{ rm -f #{tmp_path} }))
+ status == 0
+ rescue Errno::ENOENT
+ File.delete(tmp_path) rescue Errno::ENOENT
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/metric.rb b/lib/gitlab/health_checks/metric.rb
new file mode 100644
index 00000000000..1a2eab0b005
--- /dev/null
+++ b/lib/gitlab/health_checks/metric.rb
@@ -0,0 +1,3 @@
+module Gitlab::HealthChecks
+ Metric = Struct.new(:name, :value, :labels)
+end
diff --git a/lib/gitlab/health_checks/redis_check.rb b/lib/gitlab/health_checks/redis_check.rb
new file mode 100644
index 00000000000..57bbe5b3ad0
--- /dev/null
+++ b/lib/gitlab/health_checks/redis_check.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module HealthChecks
+ class RedisCheck
+ extend SimpleAbstractCheck
+
+ class << self
+ private
+
+ def metric_prefix
+ 'redis_ping'
+ end
+
+ def is_successful?(result)
+ result == 'PONG'
+ end
+
+ def check
+ catch_timeout 10.seconds do
+ Gitlab::Redis.with(&:ping)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/result.rb b/lib/gitlab/health_checks/result.rb
new file mode 100644
index 00000000000..8086760023e
--- /dev/null
+++ b/lib/gitlab/health_checks/result.rb
@@ -0,0 +1,3 @@
+module Gitlab::HealthChecks
+ Result = Struct.new(:success, :message, :labels)
+end
diff --git a/lib/gitlab/health_checks/simple_abstract_check.rb b/lib/gitlab/health_checks/simple_abstract_check.rb
new file mode 100644
index 00000000000..fbe1645c1b1
--- /dev/null
+++ b/lib/gitlab/health_checks/simple_abstract_check.rb
@@ -0,0 +1,43 @@
+module Gitlab
+ module HealthChecks
+ module SimpleAbstractCheck
+ include BaseAbstractCheck
+
+ def readiness
+ check_result = check
+ if is_successful?(check_result)
+ HealthChecks::Result.new(true)
+ elsif check_result.is_a?(Timeout::Error)
+ HealthChecks::Result.new(false, "#{human_name} check timed out")
+ else
+ HealthChecks::Result.new(false, "unexpected #{human_name} check result: #{check_result}")
+ end
+ end
+
+ def metrics
+ with_timing method(:check) do |result, elapsed|
+ Rails.logger.error("#{human_name} check returned unexpected result #{result}") unless is_successful?(result)
+ [
+ metric("#{metric_prefix}_timeout", result.is_a?(Timeout::Error) ? 1 : 0),
+ metric("#{metric_prefix}_success", is_successful?(result) ? 1 : 0),
+ metric("#{metric_prefix}_latency", elapsed)
+ ]
+ end
+ end
+
+ private
+
+ def metric_prefix
+ raise NotImplementedError
+ end
+
+ def is_successful?(result)
+ raise NotImplementedError
+ end
+
+ def check
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index f5e1e385ff9..899a6567768 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -47,6 +47,8 @@ project_tree:
- protected_branches:
- :merge_access_levels
- :push_access_levels
+ - protected_tags:
+ - :create_access_levels
- :project_feature
# Only include the following attributes for the models specified.
diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb
index df21ff22216..2e349b5f9a9 100644
--- a/lib/gitlab/import_export/project_tree_restorer.rb
+++ b/lib/gitlab/import_export/project_tree_restorer.rb
@@ -52,7 +52,11 @@ module Gitlab
create_sub_relations(relation, @tree_hash) if relation.is_a?(Hash)
relation_key = relation.is_a?(Hash) ? relation.keys.first : relation
- relation_hash = create_relation(relation_key, @tree_hash[relation_key.to_s])
+ relation_hash_list = @tree_hash[relation_key.to_s]
+
+ next unless relation_hash_list
+
+ relation_hash = create_relation(relation_key, relation_hash_list)
saved << restored_project.append_or_update_attribute(relation_key, relation_hash)
end
saved.all?
diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb
index 2ba12f5f924..4a54e7ef2e7 100644
--- a/lib/gitlab/import_export/relation_factory.rb
+++ b/lib/gitlab/import_export/relation_factory.rb
@@ -10,6 +10,7 @@ module Gitlab
hooks: 'ProjectHook',
merge_access_levels: 'ProtectedBranch::MergeAccessLevel',
push_access_levels: 'ProtectedBranch::PushAccessLevel',
+ create_access_levels: 'ProtectedTag::CreateAccessLevel',
labels: :project_labels,
priorities: :label_priorities,
label: :project_label }.freeze
@@ -185,7 +186,7 @@ module Gitlab
end
def admin_user?
- @user.is_admin?
+ @user.admin?
end
def parsed_relation_hash
diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb
index f260c0c535f..54728e5ff0e 100644
--- a/lib/gitlab/user_access.rb
+++ b/lib/gitlab/user_access.rb
@@ -28,14 +28,23 @@ module Gitlab
true
end
+ def can_create_tag?(ref)
+ return false unless can_access_git?
+
+ if ProtectedTag.protected?(project, ref)
+ project.protected_tags.protected_ref_accessible_to?(ref, user, action: :create)
+ else
+ user.can?(:push_code, project)
+ end
+ end
+
def can_push_to_branch?(ref)
return false unless can_access_git?
- if project.protected_branch?(ref)
+ if ProtectedBranch.protected?(project, ref)
return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user)
- access_levels = project.protected_branches.matching(ref).map(&:push_access_levels).flatten
- has_access = access_levels.any? { |access_level| access_level.check_access(user) }
+ has_access = project.protected_branches.protected_ref_accessible_to?(ref, user, action: :push)
has_access || !project.repository.branch_exists?(ref) && can_merge_to_branch?(ref)
else
@@ -46,9 +55,8 @@ module Gitlab
def can_merge_to_branch?(ref)
return false unless can_access_git?
- if project.protected_branch?(ref)
- access_levels = project.protected_branches.matching(ref).map(&:merge_access_levels).flatten
- access_levels.any? { |access_level| access_level.check_access(user) }
+ if ProtectedBranch.protected?(project, ref)
+ project.protected_branches.protected_ref_accessible_to?(ref, user, action: :merge)
else
user.can?(:push_code, project)
end
diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb
index 8f1d1fdc02e..2e31f4462f9 100644
--- a/lib/gitlab/visibility_level.rb
+++ b/lib/gitlab/visibility_level.rb
@@ -63,7 +63,7 @@ module Gitlab
end
def allowed_for?(user, level)
- user.is_admin? || allowed_level?(level.to_i)
+ user.admin? || allowed_level?(level.to_i)
end
# Return true if the specified level is allowed for the current user.
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index a8a7bf9bc12..e6e40f6945d 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -24,14 +24,8 @@ module Gitlab
}
if Gitlab.config.gitaly.enabled
- storage = repository.project.repository_storage
- address = Gitlab::GitalyClient.get_address(storage)
- # TODO: use GitalyClient code to assemble the Repository message
- params[:Repository] = Gitaly::Repository.new(
- path: repo_path,
- storage_name: storage,
- relative_path: Gitlab::RepoPath.strip_storage_path(repo_path),
- ).to_h
+ address = Gitlab::GitalyClient.get_address(repository.project.repository_storage)
+ params[:Repository] = repository.gitaly_repository.to_h
feature_enabled = case action.to_s
when 'git_receive_pack'
diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake
index c288e17ac8d..9f6cfe3957c 100644
--- a/lib/tasks/gitlab/gitaly.rake
+++ b/lib/tasks/gitlab/gitaly.rake
@@ -19,5 +19,19 @@ namespace :gitlab do
run_command!([command])
end
end
+
+ desc "GitLab | Print storage configuration in TOML format"
+ task storage_config: :environment do
+ require 'toml'
+
+ puts "# Gitaly storage configuration generated from #{Gitlab.config.source} on #{Time.current.to_s(:long)}"
+ puts "# This is in TOML format suitable for use in Gitaly's config.toml file."
+
+ config = Gitlab.config.repositories.storages.map do |key, val|
+ { name: key, path: val['path'] }
+ end
+
+ puts TOML.dump(storage: config)
+ end
end
end
diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake
index 350afeb5c0b..15131fbf755 100644
--- a/lib/tasks/import.rake
+++ b/lib/tasks/import.rake
@@ -48,9 +48,16 @@ class NewImporter < ::Gitlab::GithubImport::Importer
begin
raise 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url)
- gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url)
+ project.create_repository
+ project.repository.add_remote(project.import_type, project.import_url)
+ project.repository.set_remote_as_mirror(project.import_type)
+ project.repository.fetch_remote(project.import_type, forced: true)
+ project.repository.remove_remote(project.import_type)
rescue => e
- project.repository.before_import if project.repository_exists?
+ # Expire cache to prevent scenarios such as:
+ # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true
+ # 2. Retried import, repo is broken or not imported but +exists?+ still returns true
+ project.repository.expire_content_cache if project.repository_exists?
raise "Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}"
end
diff --git a/package.json b/package.json
index 312e38f7407..6d4f99e33b3 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
"visibilityjs": "^1.2.4",
"vue": "^2.2.4",
"vue-resource": "^0.9.3",
- "webpack": "^2.2.1",
+ "webpack": "^2.3.3",
"webpack-bundle-analyzer": "^2.3.0"
},
"devDependencies": {
@@ -65,6 +65,6 @@
"karma-phantomjs-launcher": "^1.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^2.0.2",
- "webpack-dev-server": "^2.3.0"
+ "webpack-dev-server": "^2.4.2"
}
}
diff --git a/public/404.html b/public/404.html
index b3b3a0fa3f3..03e98e81862 100644
--- a/public/404.html
+++ b/public/404.html
@@ -57,6 +57,11 @@
.container {
margin: auto 20px;
}
+
+ .go-back {
+ display: none;
+ }
+
</style>
</head>
@@ -71,7 +76,16 @@
<hr />
<p>Make sure the address is correct and that the page hasn't moved.</p>
<p>Please contact your GitLab administrator if you think this is a mistake.</p>
- <a href="javascript:history.back()">Go back</a>
+ <a href="javascript:history.back()" class="js-go-back go-back">Go back</a>
</div>
+ <script>
+ (function () {
+ var goBack = document.querySelector('.js-go-back');
+
+ if (history.length > 1) {
+ goBack.style.display = 'inline';
+ }
+ })();
+ </script>
</body>
</html>
diff --git a/public/422.html b/public/422.html
index 119e54ad8bd..49ebbe40f39 100644
--- a/public/422.html
+++ b/public/422.html
@@ -57,6 +57,11 @@
.container {
margin: auto 20px;
}
+
+ .go-back {
+ display: none;
+ }
+
</style>
</head>
@@ -71,7 +76,17 @@
<hr />
<p>Make sure you have access to the thing you tried to change.</p>
<p>Please contact your GitLab administrator if you think this is a mistake.</p>
- <a href="javascript:history.back()">Go back</a>
+ <a href="javascript:history.back()" class="js-go-back go-back">Go back</a>
</div>
+ <script>
+ (function () {
+ var goBack = document.querySelector('.js-go-back');
+
+ if (history.length > 1) {
+ goBack.style.display = 'inline';
+ }
+ })();
+
+ </script>
</body>
</html>
diff --git a/public/500.html b/public/500.html
index 226ef3c40ea..516920f7471 100644
--- a/public/500.html
+++ b/public/500.html
@@ -57,6 +57,11 @@
.container {
margin: auto 20px;
}
+
+ .go-back {
+ display: none;
+ }
+
</style>
</head>
@@ -71,7 +76,16 @@
<hr />
<p>Try refreshing the page, or going back and attempting the action again.</p>
<p>Please contact your GitLab administrator if this problem persists.</p>
- <a href="javascript:history.back()">Go back</a>
+ <a href="javascript:history.back()" class="js-go-back go-back">Go back</a>
</div>
+ <script>
+ (function () {
+ var goBack = document.querySelector('.js-go-back');
+
+ if (history.length > 1) {
+ goBack.style.display = 'inline';
+ }
+ })();
+ </script>
</body>
</html>
diff --git a/public/502.html b/public/502.html
index f037b81bace..189458c9816 100644
--- a/public/502.html
+++ b/public/502.html
@@ -57,6 +57,11 @@
.container {
margin: auto 20px;
}
+
+ .go-back {
+ display: none;
+ }
+
</style>
</head>
@@ -71,7 +76,16 @@
<hr />
<p>Try refreshing the page, or going back and attempting the action again.</p>
<p>Please contact your GitLab administrator if this problem persists.</p>
- <a href="javascript:history.back()">Go back</a>
+ <a href="javascript:history.back()" class="js-go-back go-back">Go back</a>
</div>
+ <script>
+ (function () {
+ var goBack = document.querySelector('.js-go-back');
+
+ if (history.length > 1) {
+ goBack.style.display = 'inline';
+ }
+ })();
+ </script>
</body>
</html>
diff --git a/public/503.html b/public/503.html
index f946a087871..b09b0e2a67e 100644
--- a/public/503.html
+++ b/public/503.html
@@ -57,6 +57,11 @@
.container {
margin: auto 20px;
}
+
+ .go-back {
+ display: none;
+ }
+
</style>
</head>
@@ -71,7 +76,16 @@
<hr />
<p>Try refreshing the page, or going back and attempting the action again.</p>
<p>Please contact your GitLab administrator if this problem persists.</p>
- <a href="javascript:history.back()">Go back</a>
+ <a href="javascript:history.back()" class="js-go-back go-back">Go back</a>
</div>
+ <script>
+ (function () {
+ var goBack = document.querySelector('.js-go-back');
+
+ if (history.length > 1) {
+ goBack.style.display = 'inline';
+ }
+ })();
+ </script>
</body>
</html>
diff --git a/qa/qa/page/main/groups.rb b/qa/qa/page/main/groups.rb
index 84597719a84..169c5ebc967 100644
--- a/qa/qa/page/main/groups.rb
+++ b/qa/qa/page/main/groups.rb
@@ -5,7 +5,7 @@ module QA
def prepare_test_namespace
return if page.has_content?(Runtime::Namespace.name)
- click_on 'New Group'
+ click_on 'New group'
fill_in 'group_path', with: Runtime::Namespace.name
fill_in 'group_description',
diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb
new file mode 100644
index 00000000000..b8b6e0c3a88
--- /dev/null
+++ b/spec/controllers/health_controller_spec.rb
@@ -0,0 +1,96 @@
+require 'spec_helper'
+
+describe HealthController do
+ include StubENV
+
+ let(:token) { current_application_settings.health_check_access_token }
+ let(:json_response) { JSON.parse(response.body) }
+
+ before do
+ stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
+ end
+
+ describe '#readiness' do
+ context 'authorization token provided' do
+ before do
+ request.headers['TOKEN'] = token
+ end
+
+ it 'returns proper response' do
+ get :readiness
+ expect(json_response['db_check']['status']).to eq('ok')
+ expect(json_response['redis_check']['status']).to eq('ok')
+ expect(json_response['fs_shards_check']['status']).to eq('ok')
+ expect(json_response['fs_shards_check']['labels']['shard']).to eq('default')
+ end
+ end
+
+ context 'without authorization token' do
+ it 'returns proper response' do
+ get :readiness
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+
+ describe '#liveness' do
+ context 'authorization token provided' do
+ before do
+ request.headers['TOKEN'] = token
+ end
+
+ it 'returns proper response' do
+ get :liveness
+ expect(json_response['db_check']['status']).to eq('ok')
+ expect(json_response['redis_check']['status']).to eq('ok')
+ expect(json_response['fs_shards_check']['status']).to eq('ok')
+ end
+ end
+
+ context 'without authorization token' do
+ it 'returns proper response' do
+ get :liveness
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+
+ describe '#metrics' do
+ context 'authorization token provided' do
+ before do
+ request.headers['TOKEN'] = token
+ end
+
+ it 'returns DB ping metrics' do
+ get :metrics
+ expect(response.body).to match(/^db_ping_timeout 0$/)
+ expect(response.body).to match(/^db_ping_success 1$/)
+ expect(response.body).to match(/^db_ping_latency [0-9\.]+$/)
+ end
+
+ it 'returns Redis ping metrics' do
+ get :metrics
+ expect(response.body).to match(/^redis_ping_timeout 0$/)
+ expect(response.body).to match(/^redis_ping_success 1$/)
+ expect(response.body).to match(/^redis_ping_latency [0-9\.]+$/)
+ end
+
+ it 'returns file system check metrics' do
+ get :metrics
+ expect(response.body).to match(/^filesystem_access_latency{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/)
+ expect(response.body).to match(/^filesystem_write_latency{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/)
+ expect(response.body).to match(/^filesystem_read_latency{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/)
+ end
+ end
+
+ context 'without authorization token' do
+ it 'returns proper response' do
+ get :metrics
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/builds_controller_spec.rb
index 683667129e5..13208d21918 100644
--- a/spec/controllers/projects/builds_controller_spec.rb
+++ b/spec/controllers/projects/builds_controller_spec.rb
@@ -10,6 +10,39 @@ describe Projects::BuildsController do
sign_in(user)
end
+ describe 'GET index' do
+ context 'number of queries' do
+ before do
+ Ci::Build::AVAILABLE_STATUSES.each do |status|
+ create_build(status, status)
+ end
+
+ RequestStore.begin!
+ end
+
+ after do
+ RequestStore.end!
+ RequestStore.clear!
+ end
+
+ def render
+ get :index, namespace_id: project.namespace,
+ project_id: project
+ end
+
+ it "verifies number of queries" do
+ recorded = ActiveRecord::QueryRecorder.new { render }
+ expect(recorded.count).to be_within(5).of(8)
+ end
+
+ def create_build(name, status)
+ pipeline = create(:ci_pipeline, project: project)
+ create(:ci_build, :tags, :triggered, :artifacts,
+ pipeline: pipeline, name: name, status: status)
+ end
+ end
+ end
+
describe 'GET status.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb
index b223a22ae60..69e4706dc71 100644
--- a/spec/controllers/projects/commit_controller_spec.rb
+++ b/spec/controllers/projects/commit_controller_spec.rb
@@ -266,8 +266,8 @@ describe Projects::CommitController do
diff_for_path(id: commit2.id, old_path: existing_path, new_path: existing_path)
expect(assigns(:diff_notes_disabled)).to be_falsey
- expect(assigns(:comments_target)).to eq(noteable_type: 'Commit',
- commit_id: commit2.id)
+ expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'Commit',
+ commit_id: commit2.id)
end
it 'only renders the diffs for the path given' do
diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb
index 79ab364a6f3..fe62898fa9b 100644
--- a/spec/controllers/projects/discussions_controller_spec.rb
+++ b/spec/controllers/projects/discussions_controller_spec.rb
@@ -4,7 +4,7 @@ describe Projects::DiscussionsController do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.source_project }
- let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
+ let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
let(:discussion) { note.discussion }
let(:request_params) do
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index d5f1d46bf7f..79034b8d24d 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -519,7 +519,7 @@ describe Projects::IssuesController do
end
context 'resolving discussions in MergeRequest' do
- let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
+ let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
let(:merge_request) { discussion.noteable }
let(:project) { merge_request.source_project }
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 99d5583e683..1739d40ab88 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -586,8 +586,8 @@ describe Projects::MergeRequestsController do
diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path)
expect(assigns(:diff_notes_disabled)).to be_falsey
- expect(assigns(:comments_target)).to eq(noteable_type: 'MergeRequest',
- noteable_id: merge_request.id)
+ expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id)
end
it 'only renders the diffs for the path given' do
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index d80780b1d90..f140eaef5d5 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -14,6 +14,109 @@ describe Projects::NotesController do
}
end
+ describe 'GET index' do
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ target_type: 'issue',
+ target_id: issue.id,
+ format: 'json'
+ }
+ end
+
+ let(:parsed_response) { JSON.parse(response.body).with_indifferent_access }
+ let(:note_json) { parsed_response[:notes].first }
+
+ before do
+ sign_in(user)
+ project.team << [user, :developer]
+ end
+
+ it 'passes last_fetched_at from headers to NotesFinder' do
+ last_fetched_at = 3.hours.ago.to_i
+
+ request.headers['X-Last-Fetched-At'] = last_fetched_at
+
+ expect(NotesFinder).to receive(:new)
+ .with(anything, anything, hash_including(last_fetched_at: last_fetched_at))
+ .and_call_original
+
+ get :index, request_params
+ end
+
+ context 'for a discussion note' do
+ let!(:note) { create(:discussion_note_on_issue, noteable: issue, project: project) }
+
+ it 'responds with the expected attributes' do
+ get :index, request_params
+
+ expect(note_json[:id]).to eq(note.id)
+ expect(note_json[:discussion_html]).not_to be_nil
+ expect(note_json[:diff_discussion_html]).to be_nil
+ end
+ end
+
+ context 'for a diff discussion note' do
+ let(:project) { create(:project, :repository) }
+ let!(:note) { create(:diff_note_on_merge_request, project: project) }
+
+ let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) }
+
+ it 'responds with the expected attributes' do
+ get :index, params
+
+ expect(note_json[:id]).to eq(note.id)
+ expect(note_json[:discussion_html]).not_to be_nil
+ expect(note_json[:diff_discussion_html]).not_to be_nil
+ end
+ end
+
+ context 'for a commit note' do
+ let(:project) { create(:project, :repository) }
+ let!(:note) { create(:note_on_commit, project: project) }
+
+ context 'when displayed on a merge request' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ let(:params) { request_params.merge(target_type: 'merge_request', target_id: merge_request.id) }
+
+ it 'responds with the expected attributes' do
+ get :index, params
+
+ expect(note_json[:id]).to eq(note.id)
+ expect(note_json[:discussion_html]).not_to be_nil
+ expect(note_json[:diff_discussion_html]).to be_nil
+ end
+ end
+
+ context 'when displayed on the commit' do
+ let(:params) { request_params.merge(target_type: 'commit', target_id: note.commit_id) }
+
+ it 'responds with the expected attributes' do
+ get :index, params
+
+ expect(note_json[:id]).to eq(note.id)
+ expect(note_json[:discussion_html]).to be_nil
+ expect(note_json[:diff_discussion_html]).to be_nil
+ end
+ end
+ end
+
+ context 'for a regular note' do
+ let!(:note) { create(:note, noteable: issue, project: project) }
+
+ it 'responds with the expected attributes' do
+ get :index, request_params
+
+ expect(note_json[:id]).to eq(note.id)
+ expect(note_json[:html]).not_to be_nil
+ expect(note_json[:discussion_html]).to be_nil
+ expect(note_json[:diff_discussion_html]).to be_nil
+ end
+ end
+ end
+
describe 'POST create' do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.source_project }
@@ -49,7 +152,8 @@ describe Projects::NotesController do
note: 'some note',
noteable_id: merge_request.id.to_s,
noteable_type: 'MergeRequest',
- merge_request_diff_head_sha: 'sha'
+ merge_request_diff_head_sha: 'sha',
+ in_reply_to_discussion_id: nil
}
expect(Notes::CreateService).to receive(:new).with(project, user, service_params).and_return(double(execute: true))
@@ -200,31 +304,4 @@ describe Projects::NotesController do
end
end
end
-
- describe 'GET index' do
- let(:last_fetched_at) { '1487756246' }
- let(:request_params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- target_type: 'issue',
- target_id: issue.id
- }
- end
-
- before do
- sign_in(user)
- project.team << [user, :developer]
- end
-
- it 'passes last_fetched_at from headers to NotesFinder' do
- request.headers['X-Last-Fetched-At'] = last_fetched_at
-
- expect(NotesFinder).to receive(:new)
- .with(anything, anything, hash_including(last_fetched_at: last_fetched_at))
- .and_call_original
-
- get :index, request_params
- end
- end
end
diff --git a/spec/controllers/projects/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb
index e378b5714fe..80be135b5d8 100644
--- a/spec/controllers/projects/protected_branches_controller_spec.rb
+++ b/spec/controllers/projects/protected_branches_controller_spec.rb
@@ -3,6 +3,7 @@ require('spec_helper')
describe Projects::ProtectedBranchesController do
describe "GET #index" do
let(:project) { create(:project_empty_repo, :public) }
+
it "redirects empty repo to projects page" do
get(:index, namespace_id: project.namespace.to_param, project_id: project)
end
diff --git a/spec/controllers/projects/protected_tags_controller_spec.rb b/spec/controllers/projects/protected_tags_controller_spec.rb
new file mode 100644
index 00000000000..64658988b3f
--- /dev/null
+++ b/spec/controllers/projects/protected_tags_controller_spec.rb
@@ -0,0 +1,11 @@
+require('spec_helper')
+
+describe Projects::ProtectedTagsController do
+ describe "GET #index" do
+ let(:project) { create(:project_empty_repo, :public) }
+
+ it "redirects empty repo to projects page" do
+ get(:index, namespace_id: project.namespace.to_param, project_id: project)
+ end
+ end
+end
diff --git a/spec/factories/ci/trigger_schedules.rb b/spec/factories/ci/trigger_schedules.rb
index 315bce16995..2390706fa41 100644
--- a/spec/factories/ci/trigger_schedules.rb
+++ b/spec/factories/ci/trigger_schedules.rb
@@ -3,9 +3,11 @@ FactoryGirl.define do
trigger factory: :ci_trigger_for_trigger_schedule
cron '0 1 * * *'
cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
+ ref 'master'
+ active true
after(:build) do |trigger_schedule, evaluator|
- trigger_schedule.update!(project: trigger_schedule.trigger.project)
+ trigger_schedule.project ||= trigger_schedule.trigger.project
end
trait :nightly do
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index e36fe326e1c..361f9dac191 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -44,6 +44,10 @@ FactoryGirl.define do
state :reopened
end
+ trait :locked do
+ state :locked
+ end
+
trait :simple do
source_branch "feature"
target_branch "master"
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index fe19a404e16..90c35e2c7f8 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -16,10 +16,21 @@ FactoryGirl.define do
factory :note_on_personal_snippet, traits: [:on_personal_snippet]
factory :system_note, traits: [:system]
- factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote do
+ factory :discussion_note_on_merge_request, traits: [:on_merge_request], class: DiscussionNote do
association :project, :repository
+
+ trait :resolved do
+ resolved_at { Time.now }
+ resolved_by { create(:user) }
+ end
end
+ factory :discussion_note_on_issue, traits: [:on_issue], class: DiscussionNote
+
+ factory :discussion_note_on_commit, traits: [:on_commit], class: DiscussionNote
+
+ factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote
+
factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: LegacyDiffNote do
association :project, :repository
end
@@ -37,7 +48,7 @@ FactoryGirl.define do
new_path: "files/ruby/popen.rb",
old_line: nil,
new_line: line_number,
- diff_refs: noteable.diff_refs
+ diff_refs: noteable.try(:diff_refs)
)
end
@@ -108,5 +119,18 @@ FactoryGirl.define do
trait :with_svg_attachment do
attachment { fixture_file_upload(Rails.root + "spec/fixtures/unsanitized.svg", "image/svg+xml") }
end
+
+ transient do
+ in_reply_to nil
+ end
+
+ before(:create) do |note, evaluator|
+ discussion = evaluator.in_reply_to
+ next unless discussion
+ discussion = discussion.to_discussion if discussion.is_a?(Note)
+ next unless discussion
+
+ note.assign_attributes(discussion.reply_attributes.merge(project: discussion.project))
+ end
end
end
diff --git a/spec/factories/protected_tags.rb b/spec/factories/protected_tags.rb
new file mode 100644
index 00000000000..d8e90ae1ee1
--- /dev/null
+++ b/spec/factories/protected_tags.rb
@@ -0,0 +1,22 @@
+FactoryGirl.define do
+ factory :protected_tag do
+ name
+ project
+
+ after(:build) do |protected_tag|
+ protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER)
+ end
+
+ trait :developers_can_create do
+ after(:create) do |protected_tag|
+ protected_tag.create_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER)
+ end
+ end
+
+ trait :no_one_can_create do
+ after(:create) do |protected_tag|
+ protected_tag.create_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS)
+ end
+ end
+ end
+end
diff --git a/spec/factories/sent_notifications.rb b/spec/factories/sent_notifications.rb
index 6287c40afe9..99253be5a22 100644
--- a/spec/factories/sent_notifications.rb
+++ b/spec/factories/sent_notifications.rb
@@ -2,7 +2,7 @@ FactoryGirl.define do
factory :sent_notification do
project factory: :empty_project
recipient factory: :user
- noteable factory: :issue
- reply_key "0123456789abcdef" * 2
+ noteable { create(:issue, project: project) }
+ reply_key { SentNotification.reply_key }
end
end
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index 56d5c7c327e..d5f595894d6 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -25,13 +25,22 @@ feature 'Admin Groups', feature: true do
visit admin_groups_path
click_link "New group"
- fill_in 'group_path', with: 'gitlab'
- fill_in 'group_description', with: 'Group description'
+ path_component = 'gitlab'
+ group_name = 'GitLab group name'
+ group_description = 'Description of group for GitLab'
+ fill_in 'group_path', with: path_component
+ fill_in 'group_name', with: group_name
+ fill_in 'group_description', with: group_description
click_button "Create group"
- expect(current_path).to eq admin_group_path(Group.find_by(path: 'gitlab'))
- expect(page).to have_content('Group: gitlab')
- expect(page).to have_content('Group description')
+ expect(current_path).to eq admin_group_path(Group.find_by(path: path_component))
+ content = page.find('div#content-body')
+ h3_texts = content.all('h3').collect(&:text).join("\n")
+ expect(h3_texts).to match group_name
+ li_texts = content.all('li').collect(&:text).join("\n")
+ expect(li_texts).to match group_name
+ expect(li_texts).to match path_component
+ expect(li_texts).to match group_description
end
scenario 'shows the visibility level radio populated with the default value' do
@@ -39,6 +48,15 @@ feature 'Admin Groups', feature: true do
expect_selected_visibility(internal)
end
+
+ scenario 'when entered in group path, it auto filled the group name', js: true do
+ visit admin_groups_path
+ click_link "New group"
+ group_path = 'gitlab'
+ fill_in 'group_path', with: group_path
+ name_field = find('input#group_name')
+ expect(name_field.value).to eq group_path
+ end
end
describe 'show a group' do
@@ -59,6 +77,17 @@ feature 'Admin Groups', feature: true do
expect_selected_visibility(group.visibility_level)
end
+
+ scenario 'edit group path does not change group name', js: true do
+ group = create(:group, :private)
+
+ visit admin_group_edit_path(group)
+ name_field = find('input#group_name')
+ original_name = name_field.value
+ fill_in 'group_path', with: 'this-new-path'
+
+ expect(name_field.value).to eq original_name
+ end
end
describe 'add user into a group', js: true do
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index c0807b8c507..f6c3bc6a58d 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -223,7 +223,7 @@ describe "Admin::Users", feature: true do
it "changes user entry" do
user.reload
expect(user.name).to eq('Big Bang')
- expect(user.is_admin?).to be_truthy
+ expect(user.admin?).to be_truthy
expect(user.password_expires_at).to be <= Time.now
end
end
diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb
index 19f5d1b0f30..1d4b86ed4b4 100644
--- a/spec/features/dashboard/group_spec.rb
+++ b/spec/features/dashboard/group_spec.rb
@@ -5,16 +5,18 @@ RSpec.describe 'Dashboard Group', feature: true do
login_as(:user)
end
- it 'creates new grpup' do
+ it 'creates new group', js: true do
visit dashboard_groups_path
click_link 'New group'
+ new_path = 'Samurai'
+ new_description = 'Tokugawa Shogunate'
- fill_in 'group_path', with: 'Samurai'
- fill_in 'group_description', with: 'Tokugawa Shogunate'
+ fill_in 'group_path', with: new_path
+ fill_in 'group_description', with: new_description
click_button 'Create group'
- expect(current_path).to eq group_path(Group.find_by(name: 'Samurai'))
- expect(page).to have_content('Samurai')
- expect(page).to have_content('Tokugawa Shogunate')
+ expect(current_path).to eq group_path(Group.find_by(name: new_path))
+ expect(page).to have_content(new_path)
+ expect(page).to have_content(new_description)
end
end
diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb
index a1718912fc6..4fca7577e74 100644
--- a/spec/features/dashboard/issuables_counter_spec.rb
+++ b/spec/features/dashboard/issuables_counter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Navigation bar counter', feature: true, js: true, caching: true do
+describe 'Navigation bar counter', feature: true, caching: true do
let(:user) { create(:user) }
let(:project) { create(:empty_project, namespace: user.namespace) }
let(:issue) { create(:issue, project: project) }
@@ -13,33 +13,48 @@ describe 'Navigation bar counter', feature: true, js: true, caching: true do
end
it 'reflects dashboard issues count' do
- visit issues_dashboard_path
+ visit issues_path
expect_counters('issues', '1')
issue.update(assignee: nil)
- visit issues_dashboard_path
- expect_counters('issues', '1')
+ Timecop.travel(3.minutes.from_now) do
+ visit issues_path
+
+ expect_counters('issues', '0')
+ end
end
it 'reflects dashboard merge requests count' do
- visit merge_requests_dashboard_path
+ visit merge_requests_path
expect_counters('merge_requests', '1')
merge_request.update(assignee: nil)
- visit merge_requests_dashboard_path
- expect_counters('merge_requests', '1')
+ Timecop.travel(3.minutes.from_now) do
+ visit merge_requests_path
+
+ expect_counters('merge_requests', '0')
+ end
+ end
+
+ def issues_path
+ issues_dashboard_path(assignee_id: user.id)
+ end
+
+ def merge_requests_path
+ merge_requests_dashboard_path(assignee_id: user.id)
end
def expect_counters(issuable_type, count)
- dashboard_count = find('li.active')
- find('.global-dropdown-toggle').click
+ dashboard_count = find('.nav-links li.active')
nav_count = find(".dashboard-shortcuts-#{issuable_type}")
+ header_count = find(".header-content .#{issuable_type.tr('_', '-')}-count")
- expect(nav_count).to have_content(count)
expect(dashboard_count).to have_content(count)
+ expect(nav_count).to have_content(count)
+ expect(header_count).to have_content(count)
end
end
diff --git a/spec/features/dashboard/project_member_activity_index_spec.rb b/spec/features/dashboard/project_member_activity_index_spec.rb
index d62839a09ef..49d93db58a9 100644
--- a/spec/features/dashboard/project_member_activity_index_spec.rb
+++ b/spec/features/dashboard/project_member_activity_index_spec.rb
@@ -21,20 +21,20 @@ feature 'Project member activity', feature: true, js: true do
context 'when a user joins the project' do
before { visit_activities_and_wait_with_event(Event::JOINED) }
- it { is_expected.to eq("joined project") }
+ it { is_expected.to eq("#{user.name} joined project") }
end
context 'when a user leaves the project' do
before { visit_activities_and_wait_with_event(Event::LEFT) }
- it { is_expected.to eq("left project") }
+ it { is_expected.to eq("#{user.name} left project") }
end
context 'when a users membership expires for the project' do
before { visit_activities_and_wait_with_event(Event::EXPIRED) }
it "presents the correct message" do
- message = "removed due to membership expiration from project"
+ message = "#{user.name} removed due to membership expiration from project"
is_expected.to eq(message)
end
end
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index c4e58d14f75..f1789fc9d43 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe 'Dashboard Projects', feature: true do
before do
project.team << [user, :developer]
login_as user
- visit dashboard_projects_path
end
it 'shows the project the user in a member of in the list' do
@@ -15,15 +14,19 @@ RSpec.describe 'Dashboard Projects', feature: true do
expect(page).to have_content('awesome stuff')
end
- describe "with a pipeline" do
- let(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) }
+ describe "with a pipeline", redis: true do
+ let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha) }
before do
- pipeline
+ # Since the cache isn't updated when a new pipeline is created
+ # we need the pipeline to advance in the pipeline since the cache was created
+ # by visiting the login page.
+ pipeline.succeed
end
it 'shows that the last pipeline passed' do
visit dashboard_projects_path
+
expect(page).to have_xpath("//a[@href='#{pipelines_namespace_project_commit_path(project.namespace, project, project.commit)}']")
end
end
diff --git a/spec/features/discussion_comments_spec.rb b/spec/features/discussion_comments_spec.rb
new file mode 100644
index 00000000000..ae778118c5c
--- /dev/null
+++ b/spec/features/discussion_comments_spec.rb
@@ -0,0 +1,295 @@
+require 'spec_helper'
+
+shared_examples 'discussion comments' do |resource_name|
+ let(:form_selector) { '.js-main-target-form' }
+ let(:dropdown_selector) { "#{form_selector} .comment-type-dropdown" }
+ let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle" }
+ let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" }
+ let(:submit_selector) { "#{form_selector} .js-comment-submit-button" }
+ let(:close_selector) { "#{form_selector} .btn-comment-and-close" }
+ let(:comments_selector) { '.timeline > .note.timeline-entry' }
+
+ it 'should show a comment type toggle' do
+ expect(page).to have_selector toggle_selector
+ end
+
+ it 'clicking "Comment" will post a comment' do
+ find("#{form_selector} .note-textarea").send_keys('a')
+
+ find(submit_selector).click
+
+ find(comments_selector, match: :first)
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).to have_content 'a'
+ expect(new_comment).not_to have_selector '.discussion'
+ end
+
+ if resource_name == 'issue'
+ it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do
+ find("#{form_selector} .note-textarea").send_keys('a')
+
+ find(close_selector).click
+
+ find(comments_selector, match: :first)
+ find("#{comments_selector}.system-note")
+ entries = all(comments_selector)
+ close_note = entries.last
+ new_comment = entries[-2]
+
+ expect(close_note).to have_content 'closed'
+ expect(new_comment).not_to have_selector '.discussion'
+ end
+ end
+
+ describe 'when the toggle is clicked' do
+ before do
+ find("#{form_selector} .note-textarea").send_keys('a')
+
+ find(toggle_selector).click
+ end
+
+ it 'opens a comment type dropdown with "Comment" and "Start discussion"' do
+ expect(page).to have_selector menu_selector
+ end
+
+ it 'has a "Comment" item' do
+ menu = find(menu_selector)
+
+ expect(menu).to have_content 'Comment'
+ expect(menu).to have_content "Add a general comment to this #{resource_name}."
+ end
+
+ it 'has a "Start discussion" item' do
+ menu = find(menu_selector)
+
+ expect(menu).to have_content 'Start discussion'
+ expect(menu).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}."
+ end
+
+ it 'has the "Comment" item selected by default' do
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).to have_selector '.fa-check'
+ expect(items.first['class']).to match 'droplab-item-selected'
+
+ expect(items.last).to have_content 'Start discussion'
+ expect(items.last).not_to have_selector '.fa-check'
+ expect(items.last['class']).not_to match 'droplab-item-selected'
+ end
+
+ it 'closes the menu when clicking the toggle' do
+ find(toggle_selector).click
+
+ expect(page).not_to have_selector menu_selector
+ end
+
+ it 'closes the menu when clicking the body' do
+ find('body').click
+
+ expect(page).not_to have_selector menu_selector
+ end
+
+ it 'clicking the ul padding should not change the text' do
+ find(menu_selector).trigger 'click'
+
+ expect(find(dropdown_selector)).to have_content 'Comment'
+ end
+
+ describe 'when selecting "Start discussion"' do
+ before do
+ find("#{menu_selector} li", match: :first)
+ all("#{menu_selector} li").last.click
+ end
+
+ it 'updates the note_type input to "DiscussionNote"' do
+ expect(find("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote')
+ end
+
+ it 'updates the submit button text' do
+ expect(find(dropdown_selector)).to have_content 'Start discussion'
+ end
+
+ if resource_name =~ /(issue|merge request)/
+ it 'updates the close button text' do
+ expect(find(close_selector)).to have_content "Start discussion & close #{resource_name}"
+ end
+
+ it 'typing does not change the close button text' do
+ find("#{form_selector} .note-textarea").send_keys('b')
+
+ expect(find(close_selector)).to have_content "Start discussion & close #{resource_name}"
+ end
+ end
+
+ it 'closes the dropdown' do
+ expect(page).not_to have_selector menu_selector
+ end
+
+ it 'clicking "Start discussion" will post a discussion' do
+ find(submit_selector).click
+
+ find(comments_selector, match: :first)
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).to have_content 'a'
+ expect(new_comment).to have_selector '.discussion'
+ end
+
+ if resource_name == 'issue'
+ it "clicking 'Start discussion & close #{resource_name}' will post a discussion and close the #{resource_name}" do
+ find(close_selector).click
+
+ find(comments_selector, match: :first)
+ find("#{comments_selector}.system-note")
+ entries = all(comments_selector)
+ close_note = entries.last
+ new_discussion = entries[-2]
+
+ expect(close_note).to have_content 'closed'
+ expect(new_discussion).to have_selector '.discussion'
+ end
+ end
+
+ describe 'when opening the menu' do
+ before do
+ find(toggle_selector).click
+ end
+
+ it 'should have "Start discussion" selected' do
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).not_to have_selector '.fa-check'
+ expect(items.first['class']).not_to match 'droplab-item-selected'
+
+ expect(items.last).to have_content 'Start discussion'
+ expect(items.last).to have_selector '.fa-check'
+ expect(items.last['class']).to match 'droplab-item-selected'
+ end
+
+ describe 'when selecting "Comment"' do
+ before do
+ find("#{menu_selector} li", match: :first).click
+ end
+
+ it 'clears the note_type input"' do
+ expect(find("#{form_selector} #note_type", visible: false).value).to eq('')
+ end
+
+ it 'updates the submit button text' do
+ expect(find(dropdown_selector)).to have_content 'Comment'
+ end
+
+ if resource_name =~ /(issue|merge request)/
+ it 'updates the close button text' do
+ expect(find(close_selector)).to have_content "Comment & close #{resource_name}"
+ end
+
+ it 'typing does not change the close button text' do
+ find("#{form_selector} .note-textarea").send_keys('b')
+
+ expect(find(close_selector)).to have_content "Comment & close #{resource_name}"
+ end
+ end
+
+ it 'closes the dropdown' do
+ expect(page).not_to have_selector menu_selector
+ end
+
+ it 'should have "Comment" selected when opening the menu' do
+ find(toggle_selector).click
+
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).to have_selector '.fa-check'
+ expect(items.first['class']).to match 'droplab-item-selected'
+
+ expect(items.last).to have_content 'Start discussion'
+ expect(items.last).not_to have_selector '.fa-check'
+ expect(items.last['class']).not_to match 'droplab-item-selected'
+ end
+ end
+ end
+ end
+ end
+
+ if resource_name =~ /(issue|merge request)/
+ describe "on a closed #{resource_name}" do
+ before do
+ find("#{form_selector} .js-note-target-close").click
+
+ find("#{form_selector} .note-textarea").send_keys('a')
+ end
+
+ it "should show a 'Comment & reopen #{resource_name}' button" do
+ expect(find("#{form_selector} .js-note-target-reopen")).to have_content "Comment & reopen #{resource_name}"
+ end
+
+ it "should show a 'Start discussion & reopen #{resource_name}' button when 'Start discussion' is selected" do
+ find(toggle_selector).click
+
+ find("#{menu_selector} li", match: :first)
+ all("#{menu_selector} li").last.click
+
+ expect(find("#{form_selector} .js-note-target-reopen")).to have_content "Start discussion & reopen #{resource_name}"
+ end
+ end
+ end
+end
+
+describe 'Discussion Comments', :feature, :js do
+ include RepoHelpers
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ before do
+ project.team << [user, :developer]
+
+ login_as(user)
+ end
+
+ describe 'on a merge request' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ it_behaves_like 'discussion comments', 'merge request'
+ end
+
+ describe 'on an issue' do
+ let(:issue) { create(:issue, project: project) }
+
+ before do
+ visit namespace_project_issue_path(project.namespace, project, issue)
+ end
+
+ it_behaves_like 'discussion comments', 'issue'
+ end
+
+ describe 'on an snippet' do
+ let(:snippet) { create(:project_snippet, :private, project: project, author: user) }
+
+ before do
+ visit namespace_project_snippet_path(project.namespace, project, snippet)
+ end
+
+ it_behaves_like 'discussion comments', 'snippet'
+ end
+
+ describe 'on a commit' do
+ before do
+ visit namespace_project_commit_path(project.namespace, project, sample_commit.id)
+ end
+
+ it_behaves_like 'discussion comments', 'commit'
+ end
+end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 7670c4caea4..3d32c47bf09 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -83,7 +83,7 @@ feature 'Group', feature: true do
end
end
- describe 'create a nested group' do
+ describe 'create a nested group', js: true do
let(:group) { create(:group, path: 'foo') }
context 'as admin' do
diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
index 572bca3de21..58f897cba3e 100644
--- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
+++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
@@ -4,7 +4,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:merge_request) { create(:merge_request, source_project: project) }
- let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first }
+ let!(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
describe 'as a user with access to the project' do
before do
diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
index bc8cbe30e66..cae01f37b6b 100644
--- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe 'Dropdown hint', js: true, feature: true do
+describe 'Dropdown hint', :js, :feature do
include FilteredSearchHelpers
include WaitForAjax
@@ -9,10 +9,6 @@ describe 'Dropdown hint', js: true, feature: true do
let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_hint) { '#js-dropdown-hint' }
- def dropdown_hint_size
- page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size
- end
-
def click_hint(text)
find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click
end
@@ -46,14 +42,16 @@ describe 'Dropdown hint', js: true, feature: true do
it 'does not filter `Press Enter or click to search`' do
filtered_search.set('randomtext')
- expect(page).to have_css(js_dropdown_hint, text: 'Press Enter or click to search', visible: false)
- expect(dropdown_hint_size).to eq(0)
+ hint_dropdown = find(js_dropdown_hint)
+
+ expect(hint_dropdown).to have_content('Press Enter or click to search')
+ expect(hint_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 0)
end
it 'filters with text' do
filtered_search.set('a')
- expect(dropdown_hint_size).to eq(3)
+ expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3)
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb
index c8645e08c4b..abe5d61e38c 100644
--- a/spec/features/issues/filtered_search/dropdown_label_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb
@@ -28,10 +28,6 @@ describe 'Dropdown label', js: true, feature: true do
filter_dropdown.find('.filter-dropdown-item', text: text).click
end
- def dropdown_label_size
- filter_dropdown.all('.filter-dropdown-item').size
- end
-
def clear_search_field
find('.filtered-search-box .clear-search').click
end
@@ -81,7 +77,7 @@ describe 'Dropdown label', js: true, feature: true do
filtered_search.set('label:')
expect(filter_dropdown).to have_content(bug_label.title)
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
end
end
@@ -97,7 +93,8 @@ describe 'Dropdown label', js: true, feature: true do
expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible
expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible
- expect(dropdown_label_size).to eq(2)
+
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 2)
clear_search_field
init_label_search
@@ -106,14 +103,14 @@ describe 'Dropdown label', js: true, feature: true do
expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible
expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible
- expect(dropdown_label_size).to eq(2)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 2)
end
it 'filters by multiple words with or without symbol' do
filtered_search.send_keys('Hig')
expect(filter_dropdown.find('.filter-dropdown-item', text: two_words_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
clear_search_field
init_label_search
@@ -121,14 +118,14 @@ describe 'Dropdown label', js: true, feature: true do
filtered_search.send_keys('~Hig')
expect(filter_dropdown.find('.filter-dropdown-item', text: two_words_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
end
it 'filters by multiple words containing single quotes with or without symbol' do
filtered_search.send_keys('won\'t')
expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_single_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
clear_search_field
init_label_search
@@ -136,14 +133,14 @@ describe 'Dropdown label', js: true, feature: true do
filtered_search.send_keys('~won\'t')
expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_single_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
end
it 'filters by multiple words containing double quotes with or without symbol' do
filtered_search.send_keys('won"t')
expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
clear_search_field
init_label_search
@@ -151,14 +148,14 @@ describe 'Dropdown label', js: true, feature: true do
filtered_search.send_keys('~won"t')
expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
end
it 'filters by special characters with or without symbol' do
filtered_search.send_keys('^+')
expect(filter_dropdown.find('.filter-dropdown-item', text: special_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
clear_search_field
init_label_search
@@ -166,7 +163,7 @@ describe 'Dropdown label', js: true, feature: true do
filtered_search.send_keys('~^+')
expect(filter_dropdown.find('.filter-dropdown-item', text: special_label.title)).to be_visible
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
end
end
@@ -280,13 +277,13 @@ describe 'Dropdown label', js: true, feature: true do
create(:label, project: project, title: 'bug-label')
init_label_search
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
create(:label, project: project)
clear_search_field
init_label_search
- expect(dropdown_label_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1)
end
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
index 0a525bc68c9..448259057b0 100644
--- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
@@ -65,7 +65,7 @@ describe 'Dropdown milestone', :feature, :js do
it 'should load all the milestones when opened' do
filtered_search.set('milestone:')
- expect(dropdown_milestone_size).to be > 0
+ expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6)
end
end
@@ -84,37 +84,37 @@ describe 'Dropdown milestone', :feature, :js do
it 'filters by name' do
filtered_search.send_keys('v1')
- expect(dropdown_milestone_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
it 'filters by case insensitive name' do
filtered_search.send_keys('V1')
- expect(dropdown_milestone_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
it 'filters by name with symbol' do
filtered_search.send_keys('%v1')
- expect(dropdown_milestone_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
it 'filters by case insensitive name with symbol' do
filtered_search.send_keys('%V1')
- expect(dropdown_milestone_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
it 'filters by special characters' do
filtered_search.send_keys('(+')
- expect(dropdown_milestone_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
it 'filters by special characters with symbol' do
filtered_search.send_keys('%(+')
- expect(dropdown_milestone_size).to eq(1)
+ expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
end
diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb
index 48e7af67616..28137f11b92 100644
--- a/spec/features/issues/filtered_search/search_bar_spec.rb
+++ b/spec/features/issues/filtered_search/search_bar_spec.rb
@@ -26,7 +26,7 @@ describe 'Search bar', js: true, feature: true do
filtered_search.native.send_keys(:down)
page.within '#js-dropdown-hint' do
- expect(page).to have_selector('.dropdown-active')
+ expect(page).to have_selector('.droplab-item-active')
end
end
@@ -79,28 +79,30 @@ describe 'Search bar', js: true, feature: true do
filtered_search.set('author')
- expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(1)
+ expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
find('.filtered-search-box .clear-search').click
filtered_search.click
- expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size)
+ expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: original_size)
end
it 'resets the dropdown filters' do
+ filtered_search.click
+
+ hint_offset = get_left_style(find('#js-dropdown-hint')['style'])
+
filtered_search.set('a')
- hint_style = page.find('#js-dropdown-hint')['style']
- hint_offset = get_left_style(hint_style)
filtered_search.set('author:')
- expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(0)
+ find('#js-dropdown-hint', visible: false)
find('.filtered-search-box .clear-search').click
filtered_search.click
- expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0
- expect(get_left_style(page.find('#js-dropdown-hint')['style'])).to eq(hint_offset)
+ expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 4)
+ expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset)
end
end
end
diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb
index a6c72b0b3ac..218d95a88b8 100644
--- a/spec/features/merge_requests/diff_notes_avatars_spec.rb
+++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb
@@ -164,9 +164,7 @@ feature 'Diff note avatars', feature: true, js: true do
context 'multiple comments' do
before do
- create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position)
- create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position)
- create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position)
+ create_list(:diff_note_on_merge_request, 3, project: project, noteable: merge_request, in_reply_to: note)
visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view)
diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb
index 69164aabdb2..88d28b649a4 100644
--- a/spec/features/merge_requests/diff_notes_resolve_spec.rb
+++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb
@@ -191,7 +191,7 @@ feature 'Diff notes resolve', feature: true, js: true do
context 'multiple notes' do
before do
- create(:diff_note_on_merge_request, project: project, noteable: merge_request)
+ create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: note)
visit_merge_request
end
diff --git a/spec/features/notes_on_merge_requests_spec.rb b/spec/features/notes_on_merge_requests_spec.rb
index fab2d532e06..783f2e93909 100644
--- a/spec/features/notes_on_merge_requests_spec.rb
+++ b/spec/features/notes_on_merge_requests_spec.rb
@@ -25,7 +25,7 @@ describe 'Comments', feature: true do
describe 'the note form' do
it 'is valid' do
is_expected.to have_css('.js-main-target-form', visible: true, count: 1)
- expect(find('.js-main-target-form input[type=submit]').value).
+ expect(find('.js-main-target-form .js-comment-button').value).
to eq('Comment')
page.within('.js-main-target-form') do
expect(page).not_to have_link('Cancel')
diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb
index 76cb240ea98..035c57eaa47 100644
--- a/spec/features/projects/settings/pipelines_settings_spec.rb
+++ b/spec/features/projects/settings/pipelines_settings_spec.rb
@@ -32,5 +32,16 @@ feature "Pipelines settings", feature: true do
expect(page).to have_button('Save changes', disabled: false)
expect(page).to have_field('Test coverage parsing', with: 'coverage_regex')
end
+
+ scenario 'updates auto_cancel_pending_pipelines' do
+ page.check('Auto-cancel redundant, pending pipelines')
+ click_on 'Save changes'
+
+ expect(page.status_code).to eq(200)
+ expect(page).to have_button('Save changes', disabled: false)
+
+ checkbox = find_field('project_auto_cancel_pending_pipelines')
+ expect(checkbox).to be_checked
+ end
end
end
diff --git a/spec/features/protected_branches/access_control_ce_spec.rb b/spec/features/protected_branches/access_control_ce_spec.rb
index e4aca25a339..eb3cea775da 100644
--- a/spec/features/protected_branches/access_control_ce_spec.rb
+++ b/spec/features/protected_branches/access_control_ce_spec.rb
@@ -2,7 +2,9 @@ RSpec.shared_examples "protected branches > access control > CE" do
ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
it "allows creating protected branches that #{access_type_name} can push to" do
visit namespace_project_protected_branches_path(project.namespace, project)
+
set_protected_branch_name('master')
+
within('.new_protected_branch') do
allowed_to_push_button = find(".js-allowed-to-push")
@@ -11,6 +13,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
within(".dropdown.open .dropdown-menu") { click_on access_type_name }
end
end
+
click_on "Protect"
expect(ProtectedBranch.count).to eq(1)
@@ -19,7 +22,9 @@ RSpec.shared_examples "protected branches > access control > CE" do
it "allows updating protected branches so that #{access_type_name} can push to them" do
visit namespace_project_protected_branches_path(project.namespace, project)
+
set_protected_branch_name('master')
+
click_on "Protect"
expect(ProtectedBranch.count).to eq(1)
@@ -34,6 +39,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
end
wait_for_ajax
+
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
end
end
@@ -41,7 +47,9 @@ RSpec.shared_examples "protected branches > access control > CE" do
ProtectedBranch::MergeAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
it "allows creating protected branches that #{access_type_name} can merge to" do
visit namespace_project_protected_branches_path(project.namespace, project)
+
set_protected_branch_name('master')
+
within('.new_protected_branch') do
allowed_to_merge_button = find(".js-allowed-to-merge")
@@ -50,6 +58,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
within(".dropdown.open .dropdown-menu") { click_on access_type_name }
end
end
+
click_on "Protect"
expect(ProtectedBranch.count).to eq(1)
@@ -58,7 +67,9 @@ RSpec.shared_examples "protected branches > access control > CE" do
it "allows updating protected branches so that #{access_type_name} can merge to them" do
visit namespace_project_protected_branches_path(project.namespace, project)
+
set_protected_branch_name('master')
+
click_on "Protect"
expect(ProtectedBranch.count).to eq(1)
@@ -73,6 +84,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
end
wait_for_ajax
+
expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id)
end
end
diff --git a/spec/features/protected_tags/access_control_ce_spec.rb b/spec/features/protected_tags/access_control_ce_spec.rb
new file mode 100644
index 00000000000..5b2baf8616c
--- /dev/null
+++ b/spec/features/protected_tags/access_control_ce_spec.rb
@@ -0,0 +1,46 @@
+RSpec.shared_examples "protected tags > access control > CE" do
+ ProtectedTag::CreateAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
+ it "allows creating protected tags that #{access_type_name} can create" do
+ visit namespace_project_protected_tags_path(project.namespace, project)
+
+ set_protected_tag_name('master')
+
+ within('.js-new-protected-tag') do
+ allowed_to_create_button = find(".js-allowed-to-create")
+
+ unless allowed_to_create_button.text == access_type_name
+ allowed_to_create_button.click
+ within(".dropdown.open .dropdown-menu") { click_on access_type_name }
+ end
+ end
+
+ click_on "Protect"
+
+ expect(ProtectedTag.count).to eq(1)
+ expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to eq([access_type_id])
+ end
+
+ it "allows updating protected tags so that #{access_type_name} can create them" do
+ visit namespace_project_protected_tags_path(project.namespace, project)
+
+ set_protected_tag_name('master')
+
+ click_on "Protect"
+
+ expect(ProtectedTag.count).to eq(1)
+
+ within(".protected-tags-list") do
+ find(".js-allowed-to-create").click
+
+ within('.js-allowed-to-create-container') do
+ expect(first("li")).to have_content("Roles")
+ click_on access_type_name
+ end
+ end
+
+ wait_for_ajax
+
+ expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to include(access_type_id)
+ end
+ end
+end
diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb
new file mode 100644
index 00000000000..09e8c850de3
--- /dev/null
+++ b/spec/features/protected_tags_spec.rb
@@ -0,0 +1,94 @@
+require 'spec_helper'
+Dir["./spec/features/protected_tags/*.rb"].sort.each { |f| require f }
+
+feature 'Projected Tags', feature: true, js: true do
+ include WaitForAjax
+
+ let(:user) { create(:user, :admin) }
+ let(:project) { create(:project) }
+
+ before { login_as(user) }
+
+ def set_protected_tag_name(tag_name)
+ find(".js-protected-tag-select").click
+ find(".dropdown-input-field").set(tag_name)
+ click_on("Create wildcard #{tag_name}")
+ end
+
+ describe "explicit protected tags" do
+ it "allows creating explicit protected tags" do
+ visit namespace_project_protected_tags_path(project.namespace, project)
+ set_protected_tag_name('some-tag')
+ click_on "Protect"
+
+ within(".protected-tags-list") { expect(page).to have_content('some-tag') }
+ expect(ProtectedTag.count).to eq(1)
+ expect(ProtectedTag.last.name).to eq('some-tag')
+ end
+
+ it "displays the last commit on the matching tag if it exists" do
+ commit = create(:commit, project: project)
+ project.repository.add_tag(user, 'some-tag', commit.id)
+
+ visit namespace_project_protected_tags_path(project.namespace, project)
+ set_protected_tag_name('some-tag')
+ click_on "Protect"
+
+ within(".protected-tags-list") { expect(page).to have_content(commit.id[0..7]) }
+ end
+
+ it "displays an error message if the named tag does not exist" do
+ visit namespace_project_protected_tags_path(project.namespace, project)
+ set_protected_tag_name('some-tag')
+ click_on "Protect"
+
+ within(".protected-tags-list") { expect(page).to have_content('tag was removed') }
+ end
+ end
+
+ describe "wildcard protected tags" do
+ it "allows creating protected tags with a wildcard" do
+ visit namespace_project_protected_tags_path(project.namespace, project)
+ set_protected_tag_name('*-stable')
+ click_on "Protect"
+
+ within(".protected-tags-list") { expect(page).to have_content('*-stable') }
+ expect(ProtectedTag.count).to eq(1)
+ expect(ProtectedTag.last.name).to eq('*-stable')
+ end
+
+ it "displays the number of matching tags" do
+ project.repository.add_tag(user, 'production-stable', 'master')
+ project.repository.add_tag(user, 'staging-stable', 'master')
+
+ visit namespace_project_protected_tags_path(project.namespace, project)
+ set_protected_tag_name('*-stable')
+ click_on "Protect"
+
+ within(".protected-tags-list") { expect(page).to have_content("2 matching tags") }
+ end
+
+ it "displays all the tags matching the wildcard" do
+ project.repository.add_tag(user, 'production-stable', 'master')
+ project.repository.add_tag(user, 'staging-stable', 'master')
+ project.repository.add_tag(user, 'development', 'master')
+
+ visit namespace_project_protected_tags_path(project.namespace, project)
+ set_protected_tag_name('*-stable')
+ click_on "Protect"
+
+ visit namespace_project_protected_tags_path(project.namespace, project)
+ click_on "2 matching tags"
+
+ within(".protected-tags-list") do
+ expect(page).to have_content("production-stable")
+ expect(page).to have_content("staging-stable")
+ expect(page).not_to have_content("development")
+ end
+ end
+ end
+
+ describe "access control" do
+ include_examples "protected tags > access control > CE"
+ end
+end
diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb
index c1ae6db00c6..81fa2de1cc3 100644
--- a/spec/features/triggers_spec.rb
+++ b/spec/features/triggers_spec.rb
@@ -77,6 +77,59 @@ feature 'Triggers', feature: true, js: true do
expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
expect(page.find('.triggers-list')).to have_content new_trigger_title
end
+
+ context 'scheduled triggers' do
+ let!(:trigger) do
+ create(:ci_trigger, owner: user, project: @project, description: trigger_title)
+ end
+
+ context 'enabling schedule' do
+ before do
+ visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger)
+ end
+
+ scenario 'do fill form with valid data and save' do
+ find('#trigger_trigger_schedule_attributes_active').click
+ fill_in 'trigger_trigger_schedule_attributes_cron', with: '1 * * * *'
+ fill_in 'trigger_trigger_schedule_attributes_cron_timezone', with: 'UTC'
+ fill_in 'trigger_trigger_schedule_attributes_ref', with: 'master'
+ click_button 'Save trigger'
+
+ expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
+ end
+
+ scenario 'do not fill form with valid data and save' do
+ find('#trigger_trigger_schedule_attributes_active').click
+ click_button 'Save trigger'
+
+ expect(page).to have_content 'The form contains the following errors'
+ end
+ end
+
+ context 'disabling schedule' do
+ before do
+ trigger.create_trigger_schedule(
+ project: trigger.project,
+ active: true,
+ ref: 'master',
+ cron: '1 * * * *',
+ cron_timezone: 'UTC')
+
+ visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger)
+ end
+
+ scenario 'disable and save form' do
+ find('#trigger_trigger_schedule_attributes_active').click
+ click_button 'Save trigger'
+ expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
+
+ visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger)
+ checkbox = find_field('trigger_trigger_schedule_attributes_active')
+
+ expect(checkbox).not_to be_checked
+ end
+ end
+ end
end
describe 'trigger "Take ownership" workflow' do
diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb
index 77a04507be1..765bf44d863 100644
--- a/spec/finders/notes_finder_spec.rb
+++ b/spec/finders/notes_finder_spec.rb
@@ -202,4 +202,45 @@ describe NotesFinder do
end
end
end
+
+ describe '#target' do
+ subject { described_class.new(project, user, params) }
+
+ context 'for a issue target' do
+ let(:issue) { create(:issue, project: project) }
+ let(:params) { { target_type: 'issue', target_id: issue.id } }
+
+ it 'returns the issue' do
+ expect(subject.target).to eq(issue)
+ end
+ end
+
+ context 'for a merge request target' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:params) { { target_type: 'merge_request', target_id: merge_request.id } }
+
+ it 'returns the merge_request' do
+ expect(subject.target).to eq(merge_request)
+ end
+ end
+
+ context 'for a snippet target' do
+ let(:snippet) { create(:project_snippet, project: project) }
+ let(:params) { { target_type: 'snippet', target_id: snippet.id } }
+
+ it 'returns the snippet' do
+ expect(subject.target).to eq(snippet)
+ end
+ end
+
+ context 'for a commit target' do
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit }
+ let(:params) { { target_type: 'commit', target_id: commit.id } }
+
+ it 'returns the commit' do
+ expect(subject.target).to eq(commit)
+ end
+ end
+ end
end
diff --git a/spec/helpers/ci_status_helper_spec.rb b/spec/helpers/ci_status_helper_spec.rb
index 174cc84a97b..c795fe5a2a3 100644
--- a/spec/helpers/ci_status_helper_spec.rb
+++ b/spec/helpers/ci_status_helper_spec.rb
@@ -19,7 +19,11 @@ describe CiStatusHelper do
describe "#pipeline_status_cache_key" do
it "builds a cache key for pipeline status" do
- pipeline_status = Ci::PipelineStatus.new(build(:project), sha: "123abc", status: "success")
+ pipeline_status = Gitlab::Cache::Ci::ProjectPipelineStatus.new(
+ build(:project),
+ sha: "123abc",
+ status: "success"
+ )
expect(helper.pipeline_status_cache_key(pipeline_status)).to eq("pipeline-status/123abc-success")
end
end
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index f0554cc068d..540cb0ab1e0 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -150,7 +150,7 @@ describe IssuesHelper do
describe "when passing a discussion" do
let(:diff_note) { create(:diff_note_on_merge_request) }
let(:merge_request) { diff_note.noteable }
- let(:discussion) { Discussion.new([diff_note]) }
+ let(:discussion) { diff_note.to_discussion }
it "links to the merge request with first note if a single discussion was passed" do
expected_path = Gitlab::UrlBuilder.build(diff_note)
diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb
index 9c577501f00..a427de32c4c 100644
--- a/spec/helpers/notes_helper_spec.rb
+++ b/spec/helpers/notes_helper_spec.rb
@@ -36,21 +36,4 @@ describe NotesHelper do
expect(helper.note_max_access_for_user(other_note)).to eq('Reporter')
end
end
-
- describe '#preload_max_access_for_authors' do
- before do
- # This method reads cache from RequestStore, so make sure it's clean.
- RequestStore.clear!
- end
-
- it 'loads multiple users' do
- expected_access = {
- owner.id => Gitlab::Access::OWNER,
- master.id => Gitlab::Access::MASTER,
- reporter.id => Gitlab::Access::REPORTER
- }
-
- expect(helper.preload_max_access_for_authors(notes, project)).to eq(expected_access)
- end
- end
end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 44312ada438..40efab6e4f7 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -63,7 +63,7 @@ describe ProjectsHelper do
end
end
- describe "#project_list_cache_key" do
+ describe "#project_list_cache_key", redis: true do
let(:project) { create(:project) }
it "includes the namespace" do
diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js
index cb48f14fd9a..edd4b3c1440 100644
--- a/spec/javascripts/build_spec.js
+++ b/spec/javascripts/build_spec.js
@@ -64,58 +64,33 @@ describe('Build', () => {
});
});
- describe('initial build trace', () => {
- beforeEach(() => {
- new Build();
- });
-
- it('displays the initial build trace', () => {
- expect($.ajax.calls.count()).toBe(1);
- const [{ url, dataType, success, context }] = $.ajax.calls.argsFor(0);
- expect(url).toBe(
- `${BUILD_URL}/trace.json`,
- );
- expect(dataType).toBe('json');
- expect(success).toEqual(jasmine.any(Function));
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
-
- success.call(context, { html: '<span>Example</span>', status: 'running' });
-
- expect($('#build-trace .js-build-output').text()).toMatch(/Example/);
- });
-
- it('removes the spinner', () => {
- const [{ success, context }] = $.ajax.calls.argsFor(0);
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- success.call(context, { trace_html: '<span>Example</span>', status: 'success' });
-
- expect($('.js-build-refresh').length).toBe(0);
- });
- });
-
describe('running build', () => {
beforeEach(function () {
- $('.js-build-options').data('buildStatus', 'running');
this.build = new Build();
- spyOn(this.build, 'location').and.returnValue(BUILD_URL);
});
it('updates the build trace on an interval', function () {
+ spyOn(gl.utils, 'visitUrl');
+
jasmine.clock().tick(4001);
- expect($.ajax.calls.count()).toBe(2);
- let [{ url, dataType, success, context }] = $.ajax.calls.argsFor(1);
- expect(url).toBe(
- `${BUILD_URL}/trace.json?state=`,
- );
- expect(dataType).toBe('json');
- expect(success).toEqual(jasmine.any(Function));
-
- success.call(context, {
+ expect($.ajax.calls.count()).toBe(1);
+
+ // We have to do it this way to prevent Webpack to fail to compile
+ // when destructuring assignments and reusing
+ // the same variables names inside the same scope
+ let args = $.ajax.calls.argsFor(0)[0];
+
+ expect(args.url).toBe(`${BUILD_URL}/trace.json`);
+ expect(args.dataType).toBe('json');
+ expect(args.success).toEqual(jasmine.any(Function));
+
+ args.success.call($, {
html: '<span>Update<span>',
status: 'running',
state: 'newstate',
append: true,
+ complete: false,
});
expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
@@ -123,17 +98,20 @@ describe('Build', () => {
jasmine.clock().tick(4001);
- expect($.ajax.calls.count()).toBe(3);
- [{ url, dataType, success, context }] = $.ajax.calls.argsFor(2);
- expect(url).toBe(`${BUILD_URL}/trace.json?state=newstate`);
- expect(dataType).toBe('json');
- expect(success).toEqual(jasmine.any(Function));
+ expect($.ajax.calls.count()).toBe(2);
+
+ args = $.ajax.calls.argsFor(1)[0];
+ expect(args.url).toBe(`${BUILD_URL}/trace.json`);
+ expect(args.dataType).toBe('json');
+ expect(args.data.state).toBe('newstate');
+ expect(args.success).toEqual(jasmine.any(Function));
- success.call(context, {
+ args.success.call($, {
html: '<span>More</span>',
status: 'running',
state: 'finalstate',
append: true,
+ complete: true,
});
expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/);
@@ -141,19 +119,22 @@ describe('Build', () => {
});
it('replaces the entire build trace', () => {
+ spyOn(gl.utils, 'visitUrl');
+
jasmine.clock().tick(4001);
- let [{ success, context }] = $.ajax.calls.argsFor(1);
- success.call(context, {
+ let args = $.ajax.calls.argsFor(0)[0];
+ args.success.call($, {
html: '<span>Update</span>',
status: 'running',
- append: true,
+ append: false,
+ complete: false,
});
expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
jasmine.clock().tick(4001);
- [{ success, context }] = $.ajax.calls.argsFor(2);
- success.call(context, {
+ args = $.ajax.calls.argsFor(1)[0];
+ args.success.call($, {
html: '<span>Different</span>',
status: 'running',
append: false,
@@ -163,15 +144,34 @@ describe('Build', () => {
expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
});
+ it('shows information about truncated log', () => {
+ jasmine.clock().tick(4001);
+ const [{ success }] = $.ajax.calls.argsFor(0);
+
+ success.call($, {
+ html: '<span>Update</span>',
+ status: 'success',
+ append: false,
+ truncated: true,
+ size: '50',
+ });
+
+ expect(
+ $('#build-trace .js-truncated-info').text().trim(),
+ ).toContain('Showing last 50 KiB of log');
+ expect($('#build-trace .js-truncated-info-size').text()).toMatch('50');
+ });
+
it('reloads the page when the build is done', () => {
spyOn(gl.utils, 'visitUrl');
jasmine.clock().tick(4001);
- const [{ success, context }] = $.ajax.calls.argsFor(1);
- success.call(context, {
+ const [{ success }] = $.ajax.calls.argsFor(0);
+ success.call($, {
html: '<span>Final</span>',
status: 'passed',
append: true,
+ complete: true,
});
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL);
diff --git a/spec/javascripts/comment_type_toggle_spec.js b/spec/javascripts/comment_type_toggle_spec.js
new file mode 100644
index 00000000000..dfd0810d52e
--- /dev/null
+++ b/spec/javascripts/comment_type_toggle_spec.js
@@ -0,0 +1,157 @@
+import CommentTypeToggle from '~/comment_type_toggle';
+import * as dropLabSrc from '~/droplab/drop_lab';
+import InputSetter from '~/droplab/plugins/input_setter';
+
+describe('CommentTypeToggle', function () {
+ describe('class constructor', function () {
+ beforeEach(function () {
+ this.dropdownTrigger = {};
+ this.dropdownList = {};
+ this.noteTypeInput = {};
+ this.submitButton = {};
+ this.closeButton = {};
+
+ this.commentTypeToggle = new CommentTypeToggle({
+ dropdownTrigger: this.dropdownTrigger,
+ dropdownList: this.dropdownList,
+ noteTypeInput: this.noteTypeInput,
+ submitButton: this.submitButton,
+ closeButton: this.closeButton,
+ });
+ });
+
+ it('should set .dropdownTrigger', function () {
+ expect(this.commentTypeToggle.dropdownTrigger).toBe(this.dropdownTrigger);
+ });
+
+ it('should set .dropdownList', function () {
+ expect(this.commentTypeToggle.dropdownList).toBe(this.dropdownList);
+ });
+
+ it('should set .noteTypeInput', function () {
+ expect(this.commentTypeToggle.noteTypeInput).toBe(this.noteTypeInput);
+ });
+
+ it('should set .submitButton', function () {
+ expect(this.commentTypeToggle.submitButton).toBe(this.submitButton);
+ });
+
+ it('should set .closeButton', function () {
+ expect(this.commentTypeToggle.closeButton).toBe(this.closeButton);
+ });
+
+ it('should set .reopenButton', function () {
+ expect(this.commentTypeToggle.reopenButton).toBe(this.reopenButton);
+ });
+ });
+
+ describe('initDroplab', function () {
+ beforeEach(function () {
+ this.commentTypeToggle = {
+ dropdownTrigger: {},
+ dropdownList: {},
+ noteTypeInput: {},
+ submitButton: {},
+ closeButton: {},
+ setConfig: () => {},
+ };
+ this.config = {};
+
+ this.droplab = jasmine.createSpyObj('droplab', ['init']);
+
+ spyOn(dropLabSrc, 'default').and.returnValue(this.droplab);
+ spyOn(this.commentTypeToggle, 'setConfig').and.returnValue(this.config);
+
+ CommentTypeToggle.prototype.initDroplab.call(this.commentTypeToggle);
+ });
+
+ it('should instantiate a DropLab instance', function () {
+ expect(dropLabSrc.default).toHaveBeenCalled();
+ });
+
+ it('should set .droplab', function () {
+ expect(this.commentTypeToggle.droplab).toBe(this.droplab);
+ });
+
+ it('should call .setConfig', function () {
+ expect(this.commentTypeToggle.setConfig).toHaveBeenCalled();
+ });
+
+ it('should call DropLab.prototype.init', function () {
+ expect(this.droplab.init).toHaveBeenCalledWith(
+ this.commentTypeToggle.dropdownTrigger,
+ this.commentTypeToggle.dropdownList,
+ [InputSetter],
+ this.config,
+ );
+ });
+ });
+
+ describe('setConfig', function () {
+ describe('if no .closeButton is provided', function () {
+ beforeEach(function () {
+ this.commentTypeToggle = {
+ dropdownTrigger: {},
+ dropdownList: {},
+ noteTypeInput: {},
+ submitButton: {},
+ reopenButton: {},
+ };
+
+ this.setConfig = CommentTypeToggle.prototype.setConfig.call(this.commentTypeToggle);
+ });
+
+ it('should not add .closeButton related InputSetter config', function () {
+ expect(this.setConfig).toEqual({
+ InputSetter: [{
+ input: this.commentTypeToggle.noteTypeInput,
+ valueAttribute: 'data-value',
+ }, {
+ input: this.commentTypeToggle.submitButton,
+ valueAttribute: 'data-submit-text',
+ }, {
+ input: this.commentTypeToggle.reopenButton,
+ valueAttribute: 'data-reopen-text',
+ }, {
+ input: this.commentTypeToggle.reopenButton,
+ valueAttribute: 'data-reopen-text',
+ inputAttribute: 'data-alternative-text',
+ }],
+ });
+ });
+ });
+
+ describe('if no .reopenButton is provided', function () {
+ beforeEach(function () {
+ this.commentTypeToggle = {
+ dropdownTrigger: {},
+ dropdownList: {},
+ noteTypeInput: {},
+ submitButton: {},
+ closeButton: {},
+ };
+
+ this.setConfig = CommentTypeToggle.prototype.setConfig.call(this.commentTypeToggle);
+ });
+
+ it('should not add .reopenButton related InputSetter config', function () {
+ expect(this.setConfig).toEqual({
+ InputSetter: [{
+ input: this.commentTypeToggle.noteTypeInput,
+ valueAttribute: 'data-value',
+ }, {
+ input: this.commentTypeToggle.submitButton,
+ valueAttribute: 'data-submit-text',
+ }, {
+ input: this.commentTypeToggle.closeButton,
+ valueAttribute: 'data-close-text',
+ }, {
+ input: this.commentTypeToggle.closeButton,
+ valueAttribute: 'data-close-text',
+ inputAttribute: 'data-alternative-text',
+ }],
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js
new file mode 100644
index 00000000000..35239e4fb8e
--- /dev/null
+++ b/spec/javascripts/droplab/constants_spec.js
@@ -0,0 +1,29 @@
+/* eslint-disable */
+
+import * as constants from '~/droplab/constants';
+
+describe('constants', function () {
+ describe('DATA_TRIGGER', function () {
+ it('should be `data-dropdown-trigger`', function() {
+ expect(constants.DATA_TRIGGER).toBe('data-dropdown-trigger');
+ });
+ });
+
+ describe('DATA_DROPDOWN', function () {
+ it('should be `data-dropdown`', function() {
+ expect(constants.DATA_DROPDOWN).toBe('data-dropdown');
+ });
+ });
+
+ describe('SELECTED_CLASS', function () {
+ it('should be `droplab-item-selected`', function() {
+ expect(constants.SELECTED_CLASS).toBe('droplab-item-selected');
+ });
+ });
+
+ describe('ACTIVE_CLASS', function () {
+ it('should be `droplab-item-active`', function() {
+ expect(constants.ACTIVE_CLASS).toBe('droplab-item-active');
+ });
+ });
+});
diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js
new file mode 100644
index 00000000000..802e2435672
--- /dev/null
+++ b/spec/javascripts/droplab/drop_down_spec.js
@@ -0,0 +1,593 @@
+/* eslint-disable */
+
+import DropDown from '~/droplab/drop_down';
+import utils from '~/droplab/utils';
+import { SELECTED_CLASS } from '~/droplab/constants';
+
+describe('DropDown', function () {
+ describe('class constructor', function () {
+ beforeEach(function () {
+ spyOn(DropDown.prototype, 'getItems');
+ spyOn(DropDown.prototype, 'initTemplateString');
+ spyOn(DropDown.prototype, 'addEvents');
+
+ this.list = { innerHTML: 'innerHTML' };
+ this.dropdown = new DropDown(this.list);
+ });
+
+ it('sets the .hidden property to true', function () {
+ expect(this.dropdown.hidden).toBe(true);
+ })
+
+ it('sets the .list property', function () {
+ expect(this.dropdown.list).toBe(this.list);
+ });
+
+ it('calls .getItems', function () {
+ expect(DropDown.prototype.getItems).toHaveBeenCalled();
+ });
+
+ it('calls .initTemplateString', function () {
+ expect(DropDown.prototype.initTemplateString).toHaveBeenCalled();
+ });
+
+ it('calls .addEvents', function () {
+ expect(DropDown.prototype.addEvents).toHaveBeenCalled();
+ });
+
+ it('sets the .initialState property to the .list.innerHTML', function () {
+ expect(this.dropdown.initialState).toBe(this.list.innerHTML);
+ });
+
+ describe('if the list argument is a string', function () {
+ beforeEach(function () {
+ this.element = {};
+ this.selector = '.selector';
+
+ spyOn(Document.prototype, 'querySelector').and.returnValue(this.element);
+
+ this.dropdown = new DropDown(this.selector);
+ });
+
+ it('calls .querySelector with the selector string', function () {
+ expect(Document.prototype.querySelector).toHaveBeenCalledWith(this.selector);
+ });
+
+ it('sets the .list property element', function () {
+ expect(this.dropdown.list).toBe(this.element);
+ });
+ });
+ });
+
+ describe('getItems', function () {
+ beforeEach(function () {
+ this.list = { querySelectorAll: () => {} };
+ this.dropdown = { list: this.list };
+ this.nodeList = [];
+
+ spyOn(this.list, 'querySelectorAll').and.returnValue(this.nodeList);
+
+ this.getItems = DropDown.prototype.getItems.call(this.dropdown);
+ });
+
+ it('calls .querySelectorAll with a list item query', function () {
+ expect(this.list.querySelectorAll).toHaveBeenCalledWith('li');
+ });
+
+ it('sets the .items property to the returned list items', function () {
+ expect(this.dropdown.items).toEqual(jasmine.any(Array));
+ });
+
+ it('returns the .items', function () {
+ expect(this.getItems).toEqual(jasmine.any(Array));
+ });
+ });
+
+ describe('initTemplateString', function () {
+ beforeEach(function () {
+ this.items = [{ outerHTML: '<a></a>' }, { outerHTML: '<img>' }];
+ this.dropdown = { items: this.items };
+
+ DropDown.prototype.initTemplateString.call(this.dropdown);
+ });
+
+ it('should set .templateString to the last items .outerHTML', function () {
+ expect(this.dropdown.templateString).toBe(this.items[1].outerHTML);
+ });
+
+ it('should not set .templateString to a non-last items .outerHTML', function () {
+ expect(this.dropdown.templateString).not.toBe(this.items[0].outerHTML);
+ });
+
+ describe('if .items is not set', function () {
+ beforeEach(function () {
+ this.dropdown = { getItems: () => {} };
+
+ spyOn(this.dropdown, 'getItems').and.returnValue([]);
+
+ DropDown.prototype.initTemplateString.call(this.dropdown);
+ });
+
+ it('should call .getItems', function () {
+ expect(this.dropdown.getItems).toHaveBeenCalled();
+ });
+ });
+
+ describe('if items array is empty', function () {
+ beforeEach(function () {
+ this.dropdown = { items: [] };
+
+ DropDown.prototype.initTemplateString.call(this.dropdown);
+ });
+
+ it('should set .templateString to an empty string', function () {
+ expect(this.dropdown.templateString).toBe('');
+ });
+ });
+ });
+
+ describe('clickEvent', function () {
+ beforeEach(function () {
+ this.list = { dispatchEvent: () => {} };
+ this.dropdown = { hide: () => {}, list: this.list, addSelectedClass: () => {} };
+ this.event = { preventDefault: () => {}, target: {} };
+ this.customEvent = {};
+ this.closestElement = {};
+
+ spyOn(this.dropdown, 'hide');
+ spyOn(this.dropdown, 'addSelectedClass');
+ spyOn(this.list, 'dispatchEvent');
+ spyOn(this.event, 'preventDefault');
+ spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
+ spyOn(utils, 'closest').and.returnValues(this.closestElement, undefined);
+
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+ });
+
+ it('should call utils.closest', function () {
+ expect(utils.closest).toHaveBeenCalledWith(this.event.target, 'LI');
+ });
+
+ it('should call addSelectedClass', function () {
+ expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.closestElement);
+ })
+
+ it('should call .preventDefault', function () {
+ expect(this.event.preventDefault).toHaveBeenCalled();
+ });
+
+ it('should call .hide', function () {
+ expect(this.dropdown.hide).toHaveBeenCalled();
+ });
+
+ it('should construct CustomEvent', function () {
+ expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', jasmine.any(Object));
+ });
+
+ it('should call .dispatchEvent with the customEvent', function () {
+ expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent);
+ });
+
+ describe('if the target is a UL element', function () {
+ beforeEach(function () {
+ this.event = { preventDefault: () => {}, target: { tagName: 'UL' } };
+
+ spyOn(this.event, 'preventDefault');
+ utils.closest.calls.reset();
+
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+ });
+
+ it('should return immediately', function () {
+ expect(utils.closest).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if no selected element exists', function () {
+ beforeEach(function () {
+ this.event.preventDefault.calls.reset();
+ this.clickEvent = DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+ });
+
+ it('should return undefined', function () {
+ expect(this.clickEvent).toBe(undefined);
+ });
+
+ it('should return before .preventDefault is called', function () {
+ expect(this.event.preventDefault).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('addSelectedClass', function () {
+ beforeEach(function () {
+ this.items = Array(4).forEach((item, i) => {
+ this.items[i] = { classList: { add: () => {} } };
+ spyOn(this.items[i].classList, 'add');
+ });
+ this.selected = { classList: { add: () => {} } };
+ this.dropdown = { removeSelectedClasses: () => {} };
+
+ spyOn(this.dropdown, 'removeSelectedClasses');
+ spyOn(this.selected.classList, 'add');
+
+ DropDown.prototype.addSelectedClass.call(this.dropdown, this.selected);
+ });
+
+ it('should call .removeSelectedClasses', function () {
+ expect(this.dropdown.removeSelectedClasses).toHaveBeenCalled();
+ });
+
+ it('should call .classList.add', function () {
+ expect(this.selected.classList.add).toHaveBeenCalledWith(SELECTED_CLASS);
+ });
+ });
+
+ describe('removeSelectedClasses', function () {
+ beforeEach(function () {
+ this.items = Array(4);
+ this.items.forEach((item, i) => {
+ this.items[i] = { classList: { add: () => {} } };
+ spyOn(this.items[i].classList, 'add');
+ });
+ this.dropdown = { items: this.items };
+
+ DropDown.prototype.removeSelectedClasses.call(this.dropdown);
+ });
+
+ it('should call .classList.remove for all items', function () {
+ this.items.forEach((item, i) => {
+ expect(this.items[i].classList.add).toHaveBeenCalledWith(SELECTED_CLASS);
+ });
+ });
+
+ describe('if .items is not set', function () {
+ beforeEach(function () {
+ this.dropdown = { getItems: () => {} };
+
+ spyOn(this.dropdown, 'getItems').and.returnValue([]);
+
+ DropDown.prototype.removeSelectedClasses.call(this.dropdown);
+ });
+
+ it('should call .getItems', function () {
+ expect(this.dropdown.getItems).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('addEvents', function () {
+ beforeEach(function () {
+ this.list = { addEventListener: () => {} };
+ this.dropdown = { list: this.list, clickEvent: () => {}, eventWrapper: {} };
+
+ spyOn(this.list, 'addEventListener');
+
+ DropDown.prototype.addEvents.call(this.dropdown);
+ });
+
+ it('should call .addEventListener', function () {
+ expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function));
+ });
+ });
+
+ describe('toggle', function () {
+ beforeEach(function () {
+ this.dropdown = { hidden: true, show: () => {}, hide: () => {} };
+
+ spyOn(this.dropdown, 'show');
+ spyOn(this.dropdown, 'hide');
+
+ DropDown.prototype.toggle.call(this.dropdown);
+ });
+
+ it('should call .show if hidden is true', function () {
+ expect(this.dropdown.show).toHaveBeenCalled();
+ });
+
+ describe('if hidden is false', function () {
+ beforeEach(function () {
+ this.dropdown = { hidden: false, show: () => {}, hide: () => {} };
+
+ spyOn(this.dropdown, 'show');
+ spyOn(this.dropdown, 'hide');
+
+ DropDown.prototype.toggle.call(this.dropdown);
+ });
+
+ it('should call .show if hidden is true', function () {
+ expect(this.dropdown.hide).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('setData', function () {
+ beforeEach(function () {
+ this.dropdown = { render: () => {} };
+ this.data = ['data'];
+
+ spyOn(this.dropdown, 'render');
+
+ DropDown.prototype.setData.call(this.dropdown, this.data);
+ });
+
+ it('should set .data', function () {
+ expect(this.dropdown.data).toBe(this.data);
+ });
+
+ it('should call .render with the .data', function () {
+ expect(this.dropdown.render).toHaveBeenCalledWith(this.data);
+ });
+ });
+
+ describe('addData', function () {
+ beforeEach(function () {
+ this.dropdown = { render: () => {}, data: ['data1'] };
+ this.data = ['data2'];
+
+ spyOn(this.dropdown, 'render');
+ spyOn(Array.prototype, 'concat').and.callThrough();
+
+ DropDown.prototype.addData.call(this.dropdown, this.data);
+ });
+
+ it('should call .concat with data', function () {
+ expect(Array.prototype.concat).toHaveBeenCalledWith(this.data);
+ });
+
+ it('should set .data with concatination', function () {
+ expect(this.dropdown.data).toEqual(['data1', 'data2']);
+ });
+
+ it('should call .render with the .data', function () {
+ expect(this.dropdown.render).toHaveBeenCalledWith(['data1', 'data2']);
+ });
+
+ describe('if .data is undefined', function () {
+ beforeEach(function () {
+ this.dropdown = { render: () => {}, data: undefined };
+ this.data = ['data2'];
+
+ spyOn(this.dropdown, 'render');
+
+ DropDown.prototype.addData.call(this.dropdown, this.data);
+ });
+
+ it('should set .data with concatination', function () {
+ expect(this.dropdown.data).toEqual(['data2']);
+ });
+ });
+ });
+
+ describe('render', function () {
+ beforeEach(function () {
+ this.list = { querySelector: () => {} };
+ this.dropdown = { renderChildren: () => {}, list: this.list };
+ this.renderableList = {};
+ this.data = [0, 1];
+
+ spyOn(this.dropdown, 'renderChildren').and.callFake(data => data);
+ spyOn(this.list, 'querySelector').and.returnValue(this.renderableList);
+ spyOn(this.data, 'map').and.callThrough();
+
+ DropDown.prototype.render.call(this.dropdown, this.data);
+ });
+
+ it('should call .map', function () {
+ expect(this.data.map).toHaveBeenCalledWith(jasmine.any(Function));
+ });
+
+ it('should call .renderChildren for each data item', function() {
+ expect(this.dropdown.renderChildren.calls.count()).toBe(this.data.length);
+ });
+
+ it('sets the renderableList .innerHTML', function () {
+ expect(this.renderableList.innerHTML).toBe('01');
+ });
+
+ describe('if no data argument is passed' , function () {
+ beforeEach(function () {
+ this.data.map.calls.reset();
+ this.dropdown.renderChildren.calls.reset();
+
+ DropDown.prototype.render.call(this.dropdown, undefined);
+ });
+
+ it('should not call .map', function () {
+ expect(this.data.map).not.toHaveBeenCalled();
+ });
+
+ it('should not call .renderChildren', function () {
+ expect(this.dropdown.renderChildren).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if no dynamic list is present', function () {
+ beforeEach(function () {
+ this.list = { querySelector: () => {} };
+ this.dropdown = { renderChildren: () => {}, list: this.list };
+ this.data = [0, 1];
+
+ spyOn(this.dropdown, 'renderChildren').and.callFake(data => data);
+ spyOn(this.list, 'querySelector');
+ spyOn(this.data, 'map').and.callThrough();
+
+ DropDown.prototype.render.call(this.dropdown, this.data);
+ });
+
+ it('sets the .list .innerHTML', function () {
+ expect(this.list.innerHTML).toBe('01');
+ });
+ });
+ });
+
+ describe('renderChildren', function () {
+ beforeEach(function () {
+ this.templateString = 'templateString';
+ this.dropdown = { setImagesSrc: () => {}, templateString: this.templateString };
+ this.data = { droplab_hidden: true };
+ this.html = 'html';
+ this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } };
+
+ spyOn(utils, 't').and.returnValue(this.html);
+ spyOn(document, 'createElement').and.returnValue(this.template);
+ spyOn(this.dropdown, 'setImagesSrc');
+
+ this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data);
+ });
+
+ it('should call utils.t with .templateString and data', function () {
+ expect(utils.t).toHaveBeenCalledWith(this.templateString, this.data);
+ });
+
+ it('should call document.createElement', function () {
+ expect(document.createElement).toHaveBeenCalledWith('div');
+ });
+
+ it('should set the templates .innerHTML to the HTML', function () {
+ expect(this.template.innerHTML).toBe(this.html);
+ });
+
+ it('should call .setImagesSrc with the template', function () {
+ expect(this.dropdown.setImagesSrc).toHaveBeenCalledWith(this.template);
+ });
+
+ it('should set the template display to none', function () {
+ expect(this.template.firstChild.style.display).toBe('none');
+ });
+
+ it('should return the templates .firstChild.outerHTML', function () {
+ expect(this.renderChildren).toBe(this.template.firstChild.outerHTML);
+ });
+
+ describe('if droplab_hidden is false', function () {
+ beforeEach(function () {
+ this.data = { droplab_hidden: false };
+ this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data);
+ });
+
+ it('should set the template display to block', function () {
+ expect(this.template.firstChild.style.display).toBe('block');
+ });
+ });
+ });
+
+ describe('setImagesSrc', function () {
+ beforeEach(function () {
+ this.dropdown = {};
+ this.template = { querySelectorAll: () => {} };
+
+ spyOn(this.template, 'querySelectorAll').and.returnValue([]);
+
+ DropDown.prototype.setImagesSrc.call(this.dropdown, this.template);
+ });
+
+ it('should call .querySelectorAll', function () {
+ expect(this.template.querySelectorAll).toHaveBeenCalledWith('img[data-src]');
+ });
+ });
+
+ describe('show', function () {
+ beforeEach(function () {
+ this.list = { style: {} };
+ this.dropdown = { list: this.list, hidden: true };
+
+ DropDown.prototype.show.call(this.dropdown);
+ });
+
+ it('it should set .list display to block', function () {
+ expect(this.list.style.display).toBe('block');
+ });
+
+ it('it should set .hidden to false', function () {
+ expect(this.dropdown.hidden).toBe(false);
+ });
+
+ describe('if .hidden is false', function () {
+ beforeEach(function () {
+ this.list = { style: {} };
+ this.dropdown = { list: this.list, hidden: false };
+
+ this.show = DropDown.prototype.show.call(this.dropdown);
+ });
+
+ it('should return undefined', function () {
+ expect(this.show).toEqual(undefined);
+ });
+
+ it('should not set .list display to block', function () {
+ expect(this.list.style.display).not.toEqual('block');
+ });
+ });
+ });
+
+ describe('hide', function () {
+ beforeEach(function () {
+ this.list = { style: {} };
+ this.dropdown = { list: this.list };
+
+ DropDown.prototype.hide.call(this.dropdown);
+ });
+
+ it('it should set .list display to none', function () {
+ expect(this.list.style.display).toBe('none');
+ });
+
+ it('it should set .hidden to true', function () {
+ expect(this.dropdown.hidden).toBe(true);
+ });
+ });
+
+ describe('toggle', function () {
+ beforeEach(function () {
+ this.hidden = true
+ this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} };
+
+ spyOn(this.dropdown, 'show');
+ spyOn(this.dropdown, 'hide');
+
+ DropDown.prototype.toggle.call(this.dropdown);
+ });
+
+ it('should call .show', function () {
+ expect(this.dropdown.show).toHaveBeenCalled();
+ });
+
+ describe('if .hidden is false', function () {
+ beforeEach(function () {
+ this.hidden = false
+ this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} };
+
+ spyOn(this.dropdown, 'show');
+ spyOn(this.dropdown, 'hide');
+
+ DropDown.prototype.toggle.call(this.dropdown);
+ });
+
+ it('should call .hide', function () {
+ expect(this.dropdown.hide).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('destroy', function () {
+ beforeEach(function () {
+ this.list = { removeEventListener: () => {} };
+ this.eventWrapper = { clickEvent: 'clickEvent' };
+ this.dropdown = { list: this.list, hide: () => {}, eventWrapper: this.eventWrapper };
+
+ spyOn(this.list, 'removeEventListener');
+ spyOn(this.dropdown, 'hide');
+
+ DropDown.prototype.destroy.call(this.dropdown);
+ });
+
+ it('it should call .hide', function () {
+ expect(this.dropdown.hide).toHaveBeenCalled();
+ });
+
+ it('it should call .removeEventListener', function () {
+ expect(this.list.removeEventListener).toHaveBeenCalledWith('click', this.eventWrapper.clickEvent);
+ });
+ });
+});
diff --git a/spec/javascripts/droplab/hook_spec.js b/spec/javascripts/droplab/hook_spec.js
new file mode 100644
index 00000000000..8ebdcdd1404
--- /dev/null
+++ b/spec/javascripts/droplab/hook_spec.js
@@ -0,0 +1,82 @@
+/* eslint-disable */
+
+import Hook from '~/droplab/hook';
+import * as dropdownSrc from '~/droplab/drop_down';
+
+describe('Hook', function () {
+ describe('class constructor', function () {
+ beforeEach(function () {
+ this.trigger = { id: 'id' };
+ this.list = {};
+ this.plugins = {};
+ this.config = {};
+ this.dropdown = {};
+
+ spyOn(dropdownSrc, 'default').and.returnValue(this.dropdown);
+
+ this.hook = new Hook(this.trigger, this.list, this.plugins, this.config);
+ });
+
+ it('should set .trigger', function () {
+ expect(this.hook.trigger).toBe(this.trigger);
+ });
+
+ it('should set .list', function () {
+ expect(this.hook.list).toBe(this.dropdown);
+ });
+
+ it('should call DropDown constructor', function () {
+ expect(dropdownSrc.default).toHaveBeenCalledWith(this.list);
+ });
+
+ it('should set .type', function () {
+ expect(this.hook.type).toBe('Hook');
+ });
+
+ it('should set .event', function () {
+ expect(this.hook.event).toBe('click');
+ });
+
+ it('should set .plugins', function () {
+ expect(this.hook.plugins).toBe(this.plugins);
+ });
+
+ it('should set .config', function () {
+ expect(this.hook.config).toBe(this.config);
+ });
+
+ it('should set .id', function () {
+ expect(this.hook.id).toBe(this.trigger.id);
+ });
+
+ describe('if config argument is undefined', function () {
+ beforeEach(function () {
+ this.config = undefined;
+
+ this.hook = new Hook(this.trigger, this.list, this.plugins, this.config);
+ });
+
+ it('should set .config to an empty object', function () {
+ expect(this.hook.config).toEqual({});
+ });
+ });
+
+ describe('if plugins argument is undefined', function () {
+ beforeEach(function () {
+ this.plugins = undefined;
+
+ this.hook = new Hook(this.trigger, this.list, this.plugins, this.config);
+ });
+
+ it('should set .plugins to an empty array', function () {
+ expect(this.hook.plugins).toEqual([]);
+ });
+ });
+ });
+
+ describe('addEvents', function () {
+ it('should exist', function () {
+ expect(Hook.prototype.hasOwnProperty('addEvents')).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/droplab/plugins/input_setter_spec.js b/spec/javascripts/droplab/plugins/input_setter_spec.js
new file mode 100644
index 00000000000..bd625f4ae80
--- /dev/null
+++ b/spec/javascripts/droplab/plugins/input_setter_spec.js
@@ -0,0 +1,212 @@
+/* eslint-disable */
+
+import InputSetter from '~/droplab/plugins/input_setter';
+
+describe('InputSetter', function () {
+ describe('init', function () {
+ beforeEach(function () {
+ this.config = { InputSetter: {} };
+ this.hook = { config: this.config };
+ this.inputSetter = jasmine.createSpyObj('inputSetter', ['addEvents']);
+
+ InputSetter.init.call(this.inputSetter, this.hook);
+ });
+
+ it('should set .hook', function () {
+ expect(this.inputSetter.hook).toBe(this.hook);
+ });
+
+ it('should set .config', function () {
+ expect(this.inputSetter.config).toBe(this.config.InputSetter);
+ });
+
+ it('should set .eventWrapper', function () {
+ expect(this.inputSetter.eventWrapper).toEqual({});
+ });
+
+ it('should call .addEvents', function () {
+ expect(this.inputSetter.addEvents).toHaveBeenCalled();
+ });
+
+ describe('if config.InputSetter is not set', function () {
+ beforeEach(function () {
+ this.config = { InputSetter: undefined };
+ this.hook = { config: this.config };
+
+ InputSetter.init.call(this.inputSetter, this.hook);
+ });
+
+ it('should set .config to an empty object', function () {
+ expect(this.inputSetter.config).toEqual({});
+ });
+
+ it('should set hook.config to an empty object', function () {
+ expect(this.hook.config.InputSetter).toEqual({});
+ });
+ })
+ });
+
+ describe('addEvents', function () {
+ beforeEach(function () {
+ this.hook = { list: { list: jasmine.createSpyObj('list', ['addEventListener']) } };
+ this.inputSetter = { eventWrapper: {}, hook: this.hook, setInputs: () => {} };
+
+ InputSetter.addEvents.call(this.inputSetter);
+ });
+
+ it('should set .eventWrapper.setInputs', function () {
+ expect(this.inputSetter.eventWrapper.setInputs).toEqual(jasmine.any(Function));
+ });
+
+ it('should call .addEventListener', function () {
+ expect(this.hook.list.list.addEventListener)
+ .toHaveBeenCalledWith('click.dl', this.inputSetter.eventWrapper.setInputs);
+ });
+ });
+
+ describe('removeEvents', function () {
+ beforeEach(function () {
+ this.hook = { list: { list: jasmine.createSpyObj('list', ['removeEventListener']) } };
+ this.eventWrapper = jasmine.createSpyObj('eventWrapper', ['setInputs']);
+ this.inputSetter = { eventWrapper: this.eventWrapper, hook: this.hook };
+
+ InputSetter.removeEvents.call(this.inputSetter);
+ });
+
+ it('should call .removeEventListener', function () {
+ expect(this.hook.list.list.removeEventListener)
+ .toHaveBeenCalledWith('click.dl', this.eventWrapper.setInputs);
+ });
+ });
+
+ describe('setInputs', function () {
+ beforeEach(function () {
+ this.event = { detail: { selected: {} } };
+ this.config = [0, 1];
+ this.inputSetter = { config: this.config, setInput: () => {} };
+
+ spyOn(this.inputSetter, 'setInput');
+
+ InputSetter.setInputs.call(this.inputSetter, this.event);
+ });
+
+ it('should call .setInput for each config element', function () {
+ const allArgs = this.inputSetter.setInput.calls.allArgs();
+
+ expect(allArgs.length).toEqual(2);
+
+ allArgs.forEach((args, i) => {
+ expect(args[0]).toBe(this.config[i]);
+ expect(args[1]).toBe(this.event.detail.selected);
+ });
+ });
+
+ describe('if config isnt an array', function () {
+ beforeEach(function () {
+ this.inputSetter = { config: {}, setInput: () => {} };
+
+ InputSetter.setInputs.call(this.inputSetter, this.event);
+ });
+
+ it('should set .config to an array with .config as the first element', function () {
+ expect(this.inputSetter.config).toEqual([{}]);
+ });
+ });
+ });
+
+ describe('setInput', function () {
+ beforeEach(function () {
+ this.selectedItem = { getAttribute: () => {} };
+ this.input = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} };
+ this.config = { valueAttribute: {}, input: this.input };
+ this.inputSetter = { hook: { trigger: {} } };
+ this.newValue = 'newValue';
+
+ spyOn(this.selectedItem, 'getAttribute').and.returnValue(this.newValue);
+ spyOn(this.input, 'hasAttribute').and.returnValue(false);
+
+ InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
+ });
+
+ it('should call .getAttribute', function () {
+ expect(this.selectedItem.getAttribute).toHaveBeenCalledWith(this.config.valueAttribute);
+ });
+
+ it('should call .hasAttribute', function () {
+ expect(this.input.hasAttribute).toHaveBeenCalledWith(undefined);
+ });
+
+ it('should set the value of the input', function () {
+ expect(this.input.value).toBe(this.newValue);
+ });
+
+ describe('if no config.input is provided', function () {
+ beforeEach(function () {
+ this.config = { valueAttribute: {} };
+ this.trigger = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} };
+ this.inputSetter = { hook: { trigger: this.trigger } };
+
+ InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
+ });
+
+ it('should set the value of the hook.trigger', function () {
+ expect(this.trigger.value).toBe(this.newValue);
+ });
+ });
+
+ describe('if the input tag is not INPUT', function () {
+ beforeEach(function () {
+ this.input = { textContent: 'oldValue', tagName: 'SPAN', hasAttribute: () => {} };
+ this.config = { valueAttribute: {}, input: this.input };
+
+ InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
+ });
+
+ it('should set the textContent of the input', function () {
+ expect(this.input.textContent).toBe(this.newValue);
+ });
+ });
+
+ describe('if there is an inputAttribute', function () {
+ beforeEach(function () {
+ this.selectedItem = { getAttribute: () => {} };
+ this.input = { id: 'oldValue', hasAttribute: () => {}, setAttribute: () => {} };
+ this.inputSetter = { hook: { trigger: {} } };
+ this.newValue = 'newValue';
+ this.inputAttribute = 'id';
+ this.config = {
+ valueAttribute: {},
+ input: this.input,
+ inputAttribute: this.inputAttribute,
+ };
+
+ spyOn(this.selectedItem, 'getAttribute').and.returnValue(this.newValue);
+ spyOn(this.input, 'hasAttribute').and.returnValue(true);
+ spyOn(this.input, 'setAttribute');
+
+ InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
+ });
+
+ it('should call setAttribute', function () {
+ expect(this.input.setAttribute).toHaveBeenCalledWith(this.inputAttribute, this.newValue);
+ });
+
+ it('should not set the value or textContent of the input', function () {
+ expect(this.input.value).not.toBe('newValue');
+ expect(this.input.textContent).not.toBe('newValue');
+ });
+ });
+ });
+
+ describe('destroy', function () {
+ beforeEach(function () {
+ this.inputSetter = jasmine.createSpyObj('inputSetter', ['removeEvents']);
+
+ InputSetter.destroy.call(this.inputSetter);
+ });
+
+ it('should call .removeEvents', function () {
+ expect(this.inputSetter.removeEvents).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js
index c16f77c53a2..2b1fe5e3eef 100644
--- a/spec/javascripts/filtered_search/dropdown_user_spec.js
+++ b/spec/javascripts/filtered_search/dropdown_user_spec.js
@@ -33,7 +33,7 @@ require('~/filtered_search/dropdown_user');
});
});
- describe('config droplabAjaxFilter\'s endpoint', () => {
+ describe('config AjaxFilter\'s endpoint', () => {
beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
@@ -45,13 +45,13 @@ require('~/filtered_search/dropdown_user');
};
const dropdown = new gl.DropdownUser();
- expect(dropdown.config.droplabAjaxFilter.endpoint).toBe('/autocomplete/users.json');
+ expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
it('should return endpoint when relative_url_root is undefined', () => {
const dropdown = new gl.DropdownUser();
- expect(dropdown.config.droplabAjaxFilter.endpoint).toBe('/autocomplete/users.json');
+ expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
it('should return endpoint with relative url when available', () => {
@@ -60,7 +60,7 @@ require('~/filtered_search/dropdown_user');
};
const dropdown = new gl.DropdownUser();
- expect(dropdown.config.droplabAjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json');
+ expect(dropdown.config.AjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json');
});
afterEach(() => {
diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js
index 5354e536edc..e437333d522 100644
--- a/spec/javascripts/merge_request_tabs_spec.js
+++ b/spec/javascripts/merge_request_tabs_spec.js
@@ -1,6 +1,7 @@
/* eslint-disable no-var, comma-dangle, object-shorthand */
require('~/merge_request_tabs');
+require('~/commit/pipelines/pipelines_bundle.js');
require('~/breakpoints');
require('~/lib/utils/common_utils');
require('~/diff');
@@ -222,7 +223,9 @@ require('vendor/jquery.scrollTo');
describe('#tabShown', () => {
beforeEach(function () {
- spyOn($, 'ajax').and.callFake(function () {});
+ spyOn($, 'ajax').and.callFake(function (options) {
+ options.success({ html: '' });
+ });
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
});
@@ -230,9 +233,6 @@ require('vendor/jquery.scrollTo');
beforeEach(function () {
this.class.diffViewType = () => 'parallel';
gl.Diff.prototype.diffViewType = () => 'parallel';
- spyOn($, 'ajax').and.callFake(function (options) {
- options.success({ html: '' });
- });
});
it('maintains `container-limited` for pipelines tab', function (done) {
diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb
new file mode 100644
index 00000000000..603b79a323c
--- /dev/null
+++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb
@@ -0,0 +1,95 @@
+require 'spec_helper'
+
+describe Banzai::Filter::IssuableStateFilter, lib: true do
+ include ActionView::Helpers::UrlHelper
+ include FilterSpecHelper
+
+ let(:user) { create(:user) }
+
+ def create_link(data)
+ link_to('text', '', class: 'gfm has-tooltip', data: data)
+ end
+
+ it 'ignores non-GFM links' do
+ html = %(See <a href="https://google.com/">Google</a>)
+ doc = filter(html, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('Google')
+ end
+
+ it 'ignores non-issuable links' do
+ project = create(:empty_project, :public)
+ link = create_link(project: project, reference_type: 'issue')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text')
+ end
+
+ context 'for issue references' do
+ it 'ignores open issue references' do
+ issue = create(:issue)
+ link = create_link(issue: issue.id, reference_type: 'issue')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text')
+ end
+
+ it 'ignores reopened issue references' do
+ reopened_issue = create(:issue, :reopened)
+ link = create_link(issue: reopened_issue.id, reference_type: 'issue')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text')
+ end
+
+ it 'appends [closed] to closed issue references' do
+ closed_issue = create(:issue, :closed)
+ link = create_link(issue: closed_issue.id, reference_type: 'issue')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text [closed]')
+ end
+ end
+
+ context 'for merge request references' do
+ it 'ignores open merge request references' do
+ mr = create(:merge_request)
+ link = create_link(merge_request: mr.id, reference_type: 'merge_request')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text')
+ end
+
+ it 'ignores reopened merge request references' do
+ mr = create(:merge_request, :reopened)
+ link = create_link(merge_request: mr.id, reference_type: 'merge_request')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text')
+ end
+
+ it 'ignores locked merge request references' do
+ mr = create(:merge_request, :locked)
+ link = create_link(merge_request: mr.id, reference_type: 'merge_request')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text')
+ end
+
+ it 'appends [closed] to closed merge request references' do
+ mr = create(:merge_request, :closed)
+ link = create_link(merge_request: mr.id, reference_type: 'merge_request')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text [closed]')
+ end
+
+ it 'appends [merged] to merged merge request references' do
+ mr = create(:merge_request, :merged)
+ link = create_link(merge_request: mr.id, reference_type: 'merge_request')
+ doc = filter(link, current_user: user)
+
+ expect(doc.css('a').last.text).to eq('text [merged]')
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb
index 0140a91c7ba..8a6fe1ad6a3 100644
--- a/spec/lib/banzai/filter/redactor_filter_spec.rb
+++ b/spec/lib/banzai/filter/redactor_filter_spec.rb
@@ -15,6 +15,16 @@ describe Banzai::Filter::RedactorFilter, lib: true do
link_to('text', '', class: 'gfm', data: data)
end
+ it 'skips when the skip_redaction flag is set' do
+ user = create(:user)
+ project = create(:empty_project)
+
+ link = reference_link(project: project.id, reference_type: 'test')
+ doc = filter(link, current_user: user, skip_redaction: true)
+
+ expect(doc.css('a').length).to eq 1
+ end
+
context 'with data-project' do
let(:parser_class) do
Class.new(Banzai::ReferenceParser::BaseParser) do
diff --git a/spec/lib/banzai/issuable_extractor_spec.rb b/spec/lib/banzai/issuable_extractor_spec.rb
new file mode 100644
index 00000000000..e5d332efb08
--- /dev/null
+++ b/spec/lib/banzai/issuable_extractor_spec.rb
@@ -0,0 +1,52 @@
+require 'spec_helper'
+
+describe Banzai::IssuableExtractor, lib: true do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+ let(:extractor) { described_class.new(project, user) }
+ let(:issue) { create(:issue, project: project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:issue_link) do
+ html_to_node(
+ "<a href='' data-issue='#{issue.id}' data-reference-type='issue' class='gfm'>text</a>"
+ )
+ end
+ let(:merge_request_link) do
+ html_to_node(
+ "<a href='' data-merge-request='#{merge_request.id}' data-reference-type='merge_request' class='gfm'>text</a>"
+ )
+ end
+
+ def html_to_node(html)
+ Nokogiri::HTML.fragment(
+ html
+ ).children[0]
+ end
+
+ it 'returns instances of issuables for nodes with references' do
+ result = extractor.extract([issue_link, merge_request_link])
+
+ expect(result).to eq(issue_link => issue, merge_request_link => merge_request)
+ end
+
+ describe 'caching' do
+ before do
+ RequestStore.begin!
+ end
+
+ after do
+ RequestStore.end!
+ RequestStore.clear!
+ end
+
+ it 'saves records to cache' do
+ extractor.extract([issue_link, merge_request_link])
+
+ second_call_queries = ActiveRecord::QueryRecorder.new do
+ extractor.extract([issue_link, merge_request_link])
+ end.count
+
+ expect(second_call_queries).to eq 0
+ end
+ end
+end
diff --git a/spec/lib/banzai/object_renderer_spec.rb b/spec/lib/banzai/object_renderer_spec.rb
index 6bcda87c999..4817fcd031a 100644
--- a/spec/lib/banzai/object_renderer_spec.rb
+++ b/spec/lib/banzai/object_renderer_spec.rb
@@ -3,128 +3,51 @@ require 'spec_helper'
describe Banzai::ObjectRenderer do
let(:project) { create(:empty_project) }
let(:user) { project.owner }
-
- def fake_object(attrs = {})
- object = double(attrs.merge("new_record?" => true, "destroyed?" => true))
- allow(object).to receive(:markdown_cache_field_for).with(:note).and_return(:note_html)
- allow(object).to receive(:banzai_render_context).with(:note).and_return(project: nil, author: nil)
- allow(object).to receive(:update_column).with(:note_html, anything).and_return(true)
- object
- end
+ let(:renderer) { described_class.new(project, user, custom_value: 'value') }
+ let(:object) { Note.new(note: 'hello', note_html: '<p>hello</p>') }
describe '#render' do
it 'renders and redacts an Array of objects' do
- renderer = described_class.new(project, user)
- object = fake_object(note: 'hello', note_html: nil)
-
- expect(renderer).to receive(:render_objects).with([object], :note).
- and_call_original
-
- expect(renderer).to receive(:redact_documents).
- with(an_instance_of(Array)).
- and_call_original
-
- expect(object).to receive(:redacted_note_html=).with('<p dir="auto">hello</p>')
- expect(object).to receive(:user_visible_reference_count=).with(0)
-
renderer.render([object], :note)
- end
- end
-
- describe '#render_objects' do
- it 'renders an Array of objects' do
- object = fake_object(note: 'hello', note_html: nil)
-
- renderer = described_class.new(project, user)
- expect(renderer).to receive(:render_attributes).with([object], :note).
- and_call_original
-
- rendered = renderer.render_objects([object], :note)
-
- expect(rendered).to be_an_instance_of(Array)
- expect(rendered[0]).to be_an_instance_of(Nokogiri::HTML::DocumentFragment)
- end
- end
-
- describe '#redact_documents' do
- it 'redacts a set of documents and returns them as an Array of Hashes' do
- doc = Nokogiri::HTML.fragment('<p>hello</p>')
- renderer = described_class.new(project, user)
-
- expect_any_instance_of(Banzai::Redactor).to receive(:redact).
- with([doc]).
- and_call_original
-
- redacted = renderer.redact_documents([doc])
-
- expect(redacted.count).to eq(1)
- expect(redacted.first[:visible_reference_count]).to eq(0)
- expect(redacted.first[:document].to_html).to eq('<p>hello</p>')
+ expect(object.redacted_note_html).to eq '<p>hello</p>'
+ expect(object.user_visible_reference_count).to eq 0
end
- end
- describe '#context_for' do
- let(:object) { fake_object(note: 'hello') }
- let(:renderer) { described_class.new(project, user) }
+ it 'calls Banzai::Redactor to perform redaction' do
+ expect_any_instance_of(Banzai::Redactor).to receive(:redact).and_call_original
- it 'returns a Hash' do
- expect(renderer.context_for(object, :note)).to be_an_instance_of(Hash)
- end
-
- it 'includes the banzai render context for the object' do
- expect(object).to receive(:banzai_render_context).with(:note).and_return(foo: :bar)
- context = renderer.context_for(object, :note)
- expect(context).to have_key(:foo)
- expect(context[:foo]).to eq(:bar)
- end
- end
-
- describe '#render_attributes' do
- it 'renders the attribute of a list of objects' do
- objects = [fake_object(note: 'hello', note_html: nil), fake_object(note: 'bye', note_html: nil)]
- renderer = described_class.new(project, user)
-
- objects.each do |object|
- expect(Banzai).to receive(:render_field).with(object, :note).and_call_original
- end
-
- docs = renderer.render_attributes(objects, :note)
-
- expect(docs[0]).to be_an_instance_of(Nokogiri::HTML::DocumentFragment)
- expect(docs[0].to_html).to eq('<p dir="auto">hello</p>')
-
- expect(docs[1]).to be_an_instance_of(Nokogiri::HTML::DocumentFragment)
- expect(docs[1].to_html).to eq('<p dir="auto">bye</p>')
- end
-
- it 'returns when no objects to render' do
- objects = []
- renderer = described_class.new(project, user, pipeline: :note)
-
- expect(renderer.render_attributes(objects, :note)).to eq([])
+ renderer.render([object], :note)
end
- end
- describe '#base_context' do
- let(:context) do
- described_class.new(project, user, foo: :bar).base_context
- end
+ it 'retrieves field content using Banzai.render_field' do
+ expect(Banzai).to receive(:render_field).with(object, :note).and_call_original
- it 'returns a Hash' do
- expect(context).to be_an_instance_of(Hash)
- end
-
- it 'includes the custom attributes' do
- expect(context[:foo]).to eq(:bar)
+ renderer.render([object], :note)
end
- it 'includes the current user' do
- expect(context[:current_user]).to eq(user)
- end
+ it 'passes context to PostProcessPipeline' do
+ another_user = create(:user)
+ another_project = create(:empty_project)
+ object = Note.new(
+ note: 'hello',
+ note_html: 'hello',
+ author: another_user,
+ project: another_project
+ )
+
+ expect(Banzai::Pipeline::PostProcessPipeline).to receive(:to_document).with(
+ anything,
+ hash_including(
+ skip_redaction: true,
+ current_user: user,
+ project: another_project,
+ author: another_user,
+ custom_value: 'value'
+ )
+ ).and_call_original
- it 'includes the current project' do
- expect(context[:project]).to eq(project)
+ renderer.render([object], :note)
end
end
end
diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb
index aa127f0179d..a3141894c74 100644
--- a/spec/lib/banzai/reference_parser/base_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb
@@ -92,16 +92,26 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
end
describe '#grouped_objects_for_nodes' do
- it 'returns a Hash grouping objects per ID' do
- nodes = [double(:node)]
+ it 'returns a Hash grouping objects per node' do
+ link = double(:link)
+
+ expect(link).to receive(:has_attribute?).
+ with('data-user').
+ and_return(true)
+
+ expect(link).to receive(:attr).
+ with('data-user').
+ and_return(user.id.to_s)
+
+ nodes = [link]
expect(subject).to receive(:unique_attribute_values).
with(nodes, 'data-user').
- and_return([user.id])
+ and_return([user.id.to_s])
hash = subject.grouped_objects_for_nodes(nodes, User, 'data-user')
- expect(hash).to eq({ user.id => user })
+ expect(hash).to eq({ link => user })
end
it 'returns an empty Hash when the list of nodes is empty' do
diff --git a/spec/lib/banzai/reference_parser/issue_parser_spec.rb b/spec/lib/banzai/reference_parser/issue_parser_spec.rb
index 6873b7b85f9..7031c47231c 100644
--- a/spec/lib/banzai/reference_parser/issue_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/issue_parser_spec.rb
@@ -67,6 +67,16 @@ describe Banzai::ReferenceParser::IssueParser, lib: true do
expect(subject.referenced_by([])).to eq([])
end
end
+
+ context 'when issue with given ID does not exist' do
+ before do
+ link['data-issue'] = '-1'
+ end
+
+ it 'returns an empty Array' do
+ expect(subject.referenced_by([link])).to eq([])
+ end
+ end
end
end
@@ -75,7 +85,7 @@ describe Banzai::ReferenceParser::IssueParser, lib: true do
link['data-issue'] = issue.id.to_s
nodes = [link]
- expect(subject.issues_for_nodes(nodes)).to eq({ issue.id => issue })
+ expect(subject.issues_for_nodes(nodes)).to eq({ link => issue })
end
end
end
diff --git a/spec/lib/banzai/reference_parser/user_parser_spec.rb b/spec/lib/banzai/reference_parser/user_parser_spec.rb
index 31ca9d27b0b..4ec998efe53 100644
--- a/spec/lib/banzai/reference_parser/user_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/user_parser_spec.rb
@@ -180,6 +180,15 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
expect(subject.nodes_user_can_reference(user, [link])).to eq([])
end
+
+ it 'returns the nodes if the project attribute value equals the current project ID' do
+ other_user = create(:user)
+
+ link['data-project'] = project.id.to_s
+ link['data-author'] = other_user.id.to_s
+
+ expect(subject.nodes_user_can_reference(user, [link])).to eq([link])
+ end
end
context 'when the link does not have a data-author attribute' do
diff --git a/spec/models/ci/pipeline_status_spec.rb b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb
index bc5b71666c2..fced253dd01 100644
--- a/spec/models/ci/pipeline_status_spec.rb
+++ b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::PipelineStatus do
+describe Gitlab::Cache::Ci::ProjectPipelineStatus do
let(:project) { create(:project) }
let(:pipeline_status) { described_class.new(project) }
@@ -12,6 +12,20 @@ describe Ci::PipelineStatus do
end
end
+ describe '.update_for_pipeline' do
+ it 'refreshes the cache if nescessary' do
+ pipeline = build_stubbed(:ci_pipeline, sha: '123456', status: 'success')
+ fake_status = double
+ expect(described_class).to receive(:new).
+ with(pipeline.project, sha: '123456', status: 'success', ref: 'master').
+ and_return(fake_status)
+
+ expect(fake_status).to receive(:store_in_cache_if_needed)
+
+ described_class.update_for_pipeline(pipeline)
+ end
+ end
+
describe '#has_status?' do
it "is false when the status wasn't loaded yet" do
expect(pipeline_status.has_status?).to be_falsy
@@ -41,14 +55,14 @@ describe Ci::PipelineStatus do
it 'loads the status from the project commit when there is no cache' do
allow(pipeline_status).to receive(:has_cache?).and_return(false)
- expect(pipeline_status).to receive(:load_from_commit)
+ expect(pipeline_status).to receive(:load_from_project)
pipeline_status.load_status
end
it 'stores the status in the cache when it loading it from the project' do
allow(pipeline_status).to receive(:has_cache?).and_return(false)
- allow(pipeline_status).to receive(:load_from_commit)
+ allow(pipeline_status).to receive(:load_from_project)
expect(pipeline_status).to receive(:store_in_cache)
@@ -70,14 +84,15 @@ describe Ci::PipelineStatus do
end
end
- describe "#load_from_commit" do
+ describe "#load_from_project" do
let!(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) }
it 'reads the status from the pipeline for the commit' do
- pipeline_status.load_from_commit
+ pipeline_status.load_from_project
expect(pipeline_status.status).to eq('success')
expect(pipeline_status.sha).to eq(project.commit.sha)
+ expect(pipeline_status.ref).to eq(project.default_branch)
end
it "doesn't fail for an empty project" do
@@ -108,10 +123,11 @@ describe Ci::PipelineStatus do
build_status = described_class.load_for_project(project)
build_status.store_in_cache_if_needed
- sha, status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{project.id}/build_status", :sha, :status) }
+ sha, status, ref = Gitlab::Redis.with { |redis| redis.hmget("projects/#{project.id}/build_status", :sha, :status, :ref) }
expect(sha).not_to be_nil
expect(status).not_to be_nil
+ expect(ref).not_to be_nil
end
it "doesn't store the status in redis when the sha is not the head of the project" do
@@ -126,14 +142,15 @@ describe Ci::PipelineStatus do
it "deletes the cache if the repository doesn't have a head commit" do
empty_project = create(:empty_project)
- Gitlab::Redis.with { |redis| redis.mapped_hmset("projects/#{empty_project.id}/build_status", { sha: "sha", status: "pending" }) }
+ Gitlab::Redis.with { |redis| redis.mapped_hmset("projects/#{empty_project.id}/build_status", { sha: "sha", status: "pending", ref: 'master' }) }
other_status = described_class.new(empty_project, sha: "123456", status: "failed")
other_status.store_in_cache_if_needed
- sha, status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{empty_project.id}/build_status", :sha, :status) }
+ sha, status, ref = Gitlab::Redis.with { |redis| redis.hmget("projects/#{empty_project.id}/build_status", :sha, :status, :ref) }
expect(sha).to be_nil
expect(status).to be_nil
+ expect(ref).to be_nil
end
end
diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb
index e22f88b7a32..959ae02c222 100644
--- a/spec/lib/gitlab/checks/change_access_spec.rb
+++ b/spec/lib/gitlab/checks/change_access_spec.rb
@@ -5,13 +5,10 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:user_access) { Gitlab::UserAccess.new(user, project: project) }
- let(:changes) do
- {
- oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c',
- newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51',
- ref: 'refs/heads/master'
- }
- end
+ let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' }
+ let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
+ let(:ref) { 'refs/heads/master' }
+ let(:changes) { { oldrev: oldrev, newrev: newrev, ref: ref } }
let(:protocol) { 'ssh' }
subject do
@@ -23,7 +20,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
).exec
end
- before { allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) }
+ before { project.add_developer(user) }
context 'without failed checks' do
it "doesn't return any error" do
@@ -41,25 +38,67 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
end
context 'tags check' do
- let(:changes) do
- {
- oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c',
- newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51',
- ref: 'refs/tags/v1.0.0'
- }
- end
+ let(:ref) { 'refs/tags/v1.0.0' }
it 'returns an error if the user is not allowed to update tags' do
+ allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true)
expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false)
expect(subject.status).to be(false)
expect(subject.message).to eq('You are not allowed to change existing tags on this project.')
end
+
+ context 'with protected tag' do
+ let!(:protected_tag) { create(:protected_tag, project: project, name: 'v*') }
+
+ context 'as master' do
+ before { project.add_master(user) }
+
+ context 'deletion' do
+ let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' }
+ let(:newrev) { '0000000000000000000000000000000000000000' }
+
+ it 'is prevented' do
+ expect(subject.status).to be(false)
+ expect(subject.message).to include('cannot be deleted')
+ end
+ end
+
+ context 'update' do
+ let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' }
+ let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
+
+ it 'is prevented' do
+ expect(subject.status).to be(false)
+ expect(subject.message).to include('cannot be updated')
+ end
+ end
+ end
+
+ context 'creation' do
+ let(:oldrev) { '0000000000000000000000000000000000000000' }
+ let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
+ let(:ref) { 'refs/tags/v9.1.0' }
+
+ it 'prevents creation below access level' do
+ expect(subject.status).to be(false)
+ expect(subject.message).to include('allowed to create this tag as it is protected')
+ end
+
+ context 'when user has access' do
+ let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') }
+
+ it 'allows tag creation' do
+ expect(subject.status).to be(true)
+ end
+ end
+ end
+ end
end
context 'protected branches check' do
before do
- allow(project).to receive(:protected_branch?).with('master').and_return(true)
+ allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true)
end
it 'returns an error if the user is not allowed to do forced pushes to protected branches' do
@@ -86,13 +125,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
end
context 'branch deletion' do
- let(:changes) do
- {
- oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c',
- newrev: '0000000000000000000000000000000000000000',
- ref: 'refs/heads/master'
- }
- end
+ let(:newrev) { '0000000000000000000000000000000000000000' }
it 'returns an error if the user is not allowed to delete protected branches' do
expect(subject.status).to be(false)
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index b300feaabe1..3f79eaf7afb 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -143,6 +143,7 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
expect(new_note.author).to eq(sent_notification.recipient)
expect(new_note.position).to eq(note.position)
expect(new_note.note).to include("I could not disagree more.")
+ expect(new_note.in_reply_to?(note)).to be_truthy
end
it "adds all attachments" do
diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb
index 4684b1d1ac0..58f11ff8906 100644
--- a/spec/lib/gitlab/gitaly_client/commit_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_spec.rb
@@ -4,7 +4,7 @@ describe Gitlab::GitalyClient::Commit do
describe '.diff_from_parent' do
let(:diff_stub) { double('Gitaly::Diff::Stub') }
let(:project) { create(:project, :repository) }
- let(:repository_message) { Gitaly::Repository.new(path: project.repository.path) }
+ let(:repository_message) { project.repository.gitaly_repository }
let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') }
before do
diff --git a/spec/lib/gitlab/gitaly_client/notifications_spec.rb b/spec/lib/gitlab/gitaly_client/notifications_spec.rb
index 39c2048fef8..b87dacb175b 100644
--- a/spec/lib/gitlab/gitaly_client/notifications_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/notifications_spec.rb
@@ -4,7 +4,7 @@ describe Gitlab::GitalyClient::Notifications do
describe '#post_receive' do
let(:project) { create(:empty_project) }
let(:repo_path) { project.repository.path_to_repo }
- subject { described_class.new(project.repository_storage, project.full_path + '.git') }
+ subject { described_class.new(project.repository) }
it 'sends a post_receive message' do
expect_any_instance_of(Gitaly::Notifications::Stub).
diff --git a/spec/lib/gitlab/gitaly_client/ref_spec.rb b/spec/lib/gitlab/gitaly_client/ref_spec.rb
index 79c9ca993e4..5405eafd281 100644
--- a/spec/lib/gitlab/gitaly_client/ref_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/ref_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Gitlab::GitalyClient::Ref do
let(:project) { create(:empty_project) }
let(:repo_path) { project.repository.path_to_repo }
- let(:client) { Gitlab::GitalyClient::Ref.new(project.repository_storage, project.full_path + '.git') }
+ let(:client) { Gitlab::GitalyClient::Ref.new(project.repository) }
before do
allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true)
diff --git a/spec/lib/gitlab/healthchecks/db_check_spec.rb b/spec/lib/gitlab/healthchecks/db_check_spec.rb
new file mode 100644
index 00000000000..33c6c24449c
--- /dev/null
+++ b/spec/lib/gitlab/healthchecks/db_check_spec.rb
@@ -0,0 +1,6 @@
+require 'spec_helper'
+require_relative './simple_check_shared'
+
+describe Gitlab::HealthChecks::DbCheck do
+ include_examples 'simple_check', 'db_ping', 'Db', '1'
+end
diff --git a/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb b/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb
new file mode 100644
index 00000000000..4cd8cf313a5
--- /dev/null
+++ b/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb
@@ -0,0 +1,127 @@
+require 'spec_helper'
+
+describe Gitlab::HealthChecks::FsShardsCheck do
+ let(:metric_class) { Gitlab::HealthChecks::Metric }
+ let(:result_class) { Gitlab::HealthChecks::Result }
+ let(:repository_storages) { [:default] }
+ let(:tmp_dir) { Dir.mktmpdir }
+
+ let(:storages_paths) do
+ {
+ default: { path: tmp_dir }
+ }.with_indifferent_access
+ end
+
+ before do
+ allow(described_class).to receive(:repository_storages) { repository_storages }
+ allow(described_class).to receive(:storages_paths) { storages_paths }
+ end
+
+ after do
+ FileUtils.remove_entry_secure(tmp_dir) if Dir.exist?(tmp_dir)
+ end
+
+ shared_examples 'filesystem checks' do
+ describe '#readiness' do
+ subject { described_class.readiness }
+
+ context 'storage points to not existing folder' do
+ let(:storages_paths) do
+ {
+ default: { path: 'tmp/this/path/doesnt/exist' }
+ }.with_indifferent_access
+ end
+
+ it { is_expected.to include(result_class.new(false, 'cannot stat storage', shard: :default)) }
+ end
+
+ context 'storage points to directory that has both read and write rights' do
+ before do
+ FileUtils.chmod_R(0755, tmp_dir)
+ end
+
+ it { is_expected.to include(result_class.new(true, nil, shard: :default)) }
+
+ it 'cleans up files used for testing' do
+ expect(described_class).to receive(:storage_write_test).with(any_args).and_call_original
+
+ subject
+
+ expect(Dir.entries(tmp_dir).count).to eq(2)
+ end
+
+ context 'read test fails' do
+ before do
+ allow(described_class).to receive(:storage_read_test).with(any_args).and_return(false)
+ end
+
+ it { is_expected.to include(result_class.new(false, 'cannot read from storage', shard: :default)) }
+ end
+
+ context 'write test fails' do
+ before do
+ allow(described_class).to receive(:storage_write_test).with(any_args).and_return(false)
+ end
+
+ it { is_expected.to include(result_class.new(false, 'cannot write to storage', shard: :default)) }
+ end
+ end
+ end
+
+ describe '#metrics' do
+ subject { described_class.metrics }
+
+ context 'storage points to not existing folder' do
+ let(:storages_paths) do
+ {
+ default: { path: 'tmp/this/path/doesnt/exist' }
+ }.with_indifferent_access
+ end
+
+ it { is_expected.to include(metric_class.new(:filesystem_accessible, 0, shard: :default)) }
+ it { is_expected.to include(metric_class.new(:filesystem_readable, 0, shard: :default)) }
+ it { is_expected.to include(metric_class.new(:filesystem_writable, 0, shard: :default)) }
+
+ it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) }
+ it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) }
+ it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) }
+ end
+
+ context 'storage points to directory that has both read and write rights' do
+ before do
+ FileUtils.chmod_R(0755, tmp_dir)
+ end
+
+ it { is_expected.to include(metric_class.new(:filesystem_accessible, 1, shard: :default)) }
+ it { is_expected.to include(metric_class.new(:filesystem_readable, 1, shard: :default)) }
+ it { is_expected.to include(metric_class.new(:filesystem_writable, 1, shard: :default)) }
+
+ it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) }
+ it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) }
+ it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) }
+ end
+ end
+ end
+
+ context 'when popen always finds required binaries' do
+ before do
+ allow(Gitlab::Popen).to receive(:popen).and_wrap_original do |method, *args, &block|
+ begin
+ method.call(*args, &block)
+ rescue RuntimeError
+ raise 'expected not to happen'
+ end
+ end
+ end
+
+ it_behaves_like 'filesystem checks'
+ end
+
+ context 'when popen never finds required binaries' do
+ before do
+ allow(Gitlab::Popen).to receive(:popen).and_raise(Errno::ENOENT)
+ end
+
+ it_behaves_like 'filesystem checks'
+ end
+end
diff --git a/spec/lib/gitlab/healthchecks/redis_check_spec.rb b/spec/lib/gitlab/healthchecks/redis_check_spec.rb
new file mode 100644
index 00000000000..734cdcb893e
--- /dev/null
+++ b/spec/lib/gitlab/healthchecks/redis_check_spec.rb
@@ -0,0 +1,6 @@
+require 'spec_helper'
+require_relative './simple_check_shared'
+
+describe Gitlab::HealthChecks::RedisCheck do
+ include_examples 'simple_check', 'redis_ping', 'Redis', 'PONG'
+end
diff --git a/spec/lib/gitlab/healthchecks/simple_check_shared.rb b/spec/lib/gitlab/healthchecks/simple_check_shared.rb
new file mode 100644
index 00000000000..1fa6d0faef9
--- /dev/null
+++ b/spec/lib/gitlab/healthchecks/simple_check_shared.rb
@@ -0,0 +1,66 @@
+shared_context 'simple_check' do |metrics_prefix, check_name, success_result|
+ describe '#metrics' do
+ subject { described_class.metrics }
+ context 'Check is passing' do
+ before do
+ allow(described_class).to receive(:check).and_return success_result
+ end
+
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 1)) }
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) }
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) }
+ end
+
+ context 'Check is misbehaving' do
+ before do
+ allow(described_class).to receive(:check).and_return 'error!'
+ end
+
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) }
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) }
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) }
+ end
+
+ context 'Check is timeouting' do
+ before do
+ allow(described_class).to receive(:check).and_return Timeout::Error.new
+ end
+
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) }
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 1)) }
+ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) }
+ end
+ end
+
+ describe '#readiness' do
+ subject { described_class.readiness }
+ context 'Check returns ok' do
+ before do
+ allow(described_class).to receive(:check).and_return success_result
+ end
+
+ it { is_expected.to have_attributes(success: true) }
+ end
+
+ context 'Check is misbehaving' do
+ before do
+ allow(described_class).to receive(:check).and_return 'error!'
+ end
+
+ it { is_expected.to have_attributes(success: false, message: "unexpected #{check_name} check result: error!") }
+ end
+
+ context 'Check is timeouting' do
+ before do
+ allow(described_class).to receive(:check ).and_return Timeout::Error.new
+ end
+
+ it { is_expected.to have_attributes(success: false, message: "#{check_name} check timed out") }
+ end
+ end
+
+ describe '#liveness' do
+ subject { described_class.readiness }
+ it { is_expected.to eq(Gitlab::HealthChecks::Result.new(true)) }
+ end
+end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 259c97f936a..0abf89d060c 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -89,6 +89,9 @@ pipelines:
- statuses
- builds
- trigger_requests
+- auto_canceled_by
+- auto_canceled_pipelines
+- auto_canceled_jobs
- pending_builds
- retryable_builds
- cancelable_statuses
@@ -98,6 +101,7 @@ statuses:
- project
- pipeline
- user
+- auto_canceled_by
variables:
- project
triggers:
@@ -120,10 +124,15 @@ protected_branches:
- project
- merge_access_levels
- push_access_levels
+protected_tags:
+- project
+- create_access_levels
merge_access_levels:
- protected_branch
push_access_levels:
- protected_branch
+create_access_levels:
+- protected_tag
container_repositories:
- project
- name
@@ -184,6 +193,7 @@ project:
- snippets
- hooks
- protected_branches
+- protected_tags
- project_members
- users
- requesters
diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json
index d9b67426818..7a0b0b06d4b 100644
--- a/spec/lib/gitlab/import_export/project.json
+++ b/spec/lib/gitlab/import_export/project.json
@@ -7455,6 +7455,24 @@
]
}
],
+ "protected_tags": [
+ {
+ "id": 1,
+ "project_id": 9,
+ "name": "v*",
+ "created_at": "2017-04-04T13:48:13.426Z",
+ "updated_at": "2017-04-04T13:48:13.426Z",
+ "create_access_levels": [
+ {
+ "id": 1,
+ "protected_tag_id": 1,
+ "access_level": 40,
+ "created_at": "2017-04-04T13:48:13.458Z",
+ "updated_at": "2017-04-04T13:48:13.458Z"
+ }
+ ]
+ }
+ ],
"project_feature": {
"builds_access_level": 0,
"created_at": "2014-12-26T09:26:45.000Z",
diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
index af9c25acb02..0e9607c5bd3 100644
--- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
@@ -64,6 +64,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
expect(ProtectedBranch.first.push_access_levels).not_to be_empty
end
+ it 'contains the create access levels on a protected tag' do
+ expect(ProtectedTag.first.create_access_levels).not_to be_empty
+ end
+
context 'event at forth level of the tree' do
let(:event) { Event.where(title: 'test levels').first }
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index 0c43c5662e8..0372e3f7dbf 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -183,6 +183,7 @@ Ci::Pipeline:
- duration
- user_id
- lock_version
+- auto_canceled_by_id
CommitStatus:
- id
- project_id
@@ -223,6 +224,7 @@ CommitStatus:
- token
- lock_version
- coverage_regex
+- auto_canceled_by_id
Ci::Variable:
- id
- project_id
@@ -251,6 +253,8 @@ Ci::TriggerSchedule:
- cron
- cron_timezone
- next_run_at
+- ref
+- active
DeployKey:
- id
- user_id
@@ -311,6 +315,12 @@ ProtectedBranch:
- name
- created_at
- updated_at
+ProtectedTag:
+- id
+- project_id
+- name
+- created_at
+- updated_at
Project:
- description
- issues_enabled
@@ -344,6 +354,14 @@ ProtectedBranch::PushAccessLevel:
- access_level
- created_at
- updated_at
+ProtectedTag::CreateAccessLevel:
+- id
+- protected_tag_id
+- access_level
+- created_at
+- updated_at
+- user_id
+- group_id
AwardEmoji:
- id
- user_id
diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb
index 369e55f61f1..611cdbbc865 100644
--- a/spec/lib/gitlab/user_access_spec.rb
+++ b/spec/lib/gitlab/user_access_spec.rb
@@ -142,4 +142,73 @@ describe Gitlab::UserAccess, lib: true do
end
end
end
+
+ describe 'can_create_tag?' do
+ describe 'push to none protected tag' do
+ it 'returns true if user is a master' do
+ project.add_user(user, :master)
+
+ expect(access.can_create_tag?('random_tag')).to be_truthy
+ end
+
+ it 'returns true if user is a developer' do
+ project.add_user(user, :developer)
+
+ expect(access.can_create_tag?('random_tag')).to be_truthy
+ end
+
+ it 'returns false if user is a reporter' do
+ project.add_user(user, :reporter)
+
+ expect(access.can_create_tag?('random_tag')).to be_falsey
+ end
+ end
+
+ describe 'push to protected tag' do
+ let(:tag) { create(:protected_tag, project: project, name: "test") }
+ let(:not_existing_tag) { create :protected_tag, project: project }
+
+ it 'returns true if user is a master' do
+ project.add_user(user, :master)
+
+ expect(access.can_create_tag?(tag.name)).to be_truthy
+ end
+
+ it 'returns false if user is a developer' do
+ project.add_user(user, :developer)
+
+ expect(access.can_create_tag?(tag.name)).to be_falsey
+ end
+
+ it 'returns false if user is a reporter' do
+ project.add_user(user, :reporter)
+
+ expect(access.can_create_tag?(tag.name)).to be_falsey
+ end
+ end
+
+ describe 'push to protected tag if allowed for developers' do
+ before do
+ @tag = create(:protected_tag, :developers_can_create, project: project)
+ end
+
+ it 'returns true if user is a master' do
+ project.add_user(user, :master)
+
+ expect(access.can_create_tag?(@tag.name)).to be_truthy
+ end
+
+ it 'returns true if user is a developer' do
+ project.add_user(user, :developer)
+
+ expect(access.can_create_tag?(@tag.name)).to be_truthy
+ end
+
+ it 'returns false if user is a reporter' do
+ project.add_user(user, :reporter)
+
+ expect(access.can_create_tag?(@tag.name)).to be_falsey
+ end
+ end
+ end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 6a89b007f96..e6f0a3b5920 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -63,7 +63,7 @@ describe Notify do
it 'contains a link to note author' do
is_expected.to have_html_escaped_body_text(issue.author_name)
- is_expected.to have_body_text 'wrote:'
+ is_expected.to have_body_text 'created an issue:'
end
end
end
@@ -215,7 +215,7 @@ describe Notify do
it 'contains a link to note author' do
is_expected.to have_html_escaped_body_text merge_request.author_name
- is_expected.to have_body_text 'wrote:'
+ is_expected.to have_body_text 'created a merge request:'
end
end
end
@@ -554,7 +554,7 @@ describe Notify do
end
it 'does not contain note author' do
- is_expected.not_to have_body_text 'wrote:'
+ is_expected.not_to have_body_text note.author_name
end
context 'when enabled email_author_in_body' do
@@ -564,7 +564,6 @@ describe Notify do
it 'contains a link to note author' do
is_expected.to have_html_escaped_body_text note.author_name
- is_expected.to have_body_text 'wrote:'
end
end
end
@@ -637,7 +636,7 @@ describe Notify do
end
end
- context 'items that are noteable, emails for a note on a diff' do
+ context 'items that are noteable, the email for a discussion note' do
let(:project) { create(:project, :repository) }
let(:note_author) { create(:user, name: 'author_name') }
@@ -645,8 +644,118 @@ describe Notify do
allow(Note).to receive(:find).with(note.id).and_return(note)
end
- shared_examples 'a note email on a diff' do |model|
- let(:note) { create(model, project: project, author: note_author) }
+ shared_examples 'a discussion note email' do |model|
+ it_behaves_like 'it should have Gmail Actions links'
+
+ it 'is sent to the given recipient as the author' do
+ sender = subject.header[:from].addrs[0]
+
+ aggregate_failures do
+ expect(sender.display_name).to eq(note_author.name)
+ expect(sender.address).to eq(gitlab_sender)
+ expect(subject).to deliver_to(recipient.notification_email)
+ end
+ end
+
+ it 'contains the message from the note' do
+ is_expected.to have_body_text note.note
+ end
+
+ it 'contains an introduction' do
+ is_expected.to have_body_text 'started a new discussion'
+ end
+
+ context 'when a comment on an existing discussion' do
+ let!(:second_note) { create(model, author: note_author, noteable: nil, in_reply_to: note) }
+
+ it 'contains an introduction' do
+ is_expected.to have_body_text 'commented on a'
+ end
+ end
+ end
+
+ describe 'on a commit' do
+ let(:commit) { project.commit }
+ let(:note) { create(:discussion_note_on_commit, commit_id: commit.id, project: project, author: note_author) }
+
+ before(:each) { allow(note).to receive(:noteable).and_return(commit) }
+
+ subject { Notify.note_commit_email(recipient.id, note.id) }
+
+ it_behaves_like 'a discussion note email', :discussion_note_on_commit
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { commit }
+ end
+ it_behaves_like 'it should show Gmail Actions View Commit link'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
+
+ it 'has the correct subject' do
+ is_expected.to have_subject "Re: #{project.name} | #{commit.title.strip} (#{commit.short_id})"
+ end
+
+ it 'contains a link to the commit' do
+ is_expected.to have_body_text commit.short_id
+ end
+ end
+
+ describe 'on a merge request' do
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+ let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note_author) }
+ let(:note_on_merge_request_path) { namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: "note_#{note.id}") }
+ before(:each) { allow(note).to receive(:noteable).and_return(merge_request) }
+
+ subject { Notify.note_merge_request_email(recipient.id, note.id) }
+
+ it_behaves_like 'a discussion note email', :discussion_note_on_merge_request
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { merge_request }
+ end
+ it_behaves_like 'it should show Gmail Actions View Merge request link'
+ it_behaves_like 'an unsubscribeable thread'
+
+ it 'has the correct subject' do
+ is_expected.to have_referable_subject(merge_request, reply: true)
+ end
+
+ it 'contains a link to the merge request note' do
+ is_expected.to have_body_text note_on_merge_request_path
+ end
+ end
+
+ describe 'on an issue' do
+ let(:issue) { create(:issue, project: project) }
+ let(:note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: note_author) }
+ let(:note_on_issue_path) { namespace_project_issue_path(project.namespace, project, issue, anchor: "note_#{note.id}") }
+ before(:each) { allow(note).to receive(:noteable).and_return(issue) }
+
+ subject { Notify.note_issue_email(recipient.id, note.id) }
+
+ it_behaves_like 'a discussion note email', :discussion_note_on_issue
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { issue }
+ end
+ it_behaves_like 'it should show Gmail Actions View Issue link'
+ it_behaves_like 'an unsubscribeable thread'
+
+ it 'has the correct subject' do
+ is_expected.to have_referable_subject(issue, reply: true)
+ end
+
+ it 'contains a link to the issue note' do
+ is_expected.to have_body_text note_on_issue_path
+ end
+ end
+ end
+
+ context 'items that are noteable, the email for a diff discussion note' do
+ let(:note_author) { create(:user, name: 'author_name') }
+
+ before :each do
+ allow(Note).to receive(:find).with(note.id).and_return(note)
+ end
+
+ shared_examples 'an email for a note on a diff discussion' do |model|
+ let(:note) { create(model, author: note_author) }
it "includes diffs with character-level highlighting" do
is_expected.to have_body_text '<span class="p">}</span></span>'
@@ -672,18 +781,15 @@ describe Notify do
is_expected.to have_html_escaped_body_text note.note
end
- it 'does not contain note author' do
- is_expected.not_to have_body_text 'wrote:'
+ it 'contains an introduction' do
+ is_expected.to have_body_text 'started a new discussion on'
end
- context 'when enabled email_author_in_body' do
- before do
- stub_application_setting(email_author_in_body: true)
- end
+ context 'when a comment on an existing discussion' do
+ let!(:second_note) { create(model, author: note_author, noteable: nil, in_reply_to: note) }
- it 'contains a link to note author' do
- is_expected.to have_html_escaped_body_text note.author_name
- is_expected.to have_body_text 'wrote:'
+ it 'contains an introduction' do
+ is_expected.to have_body_text 'commented on a discussion on'
end
end
end
@@ -694,7 +800,7 @@ describe Notify do
subject { Notify.note_commit_email(recipient.id, note.id) }
- it_behaves_like 'a note email on a diff', :diff_note_on_commit
+ it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_commit
it_behaves_like 'it should show Gmail Actions View Commit link'
it_behaves_like 'a user cannot unsubscribe through footer link'
end
@@ -705,7 +811,7 @@ describe Notify do
subject { Notify.note_merge_request_email(recipient.id, note.id) }
- it_behaves_like 'a note email on a diff', :diff_note_on_merge_request
+ it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_merge_request
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
end
diff --git a/spec/mailers/previews/notify_preview.rb b/spec/mailers/previews/notify_preview.rb
index 0e1ccb5b847..580f0d56a92 100644
--- a/spec/mailers/previews/notify_preview.rb
+++ b/spec/mailers/previews/notify_preview.rb
@@ -1,4 +1,100 @@
class NotifyPreview < ActionMailer::Preview
+ def note_merge_request_email_for_individual_note
+ note_email(:note_merge_request_email) do
+ note = <<-MD.strip_heredoc
+ This is an individual note on a merge request :smiley:
+
+ In this notification email, we expect to see:
+
+ - The note contents (that's what you're looking at)
+ - A link to view this note on Gitlab
+ - An explanation for why the user is receiving this notification
+ MD
+
+ create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, note: note)
+ end
+ end
+
+ def note_merge_request_email_for_discussion
+ note_email(:note_merge_request_email) do
+ note = <<-MD.strip_heredoc
+ This is a new discussion on a merge request :smiley:
+
+ In this notification email, we expect to see:
+
+ - A line saying who started this discussion
+ - The note contents (that's what you're looking at)
+ - A link to view this discussion on Gitlab
+ - An explanation for why the user is receiving this notification
+ MD
+
+ create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiscussionNote', note: note)
+ end
+ end
+
+ def note_merge_request_email_for_diff_discussion
+ note_email(:note_merge_request_email) do
+ note = <<-MD.strip_heredoc
+ This is a new discussion on a merge request :smiley:
+
+ In this notification email, we expect to see:
+
+ - A line saying who started this discussion and on what file
+ - The diff
+ - The note contents (that's what you're looking at)
+ - A link to view this discussion on Gitlab
+ - An explanation for why the user is receiving this notification
+ MD
+
+ position = Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 14,
+ diff_refs: merge_request.diff_refs
+ )
+
+ create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiffNote', position: position, note: note)
+ end
+ end
+
+ private
+
+ def project
+ @project ||= Project.find_by_full_path('gitlab-org/gitlab-test')
+ end
+
+ def merge_request
+ @merge_request ||= project.merge_requests.find_by(source_branch: 'master', target_branch: 'feature')
+ end
+
+ def user
+ @user ||= User.last
+ end
+
+ def create_note(params)
+ Notes::CreateService.new(project, user, params).execute
+ end
+
+ def note_email(method)
+ cleanup do
+ note = yield
+
+ Notify.public_send(method, user.id, note)
+ end
+ end
+
+ def cleanup
+ email = nil
+
+ ActiveRecord::Base.transaction do
+ email = yield
+ raise ActiveRecord::Rollback
+ end
+
+ email
+ end
+
def pipeline_success_email
pipeline = Ci::Pipeline.last
Notify.pipeline_success_email(pipeline, pipeline.user.try(:email))
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 02c47b3efc4..d7d6a75d38d 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -12,10 +12,13 @@ describe Ci::Pipeline, models: true do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:user) }
+ it { is_expected.to belong_to(:auto_canceled_by) }
it { is_expected.to have_many(:statuses) }
it { is_expected.to have_many(:trigger_requests) }
it { is_expected.to have_many(:builds) }
+ it { is_expected.to have_many(:auto_canceled_pipelines) }
+ it { is_expected.to have_many(:auto_canceled_jobs) }
it { is_expected.to validate_presence_of :sha }
it { is_expected.to validate_presence_of :status }
@@ -134,6 +137,43 @@ describe Ci::Pipeline, models: true do
end
end
+ describe '#auto_canceled?' do
+ subject { pipeline.auto_canceled? }
+
+ context 'when it is canceled' do
+ before do
+ pipeline.cancel
+ end
+
+ context 'when there is auto_canceled_by' do
+ before do
+ pipeline.update(auto_canceled_by: create(:ci_empty_pipeline))
+ end
+
+ it 'is auto canceled' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when there is no auto_canceled_by' do
+ it 'is not auto canceled' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when it is retried and canceled manually' do
+ before do
+ pipeline.enqueue
+ pipeline.cancel
+ end
+
+ it 'is not auto canceled' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+ end
+
describe 'pipeline stages' do
before do
create(:commit_status, pipeline: pipeline,
@@ -335,7 +375,7 @@ describe Ci::Pipeline, models: true do
end
end
- describe 'pipeline ETag caching' do
+ describe 'pipeline caching' do
it 'executes ExpirePipelinesCacheService' do
expect_any_instance_of(Ci::ExpirePipelineCacheService).to receive(:execute).with(pipeline)
@@ -1039,19 +1079,6 @@ describe Ci::Pipeline, models: true do
end
end
- describe '#update_status' do
- let(:pipeline) { create(:ci_pipeline, sha: '123456') }
-
- it 'updates the cached status' do
- fake_status = double
- # after updating the status, the status is set to `skipped` for this pipeline's builds
- expect(Ci::PipelineStatus).to receive(:new).with(pipeline.project, sha: '123456', status: 'skipped').and_return(fake_status)
- expect(fake_status).to receive(:store_in_cache_if_needed)
-
- pipeline.update_status
- end
- end
-
describe 'notifications when pipeline success or failed' do
let(:project) { create(:project, :repository) }
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 7343b735a74..0ee85489574 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -16,6 +16,7 @@ describe CommitStatus, :models do
it { is_expected.to belong_to(:pipeline) }
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:auto_canceled_by) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_inclusion_of(:status).in_array(%w(pending running failed success canceled)) }
@@ -101,6 +102,32 @@ describe CommitStatus, :models do
end
end
+ describe '#auto_canceled?' do
+ subject { commit_status.auto_canceled? }
+
+ context 'when it is canceled' do
+ before do
+ commit_status.update(status: 'canceled')
+ end
+
+ context 'when there is auto_canceled_by' do
+ before do
+ commit_status.update(auto_canceled_by: create(:ci_empty_pipeline))
+ end
+
+ it 'is auto canceled' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when there is no auto_canceled_by' do
+ it 'is not auto canceled' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+ end
+
describe '#duration' do
subject { commit_status.duration }
diff --git a/spec/models/concerns/discussion_on_diff_spec.rb b/spec/models/concerns/discussion_on_diff_spec.rb
new file mode 100644
index 00000000000..0002a00770f
--- /dev/null
+++ b/spec/models/concerns/discussion_on_diff_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe DiffDiscussion, DiscussionOnDiff, model: true do
+ subject { create(:diff_note_on_merge_request).to_discussion }
+
+ describe "#truncated_diff_lines" do
+ let(:truncated_lines) { subject.truncated_diff_lines }
+
+ context "when diff is greater than allowed number of truncated diff lines " do
+ it "returns fewer lines" do
+ expect(subject.diff_lines.count).to be > described_class::NUMBER_OF_TRUNCATED_DIFF_LINES
+
+ expect(truncated_lines.count).to be <= described_class::NUMBER_OF_TRUNCATED_DIFF_LINES
+ end
+ end
+
+ context "when some diff lines are meta" do
+ it "returns no meta lines" do
+ expect(subject.diff_lines).to include(be_meta)
+ expect(truncated_lines).not_to include(be_meta)
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/has_status_spec.rb b/spec/models/concerns/has_status_spec.rb
index 82abad0e2f6..67dae7cf4c0 100644
--- a/spec/models/concerns/has_status_spec.rb
+++ b/spec/models/concerns/has_status_spec.rb
@@ -231,6 +231,18 @@ describe HasStatus do
end
end
+ describe '.created_or_pending' do
+ subject { CommitStatus.created_or_pending }
+
+ %i[created pending].each do |status|
+ it_behaves_like 'containing the job', status
+ end
+
+ %i[running failed success].each do |status|
+ it_behaves_like 'not containing the job', status
+ end
+ end
+
describe '.finished' do
subject { CommitStatus.finished }
diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb
new file mode 100644
index 00000000000..92cc8859a8c
--- /dev/null
+++ b/spec/models/concerns/noteable_spec.rb
@@ -0,0 +1,261 @@
+require 'spec_helper'
+
+describe MergeRequest, Noteable, model: true do
+ let!(:active_diff_note1) { create(:diff_note_on_merge_request) }
+ let(:project) { active_diff_note1.project }
+ subject { active_diff_note1.noteable }
+ let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: subject, in_reply_to: active_diff_note1) }
+ let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: subject, position: active_position2) }
+ let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: subject, position: outdated_position) }
+ let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: subject, in_reply_to: outdated_diff_note1) }
+ let!(:discussion_note1) { create(:discussion_note_on_merge_request, project: project, noteable: subject) }
+ let!(:discussion_note2) { create(:discussion_note_on_merge_request, in_reply_to: discussion_note1) }
+ let!(:commit_diff_note1) { create(:diff_note_on_commit, project: project) }
+ let!(:commit_diff_note2) { create(:diff_note_on_commit, project: project, in_reply_to: commit_diff_note1) }
+ let!(:commit_note1) { create(:note_on_commit, project: project) }
+ let!(:commit_note2) { create(:note_on_commit, project: project) }
+ let!(:commit_discussion_note1) { create(:discussion_note_on_commit, project: project) }
+ let!(:commit_discussion_note2) { create(:discussion_note_on_commit, in_reply_to: commit_discussion_note1) }
+ let!(:commit_discussion_note3) { create(:discussion_note_on_commit, project: project) }
+ let!(:note1) { create(:note, project: project, noteable: subject) }
+ let!(:note2) { create(:note, project: project, noteable: subject) }
+
+ let(:active_position2) do
+ Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: 16,
+ new_line: 22,
+ diff_refs: subject.diff_refs
+ )
+ end
+
+ let(:outdated_position) do
+ Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 9,
+ diff_refs: project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs
+ )
+ end
+
+ describe '#discussions' do
+ let(:discussions) { subject.discussions }
+
+ it 'includes discussions for diff notes, commit diff notes, commit notes, and regular notes' do
+ expect(discussions).to eq([
+ DiffDiscussion.new([active_diff_note1, active_diff_note2], subject),
+ DiffDiscussion.new([active_diff_note3], subject),
+ DiffDiscussion.new([outdated_diff_note1, outdated_diff_note2], subject),
+ Discussion.new([discussion_note1, discussion_note2], subject),
+ DiffDiscussion.new([commit_diff_note1, commit_diff_note2], subject),
+ OutOfContextDiscussion.new([commit_note1, commit_note2], subject),
+ Discussion.new([commit_discussion_note1, commit_discussion_note2], subject),
+ Discussion.new([commit_discussion_note3], subject),
+ IndividualNoteDiscussion.new([note1], subject),
+ IndividualNoteDiscussion.new([note2], subject)
+ ])
+ end
+ end
+
+ describe '#grouped_diff_discussions' do
+ let(:grouped_diff_discussions) { subject.grouped_diff_discussions }
+
+ it "includes active discussions" do
+ discussions = grouped_diff_discussions.values.flatten
+
+ expect(discussions.count).to eq(2)
+ expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id])
+ expect(discussions.all?(&:active?)).to be true
+
+ expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2])
+ expect(discussions.last.notes).to eq([active_diff_note3])
+ end
+
+ it "doesn't include outdated discussions" do
+ expect(grouped_diff_discussions.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id)
+ end
+
+ it "groups the discussions by line code" do
+ expect(grouped_diff_discussions[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id)
+ expect(grouped_diff_discussions[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id)
+ end
+ end
+
+ context "discussion status" do
+ let(:first_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion }
+ let(:second_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion }
+ let(:third_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion }
+
+ before do
+ allow(subject).to receive(:resolvable_discussions).and_return([first_discussion, second_discussion, third_discussion])
+ end
+
+ describe "#discussions_resolvable?" do
+ context "when all discussions are unresolvable" do
+ before do
+ allow(first_discussion).to receive(:resolvable?).and_return(false)
+ allow(second_discussion).to receive(:resolvable?).and_return(false)
+ allow(third_discussion).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.discussions_resolvable?).to be false
+ end
+ end
+
+ context "when some discussions are unresolvable and some discussions are resolvable" do
+ before do
+ allow(first_discussion).to receive(:resolvable?).and_return(true)
+ allow(second_discussion).to receive(:resolvable?).and_return(false)
+ allow(third_discussion).to receive(:resolvable?).and_return(true)
+ end
+
+ it "returns true" do
+ expect(subject.discussions_resolvable?).to be true
+ end
+ end
+
+ context "when all discussions are resolvable" do
+ before do
+ allow(first_discussion).to receive(:resolvable?).and_return(true)
+ allow(second_discussion).to receive(:resolvable?).and_return(true)
+ allow(third_discussion).to receive(:resolvable?).and_return(true)
+ end
+
+ it "returns true" do
+ expect(subject.discussions_resolvable?).to be true
+ end
+ end
+ end
+
+ describe "#discussions_resolved?" do
+ context "when discussions are not resolvable" do
+ before do
+ allow(subject).to receive(:discussions_resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.discussions_resolved?).to be false
+ end
+ end
+
+ context "when discussions are resolvable" do
+ before do
+ allow(subject).to receive(:discussions_resolvable?).and_return(true)
+
+ allow(first_discussion).to receive(:resolvable?).and_return(true)
+ allow(second_discussion).to receive(:resolvable?).and_return(false)
+ allow(third_discussion).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when all resolvable discussions are resolved" do
+ before do
+ allow(first_discussion).to receive(:resolved?).and_return(true)
+ allow(third_discussion).to receive(:resolved?).and_return(true)
+ end
+
+ it "returns true" do
+ expect(subject.discussions_resolved?).to be true
+ end
+ end
+
+ context "when some resolvable discussions are not resolved" do
+ before do
+ allow(first_discussion).to receive(:resolved?).and_return(true)
+ allow(third_discussion).to receive(:resolved?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.discussions_resolved?).to be false
+ end
+ end
+ end
+ end
+
+ describe "#discussions_to_be_resolved?" do
+ context "when discussions are not resolvable" do
+ before do
+ allow(subject).to receive(:discussions_resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.discussions_to_be_resolved?).to be false
+ end
+ end
+
+ context "when discussions are resolvable" do
+ before do
+ allow(subject).to receive(:discussions_resolvable?).and_return(true)
+
+ allow(first_discussion).to receive(:resolvable?).and_return(true)
+ allow(second_discussion).to receive(:resolvable?).and_return(false)
+ allow(third_discussion).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when all resolvable discussions are resolved" do
+ before do
+ allow(first_discussion).to receive(:resolved?).and_return(true)
+ allow(third_discussion).to receive(:resolved?).and_return(true)
+ end
+
+ it "returns false" do
+ expect(subject.discussions_to_be_resolved?).to be false
+ end
+ end
+
+ context "when some resolvable discussions are not resolved" do
+ before do
+ allow(first_discussion).to receive(:resolved?).and_return(true)
+ allow(third_discussion).to receive(:resolved?).and_return(false)
+ end
+
+ it "returns true" do
+ expect(subject.discussions_to_be_resolved?).to be true
+ end
+ end
+ end
+ end
+
+ describe "#discussions_to_be_resolved" do
+ before do
+ allow(first_discussion).to receive(:to_be_resolved?).and_return(true)
+ allow(second_discussion).to receive(:to_be_resolved?).and_return(false)
+ allow(third_discussion).to receive(:to_be_resolved?).and_return(false)
+ end
+
+ it 'includes only discussions that need to be resolved' do
+ expect(subject.discussions_to_be_resolved).to eq([first_discussion])
+ end
+ end
+
+ describe '#discussions_can_be_resolved_by?' do
+ let(:user) { build(:user) }
+
+ context 'all discussions can be resolved by the user' do
+ before do
+ allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true)
+ allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true)
+ allow(third_discussion).to receive(:can_resolve?).with(user).and_return(true)
+ end
+
+ it 'allows a user to resolve the discussions' do
+ expect(subject.discussions_can_be_resolved_by?(user)).to be(true)
+ end
+ end
+
+ context 'one discussion cannot be resolved by the user' do
+ before do
+ allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true)
+ allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true)
+ allow(third_discussion).to receive(:can_resolve?).with(user).and_return(false)
+ end
+
+ it 'allows a user to resolve the discussions' do
+ expect(subject.discussions_can_be_resolved_by?(user)).to be(false)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/resolvable_discussion_spec.rb b/spec/models/concerns/resolvable_discussion_spec.rb
new file mode 100644
index 00000000000..18327fe262d
--- /dev/null
+++ b/spec/models/concerns/resolvable_discussion_spec.rb
@@ -0,0 +1,548 @@
+require 'spec_helper'
+
+describe Discussion, ResolvableDiscussion, models: true do
+ subject { described_class.new([first_note, second_note, third_note]) }
+
+ let(:first_note) { create(:discussion_note_on_merge_request) }
+ let(:merge_request) { first_note.noteable }
+ let(:project) { first_note.project }
+ let(:second_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) }
+ let(:third_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
+
+ describe "#resolvable?" do
+ context "when potentially resolvable" do
+ before do
+ allow(subject).to receive(:potentially_resolvable?).and_return(true)
+ end
+
+ context "when all notes are unresolvable" do
+ before do
+ allow(first_note).to receive(:resolvable?).and_return(false)
+ allow(second_note).to receive(:resolvable?).and_return(false)
+ allow(third_note).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.resolvable?).to be false
+ end
+ end
+
+ context "when some notes are unresolvable and some notes are resolvable" do
+ before do
+ allow(first_note).to receive(:resolvable?).and_return(true)
+ allow(second_note).to receive(:resolvable?).and_return(false)
+ allow(third_note).to receive(:resolvable?).and_return(true)
+ end
+
+ it "returns true" do
+ expect(subject.resolvable?).to be true
+ end
+ end
+
+ context "when all notes are resolvable" do
+ before do
+ allow(first_note).to receive(:resolvable?).and_return(true)
+ allow(second_note).to receive(:resolvable?).and_return(true)
+ allow(third_note).to receive(:resolvable?).and_return(true)
+ end
+
+ it "returns true" do
+ expect(subject.resolvable?).to be true
+ end
+ end
+ end
+
+ context "when not potentially resolvable" do
+ before do
+ allow(subject).to receive(:potentially_resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.resolvable?).to be false
+ end
+ end
+ end
+
+ describe "#resolved?" do
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.resolved?).to be false
+ end
+ end
+
+ context "when resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+
+ allow(first_note).to receive(:resolvable?).and_return(true)
+ allow(second_note).to receive(:resolvable?).and_return(false)
+ allow(third_note).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when all resolvable notes are resolved" do
+ before do
+ allow(first_note).to receive(:resolved?).and_return(true)
+ allow(third_note).to receive(:resolved?).and_return(true)
+ end
+
+ it "returns true" do
+ expect(subject.resolved?).to be true
+ end
+ end
+
+ context "when some resolvable notes are not resolved" do
+ before do
+ allow(first_note).to receive(:resolved?).and_return(true)
+ allow(third_note).to receive(:resolved?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.resolved?).to be false
+ end
+ end
+ end
+ end
+
+ describe "#to_be_resolved?" do
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.to_be_resolved?).to be false
+ end
+ end
+
+ context "when resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+
+ allow(first_note).to receive(:resolvable?).and_return(true)
+ allow(second_note).to receive(:resolvable?).and_return(false)
+ allow(third_note).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when all resolvable notes are resolved" do
+ before do
+ allow(first_note).to receive(:resolved?).and_return(true)
+ allow(third_note).to receive(:resolved?).and_return(true)
+ end
+
+ it "returns false" do
+ expect(subject.to_be_resolved?).to be false
+ end
+ end
+
+ context "when some resolvable notes are not resolved" do
+ before do
+ allow(first_note).to receive(:resolved?).and_return(true)
+ allow(third_note).to receive(:resolved?).and_return(false)
+ end
+
+ it "returns true" do
+ expect(subject.to_be_resolved?).to be true
+ end
+ end
+ end
+ end
+
+ describe "#can_resolve?" do
+ let(:current_user) { create(:user) }
+
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.can_resolve?(current_user)).to be false
+ end
+ end
+
+ context "when resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when not signed in" do
+ let(:current_user) { nil }
+
+ it "returns false" do
+ expect(subject.can_resolve?(current_user)).to be false
+ end
+ end
+
+ context "when signed in" do
+ context "when the signed in user is the noteable author" do
+ before do
+ subject.noteable.author = current_user
+ end
+
+ it "returns true" do
+ expect(subject.can_resolve?(current_user)).to be true
+ end
+ end
+
+ context "when the signed in user can push to the project" do
+ before do
+ subject.project.team << [current_user, :master]
+ end
+
+ it "returns true" do
+ expect(subject.can_resolve?(current_user)).to be true
+ end
+ end
+
+ context "when the signed in user is a random user" do
+ it "returns false" do
+ expect(subject.can_resolve?(current_user)).to be false
+ end
+ end
+ end
+ end
+ end
+
+ describe "#resolve!" do
+ let(:current_user) { create(:user) }
+
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns nil" do
+ expect(subject.resolve!(current_user)).to be_nil
+ end
+
+ it "doesn't set resolved_at" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_at).to be_nil
+ end
+
+ it "doesn't set resolved_by" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_by).to be_nil
+ end
+
+ it "doesn't mark as resolved" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved?).to be false
+ end
+ end
+
+ context "when resolvable" do
+ let(:user) { create(:user) }
+ let(:second_note) { create(:diff_note_on_commit) } # unresolvable
+
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when all resolvable notes are resolved" do
+ before do
+ first_note.resolve!(user)
+ third_note.resolve!(user)
+
+ first_note.reload
+ third_note.reload
+ end
+
+ it "doesn't change resolved_at on the resolved notes" do
+ expect(first_note.resolved_at).not_to be_nil
+ expect(third_note.resolved_at).not_to be_nil
+
+ expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at }
+ expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at }
+ end
+
+ it "doesn't change resolved_by on the resolved notes" do
+ expect(first_note.resolved_by).to eq(user)
+ expect(third_note.resolved_by).to eq(user)
+
+ expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by }
+ expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by }
+ end
+
+ it "doesn't change the resolved state on the resolved notes" do
+ expect(first_note.resolved?).to be true
+ expect(third_note.resolved?).to be true
+
+ expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? }
+ expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? }
+ end
+
+ it "doesn't change resolved_at" do
+ expect(subject.resolved_at).not_to be_nil
+
+ expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at }
+ end
+
+ it "doesn't change resolved_by" do
+ expect(subject.resolved_by).to eq(user)
+
+ expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by }
+ end
+
+ it "doesn't change resolved state" do
+ expect(subject.resolved?).to be true
+
+ expect { subject.resolve!(current_user) }.not_to change { subject.resolved? }
+ end
+ end
+
+ context "when some resolvable notes are resolved" do
+ before do
+ first_note.resolve!(user)
+ end
+
+ it "doesn't change resolved_at on the resolved note" do
+ expect(first_note.resolved_at).not_to be_nil
+
+ expect { subject.resolve!(current_user) }.
+ not_to change { first_note.reload.resolved_at }
+ end
+
+ it "doesn't change resolved_by on the resolved note" do
+ expect(first_note.resolved_by).to eq(user)
+
+ expect { subject.resolve!(current_user) }.
+ not_to change { first_note.reload && first_note.resolved_by }
+ end
+
+ it "doesn't change the resolved state on the resolved note" do
+ expect(first_note.resolved?).to be true
+
+ expect { subject.resolve!(current_user) }.
+ not_to change { first_note.reload && first_note.resolved? }
+ end
+
+ it "sets resolved_at on the unresolved note" do
+ subject.resolve!(current_user)
+ third_note.reload
+
+ expect(third_note.resolved_at).not_to be_nil
+ end
+
+ it "sets resolved_by on the unresolved note" do
+ subject.resolve!(current_user)
+ third_note.reload
+
+ expect(third_note.resolved_by).to eq(current_user)
+ end
+
+ it "marks the unresolved note as resolved" do
+ subject.resolve!(current_user)
+ third_note.reload
+
+ expect(third_note.resolved?).to be true
+ end
+
+ it "sets resolved_at" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_at).not_to be_nil
+ end
+
+ it "sets resolved_by" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_by).to eq(current_user)
+ end
+
+ it "marks as resolved" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved?).to be true
+ end
+ end
+
+ context "when no resolvable notes are resolved" do
+ it "sets resolved_at on the unresolved notes" do
+ subject.resolve!(current_user)
+ first_note.reload
+ third_note.reload
+
+ expect(first_note.resolved_at).not_to be_nil
+ expect(third_note.resolved_at).not_to be_nil
+ end
+
+ it "sets resolved_by on the unresolved notes" do
+ subject.resolve!(current_user)
+ first_note.reload
+ third_note.reload
+
+ expect(first_note.resolved_by).to eq(current_user)
+ expect(third_note.resolved_by).to eq(current_user)
+ end
+
+ it "marks the unresolved notes as resolved" do
+ subject.resolve!(current_user)
+ first_note.reload
+ third_note.reload
+
+ expect(first_note.resolved?).to be true
+ expect(third_note.resolved?).to be true
+ end
+
+ it "sets resolved_at" do
+ subject.resolve!(current_user)
+ first_note.reload
+ third_note.reload
+
+ expect(subject.resolved_at).not_to be_nil
+ end
+
+ it "sets resolved_by" do
+ subject.resolve!(current_user)
+ first_note.reload
+ third_note.reload
+
+ expect(subject.resolved_by).to eq(current_user)
+ end
+
+ it "marks as resolved" do
+ subject.resolve!(current_user)
+ first_note.reload
+ third_note.reload
+
+ expect(subject.resolved?).to be true
+ end
+ end
+ end
+ end
+
+ describe "#unresolve!" do
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns nil" do
+ expect(subject.unresolve!).to be_nil
+ end
+ end
+
+ context "when resolvable" do
+ let(:user) { create(:user) }
+
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+
+ allow(first_note).to receive(:resolvable?).and_return(true)
+ allow(second_note).to receive(:resolvable?).and_return(false)
+ allow(third_note).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when all resolvable notes are resolved" do
+ before do
+ first_note.resolve!(user)
+ third_note.resolve!(user)
+ end
+
+ it "unsets resolved_at on the resolved notes" do
+ subject.unresolve!
+ first_note.reload
+ third_note.reload
+
+ expect(first_note.resolved_at).to be_nil
+ expect(third_note.resolved_at).to be_nil
+ end
+
+ it "unsets resolved_by on the resolved notes" do
+ subject.unresolve!
+ first_note.reload
+ third_note.reload
+
+ expect(first_note.resolved_by).to be_nil
+ expect(third_note.resolved_by).to be_nil
+ end
+
+ it "unmarks the resolved notes as resolved" do
+ subject.unresolve!
+ first_note.reload
+ third_note.reload
+
+ expect(first_note.resolved?).to be false
+ expect(third_note.resolved?).to be false
+ end
+
+ it "unsets resolved_at" do
+ subject.unresolve!
+ first_note.reload
+ third_note.reload
+
+ expect(subject.resolved_at).to be_nil
+ end
+
+ it "unsets resolved_by" do
+ subject.unresolve!
+ first_note.reload
+ third_note.reload
+
+ expect(subject.resolved_by).to be_nil
+ end
+
+ it "unmarks as resolved" do
+ subject.unresolve!
+
+ expect(subject.resolved?).to be false
+ end
+ end
+
+ context "when some resolvable notes are resolved" do
+ before do
+ first_note.resolve!(user)
+ end
+
+ it "unsets resolved_at on the resolved note" do
+ subject.unresolve!
+
+ expect(subject.first_note.resolved_at).to be_nil
+ end
+
+ it "unsets resolved_by on the resolved note" do
+ subject.unresolve!
+
+ expect(subject.first_note.resolved_by).to be_nil
+ end
+
+ it "unmarks the resolved note as resolved" do
+ subject.unresolve!
+
+ expect(subject.first_note.resolved?).to be false
+ end
+ end
+ end
+ end
+
+ describe "#first_note_to_resolve" do
+ it "returns the first note that still needs to be resolved" do
+ allow(first_note).to receive(:to_be_resolved?).and_return(false)
+ allow(second_note).to receive(:to_be_resolved?).and_return(true)
+
+ expect(subject.first_note_to_resolve).to eq(second_note)
+ end
+ end
+
+ describe "#last_resolved_note" do
+ let(:current_user) { create(:user) }
+
+ before do
+ first_note.resolve!(current_user)
+ third_note.resolve!(current_user)
+ second_note.resolve!(current_user)
+ end
+
+ it "returns the last note that was resolved" do
+ expect(subject.last_resolved_note).to eq(second_note)
+ end
+ end
+end
diff --git a/spec/models/concerns/resolvable_note_spec.rb b/spec/models/concerns/resolvable_note_spec.rb
new file mode 100644
index 00000000000..1503ccdff11
--- /dev/null
+++ b/spec/models/concerns/resolvable_note_spec.rb
@@ -0,0 +1,329 @@
+require 'spec_helper'
+
+describe Note, ResolvableNote, models: true do
+ let(:project) { create(:project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ subject { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
+
+ context 'resolvability scopes' do
+ let!(:note1) { create(:note, project: project) }
+ let!(:note2) { create(:diff_note_on_commit, project: project) }
+ let!(:note3) { create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: project) }
+ let!(:note4) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
+ let!(:note5) { create(:discussion_note_on_issue, project: project) }
+ let!(:note6) { create(:discussion_note_on_merge_request, :system, noteable: merge_request, project: project) }
+
+ describe '.potentially_resolvable' do
+ it 'includes diff and discussion notes on merge requests' do
+ expect(Note.potentially_resolvable).to match_array([note3, note4, note6])
+ end
+ end
+
+ describe '.resolvable' do
+ it 'includes non-system diff and discussion notes on merge requests' do
+ expect(Note.resolvable).to match_array([note3, note4])
+ end
+ end
+
+ describe '.resolved' do
+ it 'includes resolved non-system diff and discussion notes on merge requests' do
+ expect(Note.resolved).to match_array([note3])
+ end
+ end
+
+ describe '.unresolved' do
+ it 'includes non-resolved non-system diff and discussion notes on merge requests' do
+ expect(Note.unresolved).to match_array([note4])
+ end
+ end
+ end
+
+ describe ".resolve!" do
+ let(:current_user) { create(:user) }
+ let!(:commit_note) { create(:diff_note_on_commit, project: project) }
+ let!(:resolved_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) }
+ let!(:unresolved_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
+
+ before do
+ described_class.resolve!(current_user)
+
+ commit_note.reload
+ resolved_note.reload
+ unresolved_note.reload
+ end
+
+ it 'resolves only the resolvable, not yet resolved notes' do
+ expect(commit_note.resolved_at).to be_nil
+ expect(resolved_note.resolved_by).not_to eq(current_user)
+ expect(unresolved_note.resolved_at).not_to be_nil
+ expect(unresolved_note.resolved_by).to eq(current_user)
+ end
+ end
+
+ describe ".unresolve!" do
+ let!(:resolved_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) }
+
+ before do
+ described_class.unresolve!
+
+ resolved_note.reload
+ end
+
+ it 'unresolves the resolved notes' do
+ expect(resolved_note.resolved_by).to be_nil
+ expect(resolved_note.resolved_at).to be_nil
+ end
+ end
+
+ describe '#resolvable?' do
+ context "when potentially resolvable" do
+ before do
+ allow(subject).to receive(:potentially_resolvable?).and_return(true)
+ end
+
+ context "when a system note" do
+ before do
+ subject.system = true
+ end
+
+ it "returns false" do
+ expect(subject.resolvable?).to be false
+ end
+ end
+
+ context "when a regular note" do
+ it "returns true" do
+ expect(subject.resolvable?).to be true
+ end
+ end
+ end
+
+ context "when not potentially resolvable" do
+ before do
+ allow(subject).to receive(:potentially_resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.resolvable?).to be false
+ end
+ end
+ end
+
+ describe "#to_be_resolved?" do
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(subject.to_be_resolved?).to be false
+ end
+ end
+
+ context "when resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when resolved" do
+ before do
+ allow(subject).to receive(:resolved?).and_return(true)
+ end
+
+ it "returns false" do
+ expect(subject.to_be_resolved?).to be false
+ end
+ end
+
+ context "when not resolved" do
+ before do
+ allow(subject).to receive(:resolved?).and_return(false)
+ end
+
+ it "returns true" do
+ expect(subject.to_be_resolved?).to be true
+ end
+ end
+ end
+ end
+
+ describe "#resolved?" do
+ let(:current_user) { create(:user) }
+
+ context 'when not resolvable' do
+ before do
+ subject.resolve!(current_user)
+
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it 'returns false' do
+ expect(subject.resolved?).to be_falsey
+ end
+ end
+
+ context 'when resolvable' do
+ context 'when the note has been resolved' do
+ before do
+ subject.resolve!(current_user)
+ end
+
+ it 'returns true' do
+ expect(subject.resolved?).to be_truthy
+ end
+ end
+
+ context 'when the note has not been resolved' do
+ it 'returns false' do
+ expect(subject.resolved?).to be_falsey
+ end
+ end
+ end
+ end
+
+ describe "#resolve!" do
+ let(:current_user) { create(:user) }
+
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns nil" do
+ expect(subject.resolve!(current_user)).to be_nil
+ end
+
+ it "doesn't set resolved_at" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_at).to be_nil
+ end
+
+ it "doesn't set resolved_by" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_by).to be_nil
+ end
+
+ it "doesn't mark as resolved" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved?).to be false
+ end
+ end
+
+ context "when resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when already resolved" do
+ let(:user) { create(:user) }
+
+ before do
+ subject.resolve!(user)
+ end
+
+ it "returns nil" do
+ expect(subject.resolve!(current_user)).to be_nil
+ end
+
+ it "doesn't change resolved_at" do
+ expect(subject.resolved_at).not_to be_nil
+
+ expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at }
+ end
+
+ it "doesn't change resolved_by" do
+ expect(subject.resolved_by).to eq(user)
+
+ expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by }
+ end
+
+ it "doesn't change resolved status" do
+ expect(subject.resolved?).to be true
+
+ expect { subject.resolve!(current_user) }.not_to change { subject.resolved? }
+ end
+ end
+
+ context "when not yet resolved" do
+ it "returns true" do
+ expect(subject.resolve!(current_user)).to be true
+ end
+
+ it "sets resolved_at" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_at).not_to be_nil
+ end
+
+ it "sets resolved_by" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved_by).to eq(current_user)
+ end
+
+ it "marks as resolved" do
+ subject.resolve!(current_user)
+
+ expect(subject.resolved?).to be true
+ end
+ end
+ end
+ end
+
+ describe "#unresolve!" do
+ context "when not resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(false)
+ end
+
+ it "returns nil" do
+ expect(subject.unresolve!).to be_nil
+ end
+ end
+
+ context "when resolvable" do
+ before do
+ allow(subject).to receive(:resolvable?).and_return(true)
+ end
+
+ context "when resolved" do
+ let(:user) { create(:user) }
+
+ before do
+ subject.resolve!(user)
+ end
+
+ it "returns true" do
+ expect(subject.unresolve!).to be true
+ end
+
+ it "unsets resolved_at" do
+ subject.unresolve!
+
+ expect(subject.resolved_at).to be_nil
+ end
+
+ it "unsets resolved_by" do
+ subject.unresolve!
+
+ expect(subject.resolved_by).to be_nil
+ end
+
+ it "unmarks as resolved" do
+ subject.unresolve!
+
+ expect(subject.resolved?).to be false
+ end
+ end
+
+ context "when not resolved" do
+ it "returns nil" do
+ expect(subject.unresolve!).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb
new file mode 100644
index 00000000000..48e7c0a822c
--- /dev/null
+++ b/spec/models/diff_discussion_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe DiffDiscussion, model: true do
+ subject { described_class.new([first_note, second_note, third_note]) }
+
+ let(:first_note) { create(:diff_note_on_merge_request) }
+ let(:merge_request) { first_note.noteable }
+ let(:project) { first_note.project }
+ let(:second_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) }
+ let(:third_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) }
+
+ describe '#reply_attributes' do
+ it 'includes position and original_position' do
+ attributes = subject.reply_attributes
+ expect(attributes[:position]).to eq(first_note.position.to_json)
+ expect(attributes[:original_position]).to eq(first_note.original_position.to_json)
+ end
+ end
+end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index 9ea3a4b7020..fb80b74b226 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -31,43 +31,6 @@ describe DiffNote, models: true do
subject { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) }
- describe ".resolve!" do
- let(:current_user) { create(:user) }
- let!(:commit_note) { create(:diff_note_on_commit) }
- let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) }
- let!(:unresolved_note) { create(:diff_note_on_merge_request) }
-
- before do
- described_class.resolve!(current_user)
-
- commit_note.reload
- resolved_note.reload
- unresolved_note.reload
- end
-
- it 'resolves only the resolvable, not yet resolved notes' do
- expect(commit_note.resolved_at).to be_nil
- expect(resolved_note.resolved_by).not_to eq(current_user)
- expect(unresolved_note.resolved_at).not_to be_nil
- expect(unresolved_note.resolved_by).to eq(current_user)
- end
- end
-
- describe ".unresolve!" do
- let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) }
-
- before do
- described_class.unresolve!
-
- resolved_note.reload
- end
-
- it 'unresolves the resolved notes' do
- expect(resolved_note.resolved_by).to be_nil
- expect(resolved_note.resolved_at).to be_nil
- end
- end
-
describe "#position=" do
context "when provided a string" do
it "sets the position" do
@@ -94,6 +57,32 @@ describe DiffNote, models: true do
end
end
+ describe "#original_position=" do
+ context "when provided a string" do
+ it "sets the original position" do
+ subject.original_position = new_position.to_json
+
+ expect(subject.original_position).to eq(new_position)
+ end
+ end
+
+ context "when provided a hash" do
+ it "sets the original position" do
+ subject.original_position = new_position.to_h
+
+ expect(subject.original_position).to eq(new_position)
+ end
+ end
+
+ context "when provided a position object" do
+ it "sets the original position" do
+ subject.original_position = new_position
+
+ expect(subject.original_position).to eq(new_position)
+ end
+ end
+ end
+
describe "#diff_file" do
it "returns the correct diff file" do
diff_file = subject.diff_file
@@ -226,252 +215,6 @@ describe DiffNote, models: true do
end
end
- describe "#resolvable?" do
- context "when noteable is a commit" do
- subject { create(:diff_note_on_commit, project: project, position: position) }
-
- it "returns false" do
- expect(subject.resolvable?).to be false
- end
- end
-
- context "when noteable is a merge request" do
- context "when a system note" do
- before do
- subject.system = true
- end
-
- it "returns false" do
- expect(subject.resolvable?).to be false
- end
- end
-
- context "when a regular note" do
- it "returns true" do
- expect(subject.resolvable?).to be true
- end
- end
- end
- end
-
- describe "#to_be_resolved?" do
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.to_be_resolved?).to be false
- end
- end
-
- context "when resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
- end
-
- context "when resolved" do
- before do
- allow(subject).to receive(:resolved?).and_return(true)
- end
-
- it "returns false" do
- expect(subject.to_be_resolved?).to be false
- end
- end
-
- context "when not resolved" do
- before do
- allow(subject).to receive(:resolved?).and_return(false)
- end
-
- it "returns true" do
- expect(subject.to_be_resolved?).to be true
- end
- end
- end
- end
-
- describe "#resolve!" do
- let(:current_user) { create(:user) }
-
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns nil" do
- expect(subject.resolve!(current_user)).to be_nil
- end
-
- it "doesn't set resolved_at" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_at).to be_nil
- end
-
- it "doesn't set resolved_by" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_by).to be_nil
- end
-
- it "doesn't mark as resolved" do
- subject.resolve!(current_user)
-
- expect(subject.resolved?).to be false
- end
- end
-
- context "when resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
- end
-
- context "when already resolved" do
- let(:user) { create(:user) }
-
- before do
- subject.resolve!(user)
- end
-
- it "returns nil" do
- expect(subject.resolve!(current_user)).to be_nil
- end
-
- it "doesn't change resolved_at" do
- expect(subject.resolved_at).not_to be_nil
-
- expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at }
- end
-
- it "doesn't change resolved_by" do
- expect(subject.resolved_by).to eq(user)
-
- expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by }
- end
-
- it "doesn't change resolved status" do
- expect(subject.resolved?).to be true
-
- expect { subject.resolve!(current_user) }.not_to change { subject.resolved? }
- end
- end
-
- context "when not yet resolved" do
- it "returns true" do
- expect(subject.resolve!(current_user)).to be true
- end
-
- it "sets resolved_at" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_at).not_to be_nil
- end
-
- it "sets resolved_by" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_by).to eq(current_user)
- end
-
- it "marks as resolved" do
- subject.resolve!(current_user)
-
- expect(subject.resolved?).to be true
- end
- end
- end
- end
-
- describe "#unresolve!" do
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns nil" do
- expect(subject.unresolve!).to be_nil
- end
- end
-
- context "when resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
- end
-
- context "when resolved" do
- let(:user) { create(:user) }
-
- before do
- subject.resolve!(user)
- end
-
- it "returns true" do
- expect(subject.unresolve!).to be true
- end
-
- it "unsets resolved_at" do
- subject.unresolve!
-
- expect(subject.resolved_at).to be_nil
- end
-
- it "unsets resolved_by" do
- subject.unresolve!
-
- expect(subject.resolved_by).to be_nil
- end
-
- it "unmarks as resolved" do
- subject.unresolve!
-
- expect(subject.resolved?).to be false
- end
- end
-
- context "when not resolved" do
- it "returns nil" do
- expect(subject.unresolve!).to be_nil
- end
- end
- end
- end
-
- describe "#discussion" do
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns nil" do
- expect(subject.discussion).to be_nil
- end
- end
-
- context "when resolvable" do
- let!(:diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: subject.position) }
- let!(:diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) }
-
- let(:active_position2) do
- Gitlab::Diff::Position.new(
- old_path: "files/ruby/popen.rb",
- new_path: "files/ruby/popen.rb",
- old_line: 16,
- new_line: 22,
- diff_refs: merge_request.diff_refs
- )
- end
-
- it "returns the discussion this note is in" do
- discussion = subject.discussion
-
- expect(discussion.id).to eq(subject.discussion_id)
- expect(discussion.notes).to eq([subject, diff_note2])
- end
- end
- end
-
describe "#discussion_id" do
let(:note) { create(:diff_note_on_merge_request) }
@@ -496,29 +239,4 @@ describe DiffNote, models: true do
end
end
end
-
- describe "#original_discussion_id" do
- let(:note) { create(:diff_note_on_merge_request) }
-
- context "when it is newly created" do
- it "has a discussion id" do
- expect(note.original_discussion_id).not_to be_nil
- expect(note.original_discussion_id).to match(/\A\h{40}\z/)
- end
- end
-
- context "when it didn't store a discussion id before" do
- before do
- note.update_column(:original_discussion_id, nil)
- end
-
- it "has a discussion id" do
- # The original_discussion_id is set in `after_initialize`, so `reload` won't work
- reloaded_note = Note.find(note.id)
-
- expect(reloaded_note.original_discussion_id).not_to be_nil
- expect(reloaded_note.original_discussion_id).to match(/\A\h{40}\z/)
- end
- end
- end
end
diff --git a/spec/models/discussion_spec.rb b/spec/models/discussion_spec.rb
index bc32fadd391..0221e23ced8 100644
--- a/spec/models/discussion_spec.rb
+++ b/spec/models/discussion_spec.rb
@@ -4,618 +4,27 @@ describe Discussion, model: true do
subject { described_class.new([first_note, second_note, third_note]) }
let(:first_note) { create(:diff_note_on_merge_request) }
- let(:second_note) { create(:diff_note_on_merge_request) }
+ let(:merge_request) { first_note.noteable }
+ let(:second_note) { create(:diff_note_on_merge_request, in_reply_to: first_note) }
let(:third_note) { create(:diff_note_on_merge_request) }
- describe "#resolvable?" do
- context "when a diff discussion" do
- before do
- allow(subject).to receive(:diff_discussion?).and_return(true)
- end
-
- context "when all notes are unresolvable" do
- before do
- allow(first_note).to receive(:resolvable?).and_return(false)
- allow(second_note).to receive(:resolvable?).and_return(false)
- allow(third_note).to receive(:resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.resolvable?).to be false
- end
- end
-
- context "when some notes are unresolvable and some notes are resolvable" do
- before do
- allow(first_note).to receive(:resolvable?).and_return(true)
- allow(second_note).to receive(:resolvable?).and_return(false)
- allow(third_note).to receive(:resolvable?).and_return(true)
- end
-
- it "returns true" do
- expect(subject.resolvable?).to be true
- end
- end
-
- context "when all notes are resolvable" do
- before do
- allow(first_note).to receive(:resolvable?).and_return(true)
- allow(second_note).to receive(:resolvable?).and_return(true)
- allow(third_note).to receive(:resolvable?).and_return(true)
- end
-
- it "returns true" do
- expect(subject.resolvable?).to be true
- end
- end
- end
-
- context "when not a diff discussion" do
- before do
- allow(subject).to receive(:diff_discussion?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.resolvable?).to be false
- end
- end
- end
-
- describe "#resolved?" do
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.resolved?).to be false
- end
- end
-
- context "when resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
-
- allow(first_note).to receive(:resolvable?).and_return(true)
- allow(second_note).to receive(:resolvable?).and_return(false)
- allow(third_note).to receive(:resolvable?).and_return(true)
- end
-
- context "when all resolvable notes are resolved" do
- before do
- allow(first_note).to receive(:resolved?).and_return(true)
- allow(third_note).to receive(:resolved?).and_return(true)
- end
-
- it "returns true" do
- expect(subject.resolved?).to be true
- end
- end
-
- context "when some resolvable notes are not resolved" do
- before do
- allow(first_note).to receive(:resolved?).and_return(true)
- allow(third_note).to receive(:resolved?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.resolved?).to be false
- end
- end
- end
- end
-
- describe "#to_be_resolved?" do
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.to_be_resolved?).to be false
- end
- end
-
- context "when resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
-
- allow(first_note).to receive(:resolvable?).and_return(true)
- allow(second_note).to receive(:resolvable?).and_return(false)
- allow(third_note).to receive(:resolvable?).and_return(true)
- end
-
- context "when all resolvable notes are resolved" do
- before do
- allow(first_note).to receive(:resolved?).and_return(true)
- allow(third_note).to receive(:resolved?).and_return(true)
- end
-
- it "returns false" do
- expect(subject.to_be_resolved?).to be false
- end
- end
-
- context "when some resolvable notes are not resolved" do
- before do
- allow(first_note).to receive(:resolved?).and_return(true)
- allow(third_note).to receive(:resolved?).and_return(false)
- end
-
- it "returns true" do
- expect(subject.to_be_resolved?).to be true
- end
- end
- end
- end
-
- describe "#can_resolve?" do
- let(:current_user) { create(:user) }
-
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.can_resolve?(current_user)).to be false
- end
- end
-
- context "when resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
- end
-
- context "when not signed in" do
- let(:current_user) { nil }
-
- it "returns false" do
- expect(subject.can_resolve?(current_user)).to be false
- end
- end
-
- context "when signed in" do
- context "when the signed in user is the noteable author" do
- before do
- subject.noteable.author = current_user
- end
-
- it "returns true" do
- expect(subject.can_resolve?(current_user)).to be true
- end
- end
-
- context "when the signed in user can push to the project" do
- before do
- subject.project.team << [current_user, :master]
- end
-
- it "returns true" do
- expect(subject.can_resolve?(current_user)).to be true
- end
- end
-
- context "when the signed in user is a random user" do
- it "returns false" do
- expect(subject.can_resolve?(current_user)).to be false
- end
- end
- end
- end
- end
-
- describe "#resolve!" do
- let(:current_user) { create(:user) }
-
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns nil" do
- expect(subject.resolve!(current_user)).to be_nil
- end
-
- it "doesn't set resolved_at" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_at).to be_nil
- end
-
- it "doesn't set resolved_by" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_by).to be_nil
- end
-
- it "doesn't mark as resolved" do
- subject.resolve!(current_user)
-
- expect(subject.resolved?).to be false
- end
- end
-
- context "when resolvable" do
- let(:user) { create(:user) }
- let(:second_note) { create(:diff_note_on_commit) } # unresolvable
-
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
- end
-
- context "when all resolvable notes are resolved" do
- before do
- first_note.resolve!(user)
- third_note.resolve!(user)
-
- first_note.reload
- third_note.reload
- end
-
- it "doesn't change resolved_at on the resolved notes" do
- expect(first_note.resolved_at).not_to be_nil
- expect(third_note.resolved_at).not_to be_nil
-
- expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at }
- expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at }
- end
-
- it "doesn't change resolved_by on the resolved notes" do
- expect(first_note.resolved_by).to eq(user)
- expect(third_note.resolved_by).to eq(user)
-
- expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by }
- expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by }
- end
-
- it "doesn't change the resolved state on the resolved notes" do
- expect(first_note.resolved?).to be true
- expect(third_note.resolved?).to be true
-
- expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? }
- expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? }
- end
-
- it "doesn't change resolved_at" do
- expect(subject.resolved_at).not_to be_nil
-
- expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at }
- end
-
- it "doesn't change resolved_by" do
- expect(subject.resolved_by).to eq(user)
-
- expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by }
- end
-
- it "doesn't change resolved state" do
- expect(subject.resolved?).to be true
-
- expect { subject.resolve!(current_user) }.not_to change { subject.resolved? }
- end
- end
-
- context "when some resolvable notes are resolved" do
- before do
- first_note.resolve!(user)
- end
-
- it "doesn't change resolved_at on the resolved note" do
- expect(first_note.resolved_at).not_to be_nil
-
- expect { subject.resolve!(current_user) }.
- not_to change { first_note.reload.resolved_at }
- end
-
- it "doesn't change resolved_by on the resolved note" do
- expect(first_note.resolved_by).to eq(user)
-
- expect { subject.resolve!(current_user) }.
- not_to change { first_note.reload && first_note.resolved_by }
- end
-
- it "doesn't change the resolved state on the resolved note" do
- expect(first_note.resolved?).to be true
-
- expect { subject.resolve!(current_user) }.
- not_to change { first_note.reload && first_note.resolved? }
- end
-
- it "sets resolved_at on the unresolved note" do
- subject.resolve!(current_user)
- third_note.reload
-
- expect(third_note.resolved_at).not_to be_nil
- end
-
- it "sets resolved_by on the unresolved note" do
- subject.resolve!(current_user)
- third_note.reload
-
- expect(third_note.resolved_by).to eq(current_user)
- end
-
- it "marks the unresolved note as resolved" do
- subject.resolve!(current_user)
- third_note.reload
-
- expect(third_note.resolved?).to be true
- end
-
- it "sets resolved_at" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_at).not_to be_nil
- end
-
- it "sets resolved_by" do
- subject.resolve!(current_user)
-
- expect(subject.resolved_by).to eq(current_user)
- end
-
- it "marks as resolved" do
- subject.resolve!(current_user)
-
- expect(subject.resolved?).to be true
- end
- end
-
- context "when no resolvable notes are resolved" do
- it "sets resolved_at on the unresolved notes" do
- subject.resolve!(current_user)
- first_note.reload
- third_note.reload
-
- expect(first_note.resolved_at).not_to be_nil
- expect(third_note.resolved_at).not_to be_nil
- end
-
- it "sets resolved_by on the unresolved notes" do
- subject.resolve!(current_user)
- first_note.reload
- third_note.reload
-
- expect(first_note.resolved_by).to eq(current_user)
- expect(third_note.resolved_by).to eq(current_user)
- end
-
- it "marks the unresolved notes as resolved" do
- subject.resolve!(current_user)
- first_note.reload
- third_note.reload
-
- expect(first_note.resolved?).to be true
- expect(third_note.resolved?).to be true
- end
-
- it "sets resolved_at" do
- subject.resolve!(current_user)
- first_note.reload
- third_note.reload
-
- expect(subject.resolved_at).not_to be_nil
- end
-
- it "sets resolved_by" do
- subject.resolve!(current_user)
- first_note.reload
- third_note.reload
-
- expect(subject.resolved_by).to eq(current_user)
- end
-
- it "marks as resolved" do
- subject.resolve!(current_user)
- first_note.reload
- third_note.reload
-
- expect(subject.resolved?).to be true
- end
- end
- end
- end
-
- describe "#unresolve!" do
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- it "returns nil" do
- expect(subject.unresolve!).to be_nil
- end
- end
-
- context "when resolvable" do
- let(:user) { create(:user) }
-
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
-
- allow(first_note).to receive(:resolvable?).and_return(true)
- allow(second_note).to receive(:resolvable?).and_return(false)
- allow(third_note).to receive(:resolvable?).and_return(true)
- end
-
- context "when all resolvable notes are resolved" do
- before do
- first_note.resolve!(user)
- third_note.resolve!(user)
- end
-
- it "unsets resolved_at on the resolved notes" do
- subject.unresolve!
- first_note.reload
- third_note.reload
-
- expect(first_note.resolved_at).to be_nil
- expect(third_note.resolved_at).to be_nil
- end
-
- it "unsets resolved_by on the resolved notes" do
- subject.unresolve!
- first_note.reload
- third_note.reload
-
- expect(first_note.resolved_by).to be_nil
- expect(third_note.resolved_by).to be_nil
- end
-
- it "unmarks the resolved notes as resolved" do
- subject.unresolve!
- first_note.reload
- third_note.reload
-
- expect(first_note.resolved?).to be false
- expect(third_note.resolved?).to be false
- end
-
- it "unsets resolved_at" do
- subject.unresolve!
- first_note.reload
- third_note.reload
-
- expect(subject.resolved_at).to be_nil
- end
-
- it "unsets resolved_by" do
- subject.unresolve!
- first_note.reload
- third_note.reload
-
- expect(subject.resolved_by).to be_nil
- end
-
- it "unmarks as resolved" do
- subject.unresolve!
-
- expect(subject.resolved?).to be false
- end
- end
-
- context "when some resolvable notes are resolved" do
- before do
- first_note.resolve!(user)
- end
-
- it "unsets resolved_at on the resolved note" do
- subject.unresolve!
-
- expect(subject.first_note.resolved_at).to be_nil
- end
-
- it "unsets resolved_by on the resolved note" do
- subject.unresolve!
-
- expect(subject.first_note.resolved_by).to be_nil
- end
-
- it "unmarks the resolved note as resolved" do
- subject.unresolve!
-
- expect(subject.first_note.resolved?).to be false
- end
- end
+ describe '.build' do
+ it 'returns a discussion of the right type' do
+ discussion = described_class.build([first_note, second_note], merge_request)
+ expect(discussion).to be_a(DiffDiscussion)
+ expect(discussion.notes.count).to be(2)
+ expect(discussion.first_note).to be(first_note)
+ expect(discussion.noteable).to be(merge_request)
end
end
- describe "#first_note_to_resolve" do
- it "returns the first not that still needs to be resolved" do
- allow(first_note).to receive(:to_be_resolved?).and_return(false)
- allow(second_note).to receive(:to_be_resolved?).and_return(true)
-
- expect(subject.first_note_to_resolve).to eq(second_note)
- end
- end
-
- describe "#collapsed?" do
- context "when a diff discussion" do
- before do
- allow(subject).to receive(:diff_discussion?).and_return(true)
- end
-
- context "when resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(true)
- end
-
- context "when resolved" do
- before do
- allow(subject).to receive(:resolved?).and_return(true)
- end
-
- it "returns true" do
- expect(subject.collapsed?).to be true
- end
- end
-
- context "when not resolved" do
- before do
- allow(subject).to receive(:resolved?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.collapsed?).to be false
- end
- end
- end
-
- context "when not resolvable" do
- before do
- allow(subject).to receive(:resolvable?).and_return(false)
- end
-
- context "when active" do
- before do
- allow(subject).to receive(:active?).and_return(true)
- end
-
- it "returns false" do
- expect(subject.collapsed?).to be false
- end
- end
-
- context "when outdated" do
- before do
- allow(subject).to receive(:active?).and_return(false)
- end
-
- it "returns true" do
- expect(subject.collapsed?).to be true
- end
- end
- end
- end
-
- context "when not a diff discussion" do
- before do
- allow(subject).to receive(:diff_discussion?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.collapsed?).to be false
- end
- end
- end
-
- describe "#truncated_diff_lines" do
- let(:truncated_lines) { subject.truncated_diff_lines }
-
- context "when diff is greater than allowed number of truncated diff lines " do
- it "returns fewer lines" do
- expect(subject.diff_lines.count).to be > described_class::NUMBER_OF_TRUNCATED_DIFF_LINES
-
- expect(truncated_lines.count).to be <= described_class::NUMBER_OF_TRUNCATED_DIFF_LINES
- end
- end
-
- context "when some diff lines are meta" do
- it "returns no meta lines" do
- expect(subject.diff_lines).to include(be_meta)
- expect(truncated_lines).not_to include(be_meta)
- end
+ describe '.build_collection' do
+ it 'returns an array of discussions of the right type' do
+ discussions = described_class.build_collection([first_note, second_note, third_note], merge_request)
+ expect(discussions).to eq([
+ DiffDiscussion.new([first_note, second_note], merge_request),
+ DiffDiscussion.new([third_note], merge_request)
+ ])
end
end
end
diff --git a/spec/models/legacy_diff_discussion_spec.rb b/spec/models/legacy_diff_discussion_spec.rb
new file mode 100644
index 00000000000..153e757a0ef
--- /dev/null
+++ b/spec/models/legacy_diff_discussion_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe LegacyDiffDiscussion, models: true do
+ subject { create(:legacy_diff_note_on_merge_request).to_discussion }
+
+ describe '#reply_attributes' do
+ it 'includes line_code' do
+ expect(subject.reply_attributes[:line_code]).to eq(subject.line_code)
+ end
+ end
+end
diff --git a/spec/models/legacy_diff_note_spec.rb b/spec/models/legacy_diff_note_spec.rb
deleted file mode 100644
index 81517a18b74..00000000000
--- a/spec/models/legacy_diff_note_spec.rb
+++ /dev/null
@@ -1,101 +0,0 @@
-require 'spec_helper'
-
-describe LegacyDiffNote, models: true do
- describe "Commit diff line notes" do
- let!(:note) { create(:legacy_diff_note_on_commit, note: "+1 from me") }
- let!(:commit) { note.noteable }
-
- it "saves a valid note" do
- expect(note.commit_id).to eq(commit.id)
- expect(note.noteable.id).to eq(commit.id)
- end
-
- it "is recognized by #legacy_diff_note?" do
- expect(note).to be_legacy_diff_note
- end
- end
-
- describe '#active?' do
- it 'is always true when the note has no associated diff line' do
- note = build(:legacy_diff_note_on_merge_request)
-
- expect(note).to receive(:diff_line).and_return(nil)
-
- expect(note).to be_active
- end
-
- it 'is never true when the note has no noteable associated' do
- note = build(:legacy_diff_note_on_merge_request)
-
- expect(note).to receive(:diff_line).and_return(double)
- expect(note).to receive(:noteable).and_return(nil)
-
- expect(note).not_to be_active
- end
-
- it 'returns the memoized value if defined' do
- note = build(:legacy_diff_note_on_merge_request)
-
- note.instance_variable_set(:@active, 'foo')
- expect(note).not_to receive(:find_noteable_diff)
-
- expect(note.active?).to eq 'foo'
- end
-
- context 'for a merge request noteable' do
- it 'is false when noteable has no matching diff' do
- merge = build_stubbed(:merge_request, :simple)
- note = build(:legacy_diff_note_on_merge_request, noteable: merge)
-
- allow(note).to receive(:diff_line).and_return(double)
- expect(note).to receive(:find_noteable_diff).and_return(nil)
-
- expect(note).not_to be_active
- end
-
- it 'is true when noteable has a matching diff' do
- merge = create(:merge_request, :simple)
-
- # Generate a real line_code value so we know it will match. We use a
- # random line from a random diff just for funsies.
- diff = merge.raw_diffs.to_a.sample
- line = Gitlab::Diff::Parser.new.parse(diff.diff.each_line).to_a.sample
- code = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos)
-
- # We're persisting in order to trigger the set_diff callback
- note = create(:legacy_diff_note_on_merge_request, noteable: merge,
- line_code: code,
- project: merge.source_project)
-
- # Make sure we don't get a false positive from a guard clause
- expect(note).to receive(:find_noteable_diff).and_call_original
- expect(note).to be_active
- end
- end
- end
-
- describe "#discussion_id" do
- let(:note) { create(:note) }
-
- context "when it is newly created" do
- it "has a discussion id" do
- expect(note.discussion_id).not_to be_nil
- expect(note.discussion_id).to match(/\A\h{40}\z/)
- end
- end
-
- context "when it didn't store a discussion id before" do
- before do
- note.update_column(:discussion_id, nil)
- end
-
- it "has a discussion id" do
- # The discussion_id is set in `after_initialize`, so `reload` won't work
- reloaded_note = Note.find(note.id)
-
- expect(reloaded_note.discussion_id).not_to be_nil
- expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
- end
- end
- end
-end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 24e7c1b17d9..90b3a2ba42d 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -441,7 +441,7 @@ describe MergeRequest, models: true do
end
it "can't be removed when its a protected branch" do
- allow(subject.source_project).to receive(:protected_branch?).and_return(true)
+ allow(ProtectedBranch).to receive(:protected?).and_return(true)
expect(subject.can_remove_source_branch?(user)).to be_falsey
end
@@ -1224,182 +1224,6 @@ describe MergeRequest, models: true do
end
end
- context "discussion status" do
- let(:first_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) }
- let(:second_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) }
- let(:third_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) }
-
- before do
- allow(subject).to receive(:diff_discussions).and_return([first_discussion, second_discussion, third_discussion])
- end
-
- describe '#resolvable_discussions' do
- before do
- allow(first_discussion).to receive(:to_be_resolved?).and_return(true)
- allow(second_discussion).to receive(:to_be_resolved?).and_return(false)
- allow(third_discussion).to receive(:to_be_resolved?).and_return(false)
- end
-
- it 'includes only discussions that need to be resolved' do
- expect(subject.resolvable_discussions).to eq([first_discussion])
- end
- end
-
- describe '#discussions_can_be_resolved_by? user' do
- let(:user) { build(:user) }
-
- context 'all discussions can be resolved by the user' do
- before do
- allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true)
- allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true)
- allow(third_discussion).to receive(:can_resolve?).with(user).and_return(true)
- end
-
- it 'allows a user to resolve the discussions' do
- expect(subject.discussions_can_be_resolved_by?(user)).to be(true)
- end
- end
-
- context 'one discussion cannot be resolved by the user' do
- before do
- allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true)
- allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true)
- allow(third_discussion).to receive(:can_resolve?).with(user).and_return(false)
- end
-
- it 'allows a user to resolve the discussions' do
- expect(subject.discussions_can_be_resolved_by?(user)).to be(false)
- end
- end
- end
-
- describe "#discussions_resolvable?" do
- context "when all discussions are unresolvable" do
- before do
- allow(first_discussion).to receive(:resolvable?).and_return(false)
- allow(second_discussion).to receive(:resolvable?).and_return(false)
- allow(third_discussion).to receive(:resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.discussions_resolvable?).to be false
- end
- end
-
- context "when some discussions are unresolvable and some discussions are resolvable" do
- before do
- allow(first_discussion).to receive(:resolvable?).and_return(true)
- allow(second_discussion).to receive(:resolvable?).and_return(false)
- allow(third_discussion).to receive(:resolvable?).and_return(true)
- end
-
- it "returns true" do
- expect(subject.discussions_resolvable?).to be true
- end
- end
-
- context "when all discussions are resolvable" do
- before do
- allow(first_discussion).to receive(:resolvable?).and_return(true)
- allow(second_discussion).to receive(:resolvable?).and_return(true)
- allow(third_discussion).to receive(:resolvable?).and_return(true)
- end
-
- it "returns true" do
- expect(subject.discussions_resolvable?).to be true
- end
- end
- end
-
- describe "#discussions_resolved?" do
- context "when discussions are not resolvable" do
- before do
- allow(subject).to receive(:discussions_resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.discussions_resolved?).to be false
- end
- end
-
- context "when discussions are resolvable" do
- before do
- allow(subject).to receive(:discussions_resolvable?).and_return(true)
-
- allow(first_discussion).to receive(:resolvable?).and_return(true)
- allow(second_discussion).to receive(:resolvable?).and_return(false)
- allow(third_discussion).to receive(:resolvable?).and_return(true)
- end
-
- context "when all resolvable discussions are resolved" do
- before do
- allow(first_discussion).to receive(:resolved?).and_return(true)
- allow(third_discussion).to receive(:resolved?).and_return(true)
- end
-
- it "returns true" do
- expect(subject.discussions_resolved?).to be true
- end
- end
-
- context "when some resolvable discussions are not resolved" do
- before do
- allow(first_discussion).to receive(:resolved?).and_return(true)
- allow(third_discussion).to receive(:resolved?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.discussions_resolved?).to be false
- end
- end
- end
- end
-
- describe "#discussions_to_be_resolved?" do
- context "when discussions are not resolvable" do
- before do
- allow(subject).to receive(:discussions_resolvable?).and_return(false)
- end
-
- it "returns false" do
- expect(subject.discussions_to_be_resolved?).to be false
- end
- end
-
- context "when discussions are resolvable" do
- before do
- allow(subject).to receive(:discussions_resolvable?).and_return(true)
-
- allow(first_discussion).to receive(:resolvable?).and_return(true)
- allow(second_discussion).to receive(:resolvable?).and_return(false)
- allow(third_discussion).to receive(:resolvable?).and_return(true)
- end
-
- context "when all resolvable discussions are resolved" do
- before do
- allow(first_discussion).to receive(:resolved?).and_return(true)
- allow(third_discussion).to receive(:resolved?).and_return(true)
- end
-
- it "returns false" do
- expect(subject.discussions_to_be_resolved?).to be false
- end
- end
-
- context "when some resolvable discussions are not resolved" do
- before do
- allow(first_discussion).to receive(:resolved?).and_return(true)
- allow(third_discussion).to receive(:resolved?).and_return(false)
- end
-
- it "returns true" do
- expect(subject.discussions_to_be_resolved?).to be true
- end
- end
- end
- end
- end
-
describe '#conflicts_can_be_resolved_in_ui?' do
def create_merge_request(source_branch)
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr|
diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb
index f3f48f951a8..e3e8e6d571c 100644
--- a/spec/models/milestone_spec.rb
+++ b/spec/models/milestone_spec.rb
@@ -109,18 +109,6 @@ describe Milestone, models: true do
it { expect(milestone.percent_complete(user)).to eq(75) }
end
- describe '#is_empty?' do
- before do
- milestone.issues << create(:issue, project: project)
- milestone.issues << create(:closed_issue, project: project)
- milestone.merge_requests << create(:merge_request)
- end
-
- it { expect(milestone.closed_items_count(user)).to eq(1) }
- it { expect(milestone.total_items_count(user)).to eq(3) }
- it { expect(milestone.is_empty?(user)).to be_falsey }
- end
-
describe '#can_be_closed?' do
it { expect(milestone.can_be_closed?).to be_truthy }
end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 33536487c41..3c4bf3f4ddb 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -245,14 +245,28 @@ describe Note, models: true do
end
end
+ describe '.find_discussion' do
+ let!(:note) { create(:discussion_note_on_merge_request) }
+ let!(:note2) { create(:discussion_note_on_merge_request, in_reply_to: note) }
+ let(:merge_request) { note.noteable }
+
+ it 'returns a discussion with multiple notes' do
+ discussion = merge_request.notes.find_discussion(note.discussion_id)
+
+ expect(discussion).not_to be_nil
+ expect(discussion.notes).to match_array([note, note2])
+ expect(discussion.first_note.discussion_id).to eq(note.discussion_id)
+ end
+ end
+
describe ".grouped_diff_discussions" do
let!(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
let!(:active_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
- let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
+ let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: active_diff_note1) }
let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) }
let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) }
- let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) }
+ let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: outdated_diff_note1) }
let(:active_position2) do
Gitlab::Diff::Position.new(
@@ -277,7 +291,7 @@ describe Note, models: true do
subject { merge_request.notes.grouped_diff_discussions }
it "includes active discussions" do
- discussions = subject.values
+ discussions = subject.values.flatten
expect(discussions.count).to eq(2)
expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id])
@@ -288,37 +302,12 @@ describe Note, models: true do
end
it "doesn't include outdated discussions" do
- expect(subject.values.map(&:id)).not_to include(outdated_diff_note1.discussion_id)
+ expect(subject.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id)
end
it "groups the discussions by line code" do
- expect(subject[active_diff_note1.line_code].id).to eq(active_diff_note1.discussion_id)
- expect(subject[active_diff_note3.line_code].id).to eq(active_diff_note3.discussion_id)
- end
- end
-
- describe "#discussion_id" do
- let(:note) { create(:note) }
-
- context "when it is newly created" do
- it "has a discussion id" do
- expect(note.discussion_id).not_to be_nil
- expect(note.discussion_id).to match(/\A\h{40}\z/)
- end
- end
-
- context "when it didn't store a discussion id before" do
- before do
- note.update_column(:discussion_id, nil)
- end
-
- it "has a discussion id" do
- # The discussion_id is set in `after_initialize`, so `reload` won't work
- reloaded_note = Note.find(note.id)
-
- expect(reloaded_note.discussion_id).not_to be_nil
- expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
- end
+ expect(subject[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id)
+ expect(subject[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id)
end
end
@@ -388,6 +377,248 @@ describe Note, models: true do
end
end
+ describe '#can_be_discussion_note?' do
+ context 'for a note on a merge request' do
+ it 'returns true' do
+ note = build(:note_on_merge_request)
+
+ expect(note.can_be_discussion_note?).to be_truthy
+ end
+ end
+
+ context 'for a note on an issue' do
+ it 'returns true' do
+ note = build(:note_on_issue)
+
+ expect(note.can_be_discussion_note?).to be_truthy
+ end
+ end
+
+ context 'for a note on a commit' do
+ it 'returns true' do
+ note = build(:note_on_commit)
+
+ expect(note.can_be_discussion_note?).to be_truthy
+ end
+ end
+
+ context 'for a note on a snippet' do
+ it 'returns true' do
+ note = build(:note_on_project_snippet)
+
+ expect(note.can_be_discussion_note?).to be_truthy
+ end
+ end
+
+ context 'for a diff note on merge request' do
+ it 'returns false' do
+ note = build(:diff_note_on_merge_request)
+
+ expect(note.can_be_discussion_note?).to be_falsey
+ end
+ end
+
+ context 'for a diff note on commit' do
+ it 'returns false' do
+ note = build(:diff_note_on_commit)
+
+ expect(note.can_be_discussion_note?).to be_falsey
+ end
+ end
+
+ context 'for a discussion note' do
+ it 'returns false' do
+ note = build(:discussion_note_on_merge_request)
+
+ expect(note.can_be_discussion_note?).to be_falsey
+ end
+ end
+ end
+
+ describe '#discussion_class' do
+ let(:note) { build(:note_on_commit) }
+ let(:merge_request) { create(:merge_request) }
+
+ context 'when the note is displayed out of context' do
+ it 'returns OutOfContextDiscussion' do
+ expect(note.discussion_class(merge_request)).to be(OutOfContextDiscussion)
+ end
+ end
+
+ context 'when the note is displayed in the original context' do
+ it 'returns IndividualNoteDiscussion' do
+ expect(note.discussion_class(note.noteable)).to be(IndividualNoteDiscussion)
+ end
+ end
+ end
+
+ describe "#discussion_id" do
+ let(:note) { create(:note_on_commit) }
+
+ context "when it is newly created" do
+ it "has a discussion id" do
+ expect(note.discussion_id).not_to be_nil
+ expect(note.discussion_id).to match(/\A\h{40}\z/)
+ end
+ end
+
+ context "when it didn't store a discussion id before" do
+ before do
+ note.update_column(:discussion_id, nil)
+ end
+
+ it "has a discussion id" do
+ # The discussion_id is set in `after_initialize`, so `reload` won't work
+ reloaded_note = Note.find(note.id)
+
+ expect(reloaded_note.discussion_id).not_to be_nil
+ expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
+ end
+ end
+
+ context 'when the note is displayed out of context' do
+ let(:merge_request) { create(:merge_request) }
+
+ it 'overrides the discussion id' do
+ expect(note.discussion_id(merge_request)).not_to eq(note.discussion_id)
+ end
+ end
+ end
+
+ describe '#to_discussion' do
+ subject { create(:discussion_note_on_merge_request) }
+ let!(:note2) { create(:discussion_note_on_merge_request, project: subject.project, noteable: subject.noteable, in_reply_to: subject) }
+
+ it "returns a discussion with just this note" do
+ discussion = subject.to_discussion
+
+ expect(discussion.id).to eq(subject.discussion_id)
+ expect(discussion.notes).to eq([subject])
+ end
+ end
+
+ describe "#discussion" do
+ let!(:note1) { create(:discussion_note_on_merge_request) }
+ let!(:note2) { create(:diff_note_on_merge_request, project: note1.project, noteable: note1.noteable) }
+
+ context 'when the note is part of a discussion' do
+ subject { create(:discussion_note_on_merge_request, project: note1.project, noteable: note1.noteable, in_reply_to: note1) }
+
+ it "returns the discussion this note is in" do
+ discussion = subject.discussion
+
+ expect(discussion.id).to eq(subject.discussion_id)
+ expect(discussion.notes).to eq([note1, subject])
+ end
+ end
+
+ context 'when the note is not part of a discussion' do
+ subject { create(:note) }
+
+ it "returns a discussion with just this note" do
+ discussion = subject.discussion
+
+ expect(discussion.id).to eq(subject.discussion_id)
+ expect(discussion.notes).to eq([subject])
+ end
+ end
+ end
+
+ describe "#part_of_discussion?" do
+ context 'for a regular note' do
+ let(:note) { build(:note) }
+
+ it 'returns false' do
+ expect(note.part_of_discussion?).to be_falsey
+ end
+ end
+
+ context 'for a diff note' do
+ let(:note) { build(:diff_note_on_commit) }
+
+ it 'returns true' do
+ expect(note.part_of_discussion?).to be_truthy
+ end
+ end
+
+ context 'for a discussion note' do
+ let(:note) { build(:discussion_note_on_merge_request) }
+
+ it 'returns true' do
+ expect(note.part_of_discussion?).to be_truthy
+ end
+ end
+ end
+
+ describe '#in_reply_to?' do
+ context 'for a note' do
+ context 'when part of a discussion' do
+ subject { create(:discussion_note_on_issue) }
+ let(:note) { create(:discussion_note_on_issue, in_reply_to: subject) }
+
+ it 'checks if the note is in reply to the other discussion' do
+ expect(subject).to receive(:in_reply_to?).with(note).and_call_original
+ expect(subject).to receive(:in_reply_to?).with(note.noteable).and_call_original
+ expect(subject).to receive(:in_reply_to?).with(note.to_discussion).and_call_original
+
+ subject.in_reply_to?(note)
+ end
+ end
+
+ context 'when not part of a discussion' do
+ subject { create(:note) }
+ let(:note) { create(:note, in_reply_to: subject) }
+
+ it 'checks if the note is in reply to the other noteable' do
+ expect(subject).to receive(:in_reply_to?).with(note).and_call_original
+ expect(subject).to receive(:in_reply_to?).with(note.noteable).and_call_original
+
+ subject.in_reply_to?(note)
+ end
+ end
+ end
+
+ context 'for a discussion' do
+ context 'when part of the same discussion' do
+ subject { create(:diff_note_on_merge_request) }
+ let(:note) { create(:diff_note_on_merge_request, in_reply_to: subject) }
+
+ it 'returns true' do
+ expect(subject.in_reply_to?(note.to_discussion)).to be_truthy
+ end
+ end
+
+ context 'when not part of the same discussion' do
+ subject { create(:diff_note_on_merge_request) }
+ let(:note) { create(:diff_note_on_merge_request) }
+
+ it 'returns false' do
+ expect(subject.in_reply_to?(note.to_discussion)).to be_falsey
+ end
+ end
+ end
+
+ context 'for a noteable' do
+ context 'when a comment on the same noteable' do
+ subject { create(:note) }
+ let(:note) { create(:note, in_reply_to: subject) }
+
+ it 'returns true' do
+ expect(subject.in_reply_to?(note.noteable)).to be_truthy
+ end
+ end
+
+ context 'when not a comment on the same noteable' do
+ subject { create(:note) }
+ let(:note) { create(:note) }
+
+ it 'returns false' do
+ expect(subject.in_reply_to?(note.noteable)).to be_falsey
+ end
+ end
+ end
+ end
+
describe 'expiring ETag cache' do
let(:note) { build(:note_on_issue) }
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 6d4ef78f8ec..92d420337f9 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -704,25 +704,6 @@ describe Project, models: true do
end
end
- describe '#open_branches' do
- let(:project) { create(:project, :repository) }
-
- before do
- project.protected_branches.create(name: 'master')
- end
-
- it { expect(project.open_branches.map(&:name)).to include('feature') }
- it { expect(project.open_branches.map(&:name)).not_to include('master') }
-
- it "includes branches matching a protected branch wildcard" do
- expect(project.open_branches.map(&:name)).to include('feature')
-
- create(:protected_branch, name: 'feat*', project: project)
-
- expect(Project.find(project.id).open_branches.map(&:name)).to include('feature')
- end
- end
-
describe '#star_count' do
it 'counts stars from multiple users' do
user1 = create :user
@@ -1297,62 +1278,6 @@ describe Project, models: true do
end
end
- describe '#protected_branch?' do
- context 'existing project' do
- let(:project) { create(:project, :repository) }
-
- it 'returns true when the branch matches a protected branch via direct match' do
- create(:protected_branch, project: project, name: "foo")
-
- expect(project.protected_branch?('foo')).to eq(true)
- end
-
- it 'returns true when the branch matches a protected branch via wildcard match' do
- create(:protected_branch, project: project, name: "production/*")
-
- expect(project.protected_branch?('production/some-branch')).to eq(true)
- end
-
- it 'returns false when the branch does not match a protected branch via direct match' do
- expect(project.protected_branch?('foo')).to eq(false)
- end
-
- it 'returns false when the branch does not match a protected branch via wildcard match' do
- create(:protected_branch, project: project, name: "production/*")
-
- expect(project.protected_branch?('staging/some-branch')).to eq(false)
- end
- end
-
- context "new project" do
- let(:project) { create(:empty_project) }
-
- it 'returns false when default_protected_branch is unprotected' do
- stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE)
-
- expect(project.protected_branch?('master')).to be false
- end
-
- it 'returns false when default_protected_branch lets developers push' do
- stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH)
-
- expect(project.protected_branch?('master')).to be false
- end
-
- it 'returns true when default_branch_protection does not let developers push but let developer merge branches' do
- stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
-
- expect(project.protected_branch?('master')).to be true
- end
-
- it 'returns true when default_branch_protection is in full protection' do
- stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL)
-
- expect(project.protected_branch?('master')).to be true
- end
- end
- end
-
describe '#user_can_push_to_empty_repo?' do
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
@@ -1949,7 +1874,7 @@ describe Project, models: true do
describe '#pipeline_status' do
let(:project) { create(:project) }
it 'builds a pipeline status' do
- expect(project.pipeline_status).to be_a(Ci::PipelineStatus)
+ expect(project.pipeline_status).to be_a(Gitlab::Cache::Ci::ProjectPipelineStatus)
end
it 'hase a loaded pipeline status' do
diff --git a/spec/models/protectable_dropdown_spec.rb b/spec/models/protectable_dropdown_spec.rb
new file mode 100644
index 00000000000..4c9bade592b
--- /dev/null
+++ b/spec/models/protectable_dropdown_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe ProtectableDropdown, models: true do
+ let(:project) { create(:project, :repository) }
+ let(:subject) { described_class.new(project, :branches) }
+
+ describe '#protectable_ref_names' do
+ before do
+ project.protected_branches.create(name: 'master')
+ end
+
+ it { expect(subject.protectable_ref_names).to include('feature') }
+ it { expect(subject.protectable_ref_names).not_to include('master') }
+
+ it "includes branches matching a protected branch wildcard" do
+ expect(subject.protectable_ref_names).to include('feature')
+
+ create(:protected_branch, name: 'feat*', project: project)
+
+ subject = described_class.new(project.reload, :branches)
+
+ expect(subject.protectable_ref_names).to include('feature')
+ end
+ end
+end
diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb
index 8bf0d24a128..179a443c43d 100644
--- a/spec/models/protected_branch_spec.rb
+++ b/spec/models/protected_branch_spec.rb
@@ -113,8 +113,8 @@ describe ProtectedBranch, models: true do
staging = build(:protected_branch, name: "staging")
expect(ProtectedBranch.matching("production")).to be_empty
- expect(ProtectedBranch.matching("production", protected_branches: [production, staging])).to include(production)
- expect(ProtectedBranch.matching("production", protected_branches: [production, staging])).not_to include(staging)
+ expect(ProtectedBranch.matching("production", protected_refs: [production, staging])).to include(production)
+ expect(ProtectedBranch.matching("production", protected_refs: [production, staging])).not_to include(staging)
end
end
@@ -132,8 +132,64 @@ describe ProtectedBranch, models: true do
staging = build(:protected_branch, name: "staging/*")
expect(ProtectedBranch.matching("production/some-branch")).to be_empty
- expect(ProtectedBranch.matching("production/some-branch", protected_branches: [production, staging])).to include(production)
- expect(ProtectedBranch.matching("production/some-branch", protected_branches: [production, staging])).not_to include(staging)
+ expect(ProtectedBranch.matching("production/some-branch", protected_refs: [production, staging])).to include(production)
+ expect(ProtectedBranch.matching("production/some-branch", protected_refs: [production, staging])).not_to include(staging)
+ end
+ end
+ end
+
+ describe '#protected?' do
+ context 'existing project' do
+ let(:project) { create(:project, :repository) }
+
+ it 'returns true when the branch matches a protected branch via direct match' do
+ create(:protected_branch, project: project, name: "foo")
+
+ expect(ProtectedBranch.protected?(project, 'foo')).to eq(true)
+ end
+
+ it 'returns true when the branch matches a protected branch via wildcard match' do
+ create(:protected_branch, project: project, name: "production/*")
+
+ expect(ProtectedBranch.protected?(project, 'production/some-branch')).to eq(true)
+ end
+
+ it 'returns false when the branch does not match a protected branch via direct match' do
+ expect(ProtectedBranch.protected?(project, 'foo')).to eq(false)
+ end
+
+ it 'returns false when the branch does not match a protected branch via wildcard match' do
+ create(:protected_branch, project: project, name: "production/*")
+
+ expect(ProtectedBranch.protected?(project, 'staging/some-branch')).to eq(false)
+ end
+ end
+
+ context "new project" do
+ let(:project) { create(:empty_project) }
+
+ it 'returns false when default_protected_branch is unprotected' do
+ stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE)
+
+ expect(ProtectedBranch.protected?(project, 'master')).to be false
+ end
+
+ it 'returns false when default_protected_branch lets developers push' do
+ stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH)
+
+ expect(ProtectedBranch.protected?(project, 'master')).to be false
+ end
+
+ it 'returns true when default_branch_protection does not let developers push but let developer merge branches' do
+ stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
+
+ expect(ProtectedBranch.protected?(project, 'master')).to be true
+ end
+
+ it 'returns true when default_branch_protection is in full protection' do
+ stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL)
+
+ expect(ProtectedBranch.protected?(project, 'master')).to be true
end
end
end
diff --git a/spec/models/protected_tag_spec.rb b/spec/models/protected_tag_spec.rb
new file mode 100644
index 00000000000..51353852a93
--- /dev/null
+++ b/spec/models/protected_tag_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+
+describe ProtectedTag, models: true do
+ describe 'Associations' do
+ it { is_expected.to belong_to(:project) }
+ end
+
+ describe 'Validation' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:name) }
+ end
+end
diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb
new file mode 100644
index 00000000000..6b7eef388be
--- /dev/null
+++ b/spec/models/sent_notification_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe SentNotification, model: true do
+ describe 'validation' do
+ describe 'note validity' do
+ context "when the project doesn't match the noteable's project" do
+ subject { build(:sent_notification, noteable: create(:issue)) }
+
+ it "is invalid" do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context "when the project doesn't match the discussion project" do
+ let(:discussion_id) { create(:note).discussion_id }
+ subject { build(:sent_notification, in_reply_to_discussion_id: discussion_id) }
+
+ it "is invalid" do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context "when the noteable project and discussion project match" do
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:discussion_id) { create(:note, project: project, noteable: issue).discussion_id }
+ subject { build(:sent_notification, project: project, noteable: issue, in_reply_to_discussion_id: discussion_id) }
+
+ it "is valid" do
+ expect(subject).to be_valid
+ end
+ end
+ end
+ end
+
+ describe '.record' do
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue) }
+
+ it 'creates a new SentNotification' do
+ expect { described_class.record(issue, user.id) }.to change { SentNotification.count }.by(1)
+ end
+ end
+
+ describe '.record_note' do
+ let(:user) { create(:user) }
+ let(:note) { create(:diff_note_on_merge_request) }
+
+ it 'creates a new SentNotification' do
+ expect { described_class.record_note(note, user.id) }.to change { SentNotification.count }.by(1)
+ end
+ end
+
+ describe '#create_reply' do
+ context 'for issue' do
+ let(:issue) { create(:issue) }
+ subject { described_class.record(issue, issue.author.id) }
+
+ it 'creates a comment on the issue' do
+ note = subject.create_reply('Test')
+ expect(note.in_reply_to?(issue)).to be_truthy
+ end
+ end
+
+ context 'for issue comment' do
+ let(:note) { create(:note_on_issue) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a comment on the issue' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'for issue discussion' do
+ let(:note) { create(:discussion_note_on_issue) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a reply on the discussion' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'for merge request' do
+ let(:merge_request) { create(:merge_request) }
+ subject { described_class.record(merge_request, merge_request.author.id) }
+
+ it 'creates a comment on the merge_request' do
+ note = subject.create_reply('Test')
+ expect(note.in_reply_to?(merge_request)).to be_truthy
+ end
+ end
+
+ context 'for merge request comment' do
+ let(:note) { create(:note_on_merge_request) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a comment on the merge request' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'for merge request diff discussion' do
+ let(:note) { create(:diff_note_on_merge_request) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a reply on the discussion' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'for merge request non-diff discussion' do
+ let(:note) { create(:discussion_note_on_merge_request) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a reply on the discussion' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'for commit' do
+ let(:project) { create(:project) }
+ let(:commit) { project.commit }
+ subject { described_class.record(commit, project.creator.id) }
+
+ it 'creates a comment on the commit' do
+ note = subject.create_reply('Test')
+ expect(note.in_reply_to?(commit)).to be_truthy
+ end
+ end
+
+ context 'for commit comment' do
+ let(:note) { create(:note_on_commit) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a comment on the commit' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'for commit diff discussion' do
+ let(:note) { create(:diff_note_on_commit) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a reply on the discussion' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'for commit non-diff discussion' do
+ let(:note) { create(:discussion_note_on_commit) }
+ subject { described_class.record_note(note, note.author.id) }
+
+ it 'creates a reply on the discussion' do
+ new_note = subject.create_reply('Test')
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 6f7b9c2388a..9de16c41e94 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -315,7 +315,7 @@ describe User, models: true do
end
describe "Respond to" do
- it { is_expected.to respond_to(:is_admin?) }
+ it { is_expected.to respond_to(:admin?) }
it { is_expected.to respond_to(:name) }
it { is_expected.to respond_to(:private_token) }
it { is_expected.to respond_to(:external?) }
@@ -586,7 +586,7 @@ describe User, models: true do
describe 'normal user' do
let(:user) { create(:user, name: 'John Smith') }
- it { expect(user.is_admin?).to be_falsey }
+ it { expect(user.admin?).to be_falsey }
it { expect(user.require_ssh_key?).to be_truthy }
it { expect(user.can_create_group?).to be_truthy }
it { expect(user.can_create_project?).to be_truthy }
diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb
index 7a35da38b2b..2190ab0e82e 100644
--- a/spec/presenters/ci/build_presenter_spec.rb
+++ b/spec/presenters/ci/build_presenter_spec.rb
@@ -57,6 +57,32 @@ describe Ci::BuildPresenter do
end
end
+ describe '#status_title' do
+ context 'when build is auto-canceled' do
+ before do
+ expect(build).to receive(:auto_canceled?).and_return(true)
+ expect(build).to receive(:auto_canceled_by_id).and_return(1)
+ end
+
+ it 'shows that the build is auto-canceled' do
+ status_title = presenter.status_title
+
+ expect(status_title).to include('auto-canceled')
+ expect(status_title).to include('Pipeline #1')
+ end
+ end
+
+ context 'when build is not auto-canceled' do
+ before do
+ expect(build).to receive(:auto_canceled?).and_return(false)
+ end
+
+ it 'does not have a status title' do
+ expect(presenter.status_title).to be_nil
+ end
+ end
+ end
+
describe 'quack like a Ci::Build permission-wise' do
context 'user is not allowed' do
let(:project) { build_stubbed(:empty_project, public_builds: false) }
diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb
new file mode 100644
index 00000000000..9134d1cc31c
--- /dev/null
+++ b/spec/presenters/ci/pipeline_presenter_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe Ci::PipelinePresenter do
+ let(:project) { create(:empty_project) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ subject(:presenter) do
+ described_class.new(pipeline)
+ end
+
+ it 'inherits from Gitlab::View::Presenter::Delegated' do
+ expect(described_class.superclass).to eq(Gitlab::View::Presenter::Delegated)
+ end
+
+ describe '#initialize' do
+ it 'takes a pipeline and optional params' do
+ expect { presenter }.not_to raise_error
+ end
+
+ it 'exposes pipeline' do
+ expect(presenter.pipeline).to eq(pipeline)
+ end
+
+ it 'forwards missing methods to pipeline' do
+ expect(presenter.ref).to eq(pipeline.ref)
+ end
+ end
+
+ describe '#status_title' do
+ context 'when pipeline is auto-canceled' do
+ before do
+ expect(pipeline).to receive(:auto_canceled?).and_return(true)
+ expect(pipeline).to receive(:auto_canceled_by_id).and_return(1)
+ end
+
+ it 'shows that the pipeline is auto-canceled' do
+ status_title = presenter.status_title
+
+ expect(status_title).to include('auto-canceled')
+ expect(status_title).to include('Pipeline #1')
+ end
+ end
+
+ context 'when pipeline is not auto-canceled' do
+ before do
+ expect(pipeline).to receive(:auto_canceled?).and_return(false)
+ end
+
+ it 'does not have a status title' do
+ expect(presenter.status_title).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index 2b27ce6390a..551aae7d701 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -840,7 +840,7 @@ describe API::Issues, api: true do
end
context 'resolving discussions' do
- let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
+ let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
let(:merge_request) { discussion.noteable }
let(:project) { merge_request.source_project }
diff --git a/spec/requests/api/session_spec.rb b/spec/requests/api/session_spec.rb
index 28fab2011a5..393bf076616 100644
--- a/spec/requests/api/session_spec.rb
+++ b/spec/requests/api/session_spec.rb
@@ -13,7 +13,7 @@ describe API::Session, api: true do
expect(json_response['email']).to eq(user.email)
expect(json_response['private_token']).to eq(user.private_token)
- expect(json_response['is_admin']).to eq(user.is_admin?)
+ expect(json_response['is_admin']).to eq(user.admin?)
expect(json_response['can_create_project']).to eq(user.can_create_project?)
expect(json_response['can_create_group']).to eq(user.can_create_group?)
end
@@ -37,7 +37,7 @@ describe API::Session, api: true do
expect(json_response['email']).to eq user.email
expect(json_response['private_token']).to eq user.private_token
- expect(json_response['is_admin']).to eq user.is_admin?
+ expect(json_response['is_admin']).to eq user.admin?
expect(json_response['can_create_project']).to eq user.can_create_project?
expect(json_response['can_create_group']).to eq user.can_create_group?
end
@@ -50,7 +50,7 @@ describe API::Session, api: true do
expect(json_response['email']).to eq user.email
expect(json_response['private_token']).to eq user.private_token
- expect(json_response['is_admin']).to eq user.is_admin?
+ expect(json_response['is_admin']).to eq user.admin?
expect(json_response['can_create_project']).to eq user.can_create_project?
expect(json_response['can_create_group']).to eq user.can_create_group?
end
diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb
index b1b398a897e..91d9057075f 100644
--- a/spec/requests/api/v3/issues_spec.rb
+++ b/spec/requests/api/v3/issues_spec.rb
@@ -824,7 +824,7 @@ describe API::V3::Issues, api: true do
end
context 'resolving issues in a merge request' do
- let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
+ let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
let(:merge_request) { discussion.noteable }
let(:project) { merge_request.source_project }
before do
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index d2f0337c260..fa5014cee07 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -9,72 +9,140 @@ describe Ci::CreatePipelineService, services: true do
end
describe '#execute' do
- def execute(params)
+ def execute_service(after: project.commit.id, message: 'Message', ref: 'refs/heads/master')
+ params = { ref: ref,
+ before: '00000000',
+ after: after,
+ commits: [{ message: message }] }
+
described_class.new(project, user, params).execute
end
context 'valid params' do
- let(:pipeline) do
- execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: [{ message: "Message" }])
+ let(:pipeline) { execute_service }
+
+ let(:pipeline_on_previous_commit) do
+ execute_service(
+ after: previous_commit_sha_from_ref('master')
+ )
end
it { expect(pipeline).to be_kind_of(Ci::Pipeline) }
it { expect(pipeline).to be_valid }
- it { expect(pipeline).to be_persisted }
it { expect(pipeline).to eq(project.pipelines.last) }
it { expect(pipeline).to have_attributes(user: user) }
+ it { expect(pipeline).to have_attributes(status: 'pending') }
it { expect(pipeline.builds.first).to be_kind_of(Ci::Build) }
+
+ context 'auto-cancel enabled' do
+ before do
+ project.update(auto_cancel_pending_pipelines: 'enabled')
+ end
+
+ it 'does not cancel HEAD pipeline' do
+ pipeline
+ pipeline_on_previous_commit
+
+ expect(pipeline.reload).to have_attributes(status: 'pending', auto_canceled_by_id: nil)
+ end
+
+ it 'auto cancel pending non-HEAD pipelines' do
+ pipeline_on_previous_commit
+ pipeline
+
+ expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'canceled', auto_canceled_by_id: pipeline.id)
+ end
+
+ it 'does not cancel running outdated pipelines' do
+ pipeline_on_previous_commit.run
+ execute_service
+
+ expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'running', auto_canceled_by_id: nil)
+ end
+
+ it 'cancel created outdated pipelines' do
+ pipeline_on_previous_commit.update(status: 'created')
+ pipeline
+
+ expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'canceled', auto_canceled_by_id: pipeline.id)
+ end
+
+ it 'does not cancel pipelines from the other branches' do
+ pending_pipeline = execute_service(
+ ref: 'refs/heads/feature',
+ after: previous_commit_sha_from_ref('feature')
+ )
+ pipeline
+
+ expect(pending_pipeline.reload).to have_attributes(status: 'pending', auto_canceled_by_id: nil)
+ end
+ end
+
+ context 'auto-cancel disabled' do
+ before do
+ project.update(auto_cancel_pending_pipelines: 'disabled')
+ end
+
+ it 'does not auto cancel pending non-HEAD pipelines' do
+ pipeline_on_previous_commit
+ pipeline
+
+ expect(pipeline_on_previous_commit.reload)
+ .to have_attributes(status: 'pending', auto_canceled_by_id: nil)
+ end
+ end
+
+ def previous_commit_sha_from_ref(ref)
+ project.commit(ref).parent.sha
+ end
end
context "skip tag if there is no build for it" do
it "creates commit if there is appropriate job" do
- result = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: [{ message: "Message" }])
- expect(result).to be_persisted
+ expect(execute_service).to be_persisted
end
it "creates commit if there is no appropriate job but deploy job has right ref setting" do
config = YAML.dump({ deploy: { script: "ls", only: ["master"] } })
stub_ci_pipeline_yaml_file(config)
- result = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: [{ message: "Message" }])
- expect(result).to be_persisted
+ expect(execute_service).to be_persisted
end
end
it 'skips creating pipeline for refs without .gitlab-ci.yml' do
stub_ci_pipeline_yaml_file(nil)
- result = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: [{ message: 'Message' }])
- expect(result).not_to be_persisted
+ expect(execute_service).not_to be_persisted
expect(Ci::Pipeline.count).to eq(0)
end
- it 'fails commits if yaml is invalid' do
- message = 'message'
- allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message }
- stub_ci_pipeline_yaml_file('invalid: file: file')
- commits = [{ message: message }]
- pipeline = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: commits)
-
- expect(pipeline).to be_persisted
- expect(pipeline.builds.any?).to be false
- expect(pipeline.status).to eq('failed')
- expect(pipeline.yaml_errors).not_to be_nil
+ shared_examples 'a failed pipeline' do
+ it 'creates failed pipeline' do
+ stub_ci_pipeline_yaml_file(ci_yaml)
+
+ pipeline = execute_service(message: message)
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.builds.any?).to be false
+ expect(pipeline.status).to eq('failed')
+ expect(pipeline.yaml_errors).not_to be_nil
+ end
+ end
+
+ context 'when yaml is invalid' do
+ let(:ci_yaml) { 'invalid: file: fiile' }
+ let(:message) { 'Message' }
+
+ it_behaves_like 'a failed pipeline'
+
+ context 'when receive git commit' do
+ before do
+ allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message }
+ end
+
+ it_behaves_like 'a failed pipeline'
+ end
end
context 'when commit contains a [ci skip] directive' do
@@ -97,11 +165,7 @@ describe Ci::CreatePipelineService, services: true do
ci_messages.each do |ci_message|
it "skips builds creation if the commit message is #{ci_message}" do
- commits = [{ message: ci_message }]
- pipeline = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: commits)
+ pipeline = execute_service(message: ci_message)
expect(pipeline).to be_persisted
expect(pipeline.builds.any?).to be false
@@ -109,58 +173,34 @@ describe Ci::CreatePipelineService, services: true do
end
end
- it "does not skips builds creation if there is no [ci skip] or [skip ci] tag in commit message" do
- allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { "some message" }
+ shared_examples 'creating a pipeline' do
+ it 'does not skip pipeline creation' do
+ allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { commit_message }
- commits = [{ message: "some message" }]
- pipeline = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: commits)
+ pipeline = execute_service(message: commit_message)
- expect(pipeline).to be_persisted
- expect(pipeline.builds.first.name).to eq("rspec")
+ expect(pipeline).to be_persisted
+ expect(pipeline.builds.first.name).to eq("rspec")
+ end
end
- it "does not skip builds creation if the commit message is nil" do
- allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { nil }
-
- commits = [{ message: nil }]
- pipeline = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: commits)
+ context 'when commit message does not contain [ci skip] nor [skip ci]' do
+ let(:commit_message) { 'some message' }
- expect(pipeline).to be_persisted
- expect(pipeline.builds.first.name).to eq("rspec")
+ it_behaves_like 'creating a pipeline'
end
- it "fails builds creation if there is [ci skip] tag in commit message and yaml is invalid" do
- stub_ci_pipeline_yaml_file('invalid: file: fiile')
- commits = [{ message: message }]
- pipeline = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: commits)
+ context 'when commit message is nil' do
+ let(:commit_message) { nil }
- expect(pipeline).to be_persisted
- expect(pipeline.builds.any?).to be false
- expect(pipeline.status).to eq("failed")
- expect(pipeline.yaml_errors).not_to be_nil
+ it_behaves_like 'creating a pipeline'
end
- end
- it "creates commit with failed status if yaml is invalid" do
- stub_ci_pipeline_yaml_file('invalid: file')
- commits = [{ message: "some message" }]
- pipeline = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: commits)
-
- expect(pipeline).to be_persisted
- expect(pipeline.status).to eq("failed")
- expect(pipeline.builds.any?).to be false
+ context 'when there is [ci skip] tag in commit message and yaml is invalid' do
+ let(:ci_yaml) { 'invalid: file: fiile' }
+
+ it_behaves_like 'a failed pipeline'
+ end
end
context 'when there are no jobs for this pipeline' do
@@ -170,10 +210,7 @@ describe Ci::CreatePipelineService, services: true do
end
it 'does not create a new pipeline' do
- result = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: [{ message: 'some msg' }])
+ result = execute_service
expect(result).not_to be_persisted
expect(Ci::Build.all).to be_empty
@@ -188,10 +225,7 @@ describe Ci::CreatePipelineService, services: true do
end
it 'does not create a new pipeline' do
- result = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: [{ message: 'some msg' }])
+ result = execute_service
expect(result).to be_persisted
expect(result.manual_actions).not_to be_empty
@@ -205,10 +239,7 @@ describe Ci::CreatePipelineService, services: true do
end
it 'creates the environment' do
- result = execute(ref: 'refs/heads/master',
- before: '00000000',
- after: project.commit.id,
- commits: [{ message: 'some msg' }])
+ result = execute_service
expect(result).to be_persisted
expect(Environment.find_by(name: "review/master")).not_to be_nil
diff --git a/spec/services/ci/expire_pipeline_cache_service_spec.rb b/spec/services/ci/expire_pipeline_cache_service_spec.rb
index 3c735872c30..166c6dfc93e 100644
--- a/spec/services/ci/expire_pipeline_cache_service_spec.rb
+++ b/spec/services/ci/expire_pipeline_cache_service_spec.rb
@@ -16,5 +16,12 @@ describe Ci::ExpirePipelineCacheService, services: true do
subject.execute(pipeline)
end
+
+ it 'updates the cached status for a project' do
+ expect(Gitlab::Cache::Ci::ProjectPipelineStatus).to receive(:update_for_pipeline).
+ with(pipeline)
+
+ subject.execute(pipeline)
+ end
end
end
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 8567817147b..b2d37657770 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -16,20 +16,21 @@ describe Ci::RetryBuildService, :services do
%i[id status user token coverage trace runner artifacts_expire_at
artifacts_file artifacts_metadata artifacts_size created_at
updated_at started_at finished_at queued_at erased_by
- erased_at].freeze
+ erased_at auto_canceled_by].freeze
IGNORE_ACCESSORS =
%i[type lock_version target_url base_tags
commit_id deployments erased_by_id last_deployment project_id
runner_id tag_taggings taggings tags trigger_request_id
- user_id].freeze
+ user_id auto_canceled_by_id].freeze
shared_examples 'build duplication' do
let(:build) do
create(:ci_build, :failed, :artifacts_expired, :erased,
:queued, :coverage, :tags, :allowed_to_fail, :on_tag,
:teardown_environment, :triggered, :trace,
- description: 'some build', pipeline: pipeline)
+ description: 'some build', pipeline: pipeline,
+ auto_canceled_by: create(:ci_empty_pipeline))
end
describe 'clone accessors' do
diff --git a/spec/services/discussions/resolve_service_spec.rb b/spec/services/discussions/resolve_service_spec.rb
index 12c3cdf28c6..ab8df7b74cd 100644
--- a/spec/services/discussions/resolve_service_spec.rb
+++ b/spec/services/discussions/resolve_service_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Discussions::ResolveService do
describe '#execute' do
- let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
+ let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
let(:project) { merge_request.project }
let(:merge_request) { discussion.noteable }
let(:user) { create(:user) }
@@ -41,7 +41,7 @@ describe Discussions::ResolveService do
end
it 'can resolve multiple discussions at once' do
- other_discussion = Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: discussion.noteable, project: discussion.noteable.source_project)]).first
+ other_discussion = create(:diff_note_on_merge_request, noteable: discussion.noteable, project: discussion.noteable.source_project).to_discussion
service.execute([discussion, other_discussion])
diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb
index 17990f41b3b..55d635235b0 100644
--- a/spec/services/issues/build_service_spec.rb
+++ b/spec/services/issues/build_service_spec.rb
@@ -11,7 +11,7 @@ describe Issues::BuildService, services: true do
context 'for a single discussion' do
describe '#execute' do
let(:merge_request) { create(:merge_request, title: "Hello world", source_project: project) }
- let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done")]) }
+ let(:discussion) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done").to_discussion }
let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id) }
it 'references the noteable title in the issue title' do
@@ -47,7 +47,7 @@ describe Issues::BuildService, services: true do
let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) }
it 'mentions the author of the note' do
- discussion = Discussion.new([create(:diff_note_on_merge_request, author: create(:user, username: 'author'))])
+ discussion = create(:diff_note_on_merge_request, author: create(:user, username: 'author')).to_discussion
expect(service.item_for_discussion(discussion)).to include('@author')
end
@@ -60,7 +60,7 @@ describe Issues::BuildService, services: true do
note_result = " > This is a string\n"\
" > > with a blockquote\n"\
" > > > That has a quote\n"
- discussion = Discussion.new([create(:diff_note_on_merge_request, note: note_text)])
+ discussion = create(:diff_note_on_merge_request, note: note_text).to_discussion
expect(service.item_for_discussion(discussion)).to include(note_result)
end
end
@@ -91,25 +91,23 @@ describe Issues::BuildService, services: true do
end
describe 'with multiple discussions' do
- before do
- create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15)
- end
+ let!(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15) }
it 'mentions all the authors in the description' do
- authors = merge_request.diff_discussions.map(&:author)
+ authors = merge_request.resolvable_discussions.map(&:author)
expect(issue.description).to include(*authors.map(&:to_reference))
end
it 'has a link for each unresolved discussion in the description' do
- notes = merge_request.diff_discussions.map(&:first_note)
+ notes = merge_request.resolvable_discussions.map(&:first_note)
links = notes.map { |note| Gitlab::UrlBuilder.build(note) }
expect(issue.description).to include(*links)
end
it 'mentions additional notes' do
- create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, line_number: 15)
+ create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, in_reply_to: diff_note)
expect(issue.description).to include('(+2 comments)')
end
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 776cbc4296b..80bfb731550 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -141,7 +141,7 @@ describe Issues::CreateService, services: true do
it_behaves_like 'new issuable record that supports slash commands'
context 'resolving discussions' do
- let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
+ let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
let(:merge_request) { discussion.noteable }
let(:project) { merge_request.source_project }
diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb
index 3a72f92383c..4a4929daefc 100644
--- a/spec/services/issues/resolve_discussions_spec.rb
+++ b/spec/services/issues/resolve_discussions_spec.rb
@@ -18,7 +18,7 @@ describe DummyService, services: true do
end
describe "for resolving discussions" do
- let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, note: "Almost done")]) }
+ let(:discussion) { create(:diff_note_on_merge_request, project: project, note: "Almost done").to_discussion }
let(:merge_request) { discussion.noteable }
let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") }
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
new file mode 100644
index 00000000000..f9dd5541b10
--- /dev/null
+++ b/spec/services/notes/build_service_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Notes::BuildService, services: true do
+ let(:note) { create(:discussion_note_on_issue) }
+ let(:project) { note.project }
+ let(:author) { note.author }
+
+ describe '#execute' do
+ context 'when in_reply_to_discussion_id is specified' do
+ context 'when a note with that original discussion ID exists' do
+ it 'sets the note up to be in reply to that note' do
+ new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute
+ expect(new_note).to be_valid
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'when a note with that discussion ID exists' do
+ it 'sets the note up to be in reply to that note' do
+ new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute
+ expect(new_note).to be_valid
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'when no note with that discussion ID exists' do
+ it 'sets an error' do
+ new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: 'foo').execute
+ expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
+ end
+ end
+ end
+
+ it 'builds a note without saving it' do
+ new_note = described_class.new(project, author, noteable_type: note.noteable_type, noteable_id: note.noteable_id, note: 'Test').execute
+ expect(new_note).to be_valid
+ expect(new_note).not_to be_persisted
+ end
+ end
+end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index e3146a56495..617c8eaf3d5 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -439,7 +439,7 @@ describe NotificationService, services: true do
notification.new_note(note)
- expect(SentNotification.last.position).to eq(note.position)
+ expect(SentNotification.last.in_reply_to_discussion_id).to eq(note.discussion_id)
end
end
end
diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb
new file mode 100644
index 00000000000..62bdd49a4d7
--- /dev/null
+++ b/spec/services/protected_branches/update_service_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe ProtectedBranches::UpdateService, services: true do
+ let(:protected_branch) { create(:protected_branch) }
+ let(:project) { protected_branch.project }
+ let(:user) { project.owner }
+ let(:params) { { name: 'new protected branch name' } }
+
+ describe '#execute' do
+ subject(:service) { described_class.new(project, user, params) }
+
+ it 'updates a protected branch' do
+ result = service.execute(protected_branch)
+
+ expect(result.reload.name).to eq(params[:name])
+ end
+
+ context 'without admin_project permissions' do
+ let(:user) { create(:user) }
+
+ it "raises error" do
+ expect{ service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
+ end
+end
diff --git a/spec/services/protected_tags/create_service_spec.rb b/spec/services/protected_tags/create_service_spec.rb
new file mode 100644
index 00000000000..d91a58e8de5
--- /dev/null
+++ b/spec/services/protected_tags/create_service_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe ProtectedTags::CreateService, services: true do
+ let(:project) { create(:empty_project) }
+ let(:user) { project.owner }
+ let(:params) do
+ {
+ name: 'master',
+ create_access_levels_attributes: [{ access_level: Gitlab::Access::MASTER }]
+ }
+ end
+
+ describe '#execute' do
+ subject(:service) { described_class.new(project, user, params) }
+
+ it 'creates a new protected tag' do
+ expect { service.execute }.to change(ProtectedTag, :count).by(1)
+ expect(project.protected_tags.last.create_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
+ end
+ end
+end
diff --git a/spec/services/protected_tags/update_service_spec.rb b/spec/services/protected_tags/update_service_spec.rb
new file mode 100644
index 00000000000..e78fde4c48d
--- /dev/null
+++ b/spec/services/protected_tags/update_service_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe ProtectedTags::UpdateService, services: true do
+ let(:protected_tag) { create(:protected_tag) }
+ let(:project) { protected_tag.project }
+ let(:user) { project.owner }
+ let(:params) { { name: 'new protected tag name' } }
+
+ describe '#execute' do
+ subject(:service) { described_class.new(project, user, params) }
+
+ it 'updates a protected tag' do
+ result = service.execute(protected_tag)
+
+ expect(result.reload.name).to eq(params[:name])
+ end
+
+ context 'without admin_project permissions' do
+ let(:user) { create(:user) }
+
+ it "raises error" do
+ expect{ service.execute(protected_tag) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
+ end
+end
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 5ec1ed8237b..42d63a9f9ba 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -796,7 +796,7 @@ describe SystemNoteService, services: true do
end
describe '.discussion_continued_in_issue' do
- let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
+ let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
let(:merge_request) { discussion.noteable }
let(:project) { merge_request.source_project }
let(:issue) { create(:issue, project: project) }
diff --git a/spec/support/slash_commands_helpers.rb b/spec/support/slash_commands_helpers.rb
index 0d91fe5fd5d..4bfe481115f 100644
--- a/spec/support/slash_commands_helpers.rb
+++ b/spec/support/slash_commands_helpers.rb
@@ -3,7 +3,7 @@ module SlashCommandsHelpers
Sidekiq::Testing.fake! do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: text
- find('.comment-btn').trigger('click')
+ find('.js-comment-submit-button').trigger('click')
end
end
end
diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb
index 52f4fabdc47..01bc80f957e 100644
--- a/spec/support/time_tracking_shared_examples.rb
+++ b/spec/support/time_tracking_shared_examples.rb
@@ -77,6 +77,6 @@ end
def submit_time(slash_command)
fill_in 'note[note]', with: slash_command
- find('.comment-btn').trigger('click')
+ find('.js-comment-submit-button').trigger('click')
wait_for_ajax
end
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index d95baddf546..b369dcbb305 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -75,4 +75,36 @@ describe 'gitlab:gitaly namespace rake task' do
end
end
end
+
+ describe 'storage_config' do
+ it 'prints storage configuration in a TOML format' do
+ config = {
+ 'default' => { 'path' => '/path/to/default' },
+ 'nfs_01' => { 'path' => '/path/to/nfs_01' },
+ }
+ allow(Gitlab.config.repositories).to receive(:storages).and_return(config)
+
+ orig_stdout = $stdout
+ $stdout = StringIO.new
+
+ header = ''
+ Timecop.freeze do
+ header = <<~TOML
+ # Gitaly storage configuration generated from #{Gitlab.config.source} on #{Time.current.to_s(:long)}
+ # This is in TOML format suitable for use in Gitaly's config.toml file.
+ TOML
+ run_rake_task('gitlab:gitaly:storage_config')
+ end
+
+ output = $stdout.string
+ $stdout = orig_stdout
+
+ expect(output).to include(header)
+
+ parsed_output = TOML.parse(output)
+ config.each do |name, params|
+ expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params['path'] })
+ end
+ end
+ 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 55b64808fb3..0f39df0f250 100644
--- a/spec/views/projects/builds/show.html.haml_spec.rb
+++ b/spec/views/projects/builds/show.html.haml_spec.rb
@@ -9,7 +9,7 @@ describe 'projects/builds/show', :view do
end
before do
- assign(:build, build)
+ assign(:build, build.present)
assign(:project, project)
allow(view).to receive(:can?).and_return(true)
diff --git a/spec/views/projects/notes/_form.html.haml_spec.rb b/spec/views/projects/notes/_form.html.haml_spec.rb
index b61f016967f..a364f9bce92 100644
--- a/spec/views/projects/notes/_form.html.haml_spec.rb
+++ b/spec/views/projects/notes/_form.html.haml_spec.rb
@@ -4,7 +4,7 @@ describe 'projects/notes/_form' do
include Devise::Test::ControllerHelpers
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project, :repository) }
before do
project.team << [user, :master]
@@ -20,7 +20,7 @@ describe 'projects/notes/_form' do
context "with a note on #{noteable}" do
let(:note) { build(:"note_on_#{noteable}", project: project) }
- it 'says that only markdown is supported, not slash commands' do
+ it 'says that markdown and slash commands are supported' do
expect(rendered).to have_content('Markdown and slash commands are supported')
end
end
diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb
index dca78dec6df..bb39ec8efbf 100644
--- a/spec/views/projects/pipelines/show.html.haml_spec.rb
+++ b/spec/views/projects/pipelines/show.html.haml_spec.rb
@@ -5,7 +5,13 @@ describe 'projects/pipelines/show' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
- let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, user: user) }
+
+ let(:pipeline) do
+ create(:ci_empty_pipeline,
+ project: project,
+ sha: project.commit.id,
+ user: user)
+ end
before do
controller.prepend_view_path('app/views/projects')
@@ -21,7 +27,7 @@ describe 'projects/pipelines/show' do
create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3)
assign(:project, project)
- assign(:pipeline, pipeline)
+ assign(:pipeline, pipeline.present(current_user: user))
assign(:commit, project.commit)
allow(view).to receive(:can?).and_return(true)
diff --git a/spec/workers/trigger_schedule_worker_spec.rb b/spec/workers/trigger_schedule_worker_spec.rb
index 151e1c2f7b9..861bed4442e 100644
--- a/spec/workers/trigger_schedule_worker_spec.rb
+++ b/spec/workers/trigger_schedule_worker_spec.rb
@@ -8,24 +8,39 @@ describe TriggerScheduleWorker do
end
context 'when there is a scheduled trigger within next_run_at' do
+ let(:next_run_at) { 2.days.ago }
+
+ let!(:trigger_schedule) do
+ create(:ci_trigger_schedule, :nightly)
+ end
+
before do
- trigger_schedule = create(:ci_trigger_schedule, :nightly)
- time_future = Time.now + 10.days
- allow(Time).to receive(:now).and_return(time_future)
- @next_time = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone).next_time_from(time_future)
+ trigger_schedule.update_column(:next_run_at, next_run_at)
end
it 'creates a new trigger request' do
- expect { worker.perform }.to change { Ci::TriggerRequest.count }.by(1)
+ expect { worker.perform }.to change { Ci::TriggerRequest.count }
end
it 'creates a new pipeline' do
- expect { worker.perform }.to change { Ci::Pipeline.count }.by(1)
+ expect { worker.perform }.to change { Ci::Pipeline.count }
expect(Ci::Pipeline.last).to be_pending
end
it 'updates next_run_at' do
- expect { worker.perform }.to change { Ci::TriggerSchedule.last.next_run_at }.to(@next_time)
+ worker.perform
+
+ expect(trigger_schedule.reload.next_run_at).not_to eq(next_run_at)
+ end
+
+ context 'inactive schedule' do
+ before do
+ trigger_schedule.update(active: false)
+ end
+
+ it 'does not create a new trigger' do
+ expect { worker.perform }.not_to change { Ci::TriggerRequest.count }
+ end
end
end
@@ -43,8 +58,8 @@ describe TriggerScheduleWorker do
context 'when next_run_at is nil' do
before do
- trigger_schedule = create(:ci_trigger_schedule, :nightly)
- trigger_schedule.update_attribute(:next_run_at, nil)
+ schedule = create(:ci_trigger_schedule, :nightly)
+ schedule.update_column(:next_run_at, nil)
end
it 'does not create a new pipeline' do
diff --git a/yarn.lock b/yarn.lock
index 9f2b8fe3d6e..2434b3a8a48 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -25,7 +25,7 @@ acorn-jsx@^3.0.0:
dependencies:
acorn "^3.0.4"
-acorn@4.0.4, acorn@^4.0.4:
+acorn@4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.4.tgz#17a8d6a7a6c4ef538b814ec9abac2779293bf30a"
@@ -33,7 +33,7 @@ acorn@^3.0.4:
version "3.3.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
-acorn@^4.0.11, acorn@^4.0.3:
+acorn@^4.0.11, acorn@^4.0.3, acorn@^4.0.4:
version "4.0.11"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.11.tgz#edcda3bd937e7556410d42ed5860f67399c794c0"
@@ -184,7 +184,7 @@ async-each@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
-async@0.2.x, async@~0.2.6:
+async@0.2.x:
version "0.2.10"
resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
@@ -1809,7 +1809,7 @@ events@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
-eventsource@~0.1.6:
+eventsource@0.1.6:
version "0.1.6"
resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232"
dependencies:
@@ -2309,9 +2309,9 @@ http-errors@~1.5.0, http-errors@~1.5.1:
setprototypeof "1.0.2"
statuses ">= 1.3.1 < 2"
-http-proxy-middleware@~0.17.1:
- version "0.17.3"
- resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.3.tgz#940382147149b856084f5534752d5b5a8168cd1d"
+http-proxy-middleware@~0.17.4:
+ version "0.17.4"
+ resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
dependencies:
http-proxy "^1.16.2"
is-glob "^3.1.0"
@@ -3690,7 +3690,7 @@ read-pkg@^1.0.0:
normalize-package-data "^2.3.2"
path-type "^1.0.0"
-"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.0, readable-stream@^2.2.2:
+"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.2, readable-stream@^2.1.0, readable-stream@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.2.tgz#a9e6fec3c7dda85f8bb1b3ba7028604556fc825e"
dependencies:
@@ -3702,16 +3702,7 @@ read-pkg@^1.0.0:
string_decoder "~0.10.x"
util-deprecate "~1.0.1"
-readable-stream@~1.0.2:
- version "1.0.34"
- resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
- dependencies:
- core-util-is "~1.0.0"
- inherits "~2.0.1"
- isarray "0.0.1"
- string_decoder "~0.10.x"
-
-readable-stream@~2.0.0, readable-stream@~2.0.6:
+readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@~2.0.0, readable-stream@~2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e"
dependencies:
@@ -3722,6 +3713,15 @@ readable-stream@~2.0.0, readable-stream@~2.0.6:
string_decoder "~0.10.x"
util-deprecate "~1.0.1"
+readable-stream@~1.0.2:
+ version "1.0.34"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.1"
+ isarray "0.0.1"
+ string_decoder "~0.10.x"
+
readable-stream@~2.1.4:
version "2.1.5"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0"
@@ -4062,12 +4062,12 @@ socket.io@1.7.2:
socket.io-client "1.7.2"
socket.io-parser "2.3.1"
-sockjs-client@1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.1.tgz#284843e9a9784d7c474b1571b3240fca9dda4bb0"
+sockjs-client@1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.2.tgz#f0212a8550e4c9468c8cceaeefd2e3493c033ad5"
dependencies:
debug "^2.2.0"
- eventsource "~0.1.6"
+ eventsource "0.1.6"
faye-websocket "~0.11.0"
inherits "^2.0.1"
json3 "^3.3.2"
@@ -4080,6 +4080,10 @@ sockjs@0.3.18:
faye-websocket "^0.10.0"
uuid "^2.0.2"
+source-list-map@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-1.1.1.tgz#1a33ac210ca144d1e561f906ebccab5669ff4cb4"
+
source-list-map@~0.1.7:
version "0.1.8"
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106"
@@ -4406,14 +4410,14 @@ typedarray@^0.0.6, typedarray@~0.0.5:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
-uglify-js@^2.6, uglify-js@^2.7.5:
- version "2.7.5"
- resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.5.tgz#4612c0c7baaee2ba7c487de4904ae122079f2ca8"
+uglify-js@^2.6, uglify-js@^2.8.5:
+ version "2.8.21"
+ resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.21.tgz#1733f669ae6f82fc90c7b25ec0f5c783ee375314"
dependencies:
- async "~0.2.6"
source-map "~0.5.1"
- uglify-to-browserify "~1.0.0"
yargs "~3.10.0"
+ optionalDependencies:
+ uglify-to-browserify "~1.0.0"
uglify-to-browserify@~1.0.0:
version "1.0.2"
@@ -4534,9 +4538,9 @@ vue@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.4.tgz#d0a3a050a80a12356d7950ae5a7b3131048209cc"
-watchpack@^1.2.0:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.2.1.tgz#01efa80c5c29e5c56ba55d6f5470a35b6402f0b2"
+watchpack@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.3.1.tgz#7d8693907b28ce6013e7f3610aa2a1acf07dad87"
dependencies:
async "^2.1.2"
chokidar "^1.4.3"
@@ -4572,9 +4576,9 @@ webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.9.0:
path-is-absolute "^1.0.0"
range-parser "^1.0.3"
-webpack-dev-server@^2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.3.0.tgz#0437704bbd4d941a6e4c061eb3cc232ed7d06101"
+webpack-dev-server@^2.4.2:
+ version "2.4.2"
+ resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.4.2.tgz#cf595d6b40878452b6d2ad7229056b686f8a16be"
dependencies:
ansi-html "0.0.7"
chokidar "^1.6.0"
@@ -4582,28 +4586,35 @@ webpack-dev-server@^2.3.0:
connect-history-api-fallback "^1.3.0"
express "^4.13.3"
html-entities "^1.2.0"
- http-proxy-middleware "~0.17.1"
+ http-proxy-middleware "~0.17.4"
opn "4.0.2"
portfinder "^1.0.9"
serve-index "^1.7.2"
sockjs "0.3.18"
- sockjs-client "1.1.1"
+ sockjs-client "1.1.2"
spdy "^3.4.1"
strip-ansi "^3.0.0"
supports-color "^3.1.1"
webpack-dev-middleware "^1.9.0"
yargs "^6.0.0"
-webpack-sources@^0.1.0, webpack-sources@^0.1.4:
+webpack-sources@^0.1.0:
version "0.1.4"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.1.4.tgz#ccc2c817e08e5fa393239412690bb481821393cd"
dependencies:
source-list-map "~0.1.7"
source-map "~0.5.3"
-webpack@^2.2.1:
- version "2.2.1"
- resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.2.1.tgz#7bb1d72ae2087dd1a4af526afec15eed17dda475"
+webpack-sources@^0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.2.3.tgz#17c62bfaf13c707f9d02c479e0dcdde8380697fb"
+ dependencies:
+ source-list-map "^1.1.1"
+ source-map "~0.5.3"
+
+webpack@^2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.3.3.tgz#eecc083c18fb7bf958ea4f40b57a6640c5a0cc78"
dependencies:
acorn "^4.0.4"
acorn-dynamic-import "^2.0.0"
@@ -4621,9 +4632,9 @@ webpack@^2.2.1:
source-map "^0.5.3"
supports-color "^3.1.0"
tapable "~0.2.5"
- uglify-js "^2.7.5"
- watchpack "^1.2.0"
- webpack-sources "^0.1.4"
+ uglify-js "^2.8.5"
+ watchpack "^1.3.1"
+ webpack-sources "^0.2.3"
yargs "^6.0.0"
websocket-driver@>=0.5.1: